From 6ce45f6d230db5816f0230e0a527a0ec1f28dccf Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Fri, 1 May 2026 02:49:22 +0200 Subject: [PATCH] ddfsdfdf --- modules/fx-rates/assets/fx-rates.js | 80 ++++++++++++++++++- modules/fx-rates/module.json | 2 +- modules/fx-rates/pages/index.php | 36 +++++++-- modules/fx-rates/src/Api/Router.php | 4 +- .../fx-rates/src/Domain/FxRatesService.php | 26 ++++-- .../src/Infrastructure/FxRatesRepository.php | 62 +++++++++++--- partials/landingpages/modules/setup.php | 1 + 7 files changed, 180 insertions(+), 31 deletions(-) diff --git a/modules/fx-rates/assets/fx-rates.js b/modules/fx-rates/assets/fx-rates.js index 9005900..0607c5b 100644 --- a/modules/fx-rates/assets/fx-rates.js +++ b/modules/fx-rates/assets/fx-rates.js @@ -22,6 +22,82 @@ .map((item) => String(item || '').trim().toUpperCase()) .filter(Boolean) : []; + const refreshMaxAgeMinutes = Math.max(1, Number(settings.refresh_max_age_minutes || 60)); + + const parseDateValue = (value) => { + const raw = String(value || '').trim(); + if (!raw) { + return null; + } + + let normalized = raw.replace(' ', 'T'); + normalized = normalized.replace(/([+-]\d{2})$/, '$1:00'); + const parsed = new Date(normalized); + return Number.isNaN(parsed.getTime()) ? null : parsed; + }; + + const latestFetchedAt = () => { + const latest = page.latest && typeof page.latest === 'object' ? page.latest : null; + const direct = parseDateValue(latest?.fetched_at); + if (direct) { + return direct; + } + + const recentFetches = Array.isArray(page.recent_fetches) ? page.recent_fetches : []; + for (const entry of recentFetches) { + const parsed = parseDateValue(entry?.fetched_at); + if (parsed) { + return parsed; + } + } + + return null; + }; + + const bindManualRefreshAction = () => { + const refreshLink = Array.from(document.querySelectorAll('a[href]')).find((link) => { + try { + const url = new URL(link.href, window.location.origin); + return url.pathname === '/module/fx-rates' && url.searchParams.get('refresh') === '1'; + } catch (_error) { + return false; + } + }); + + if (!refreshLink) { + return; + } + + refreshLink.addEventListener('click', (event) => { + const url = new URL(refreshLink.href, window.location.origin); + if (url.searchParams.get('force') === '1') { + return; + } + + const lastFetch = latestFetchedAt(); + if (!lastFetch) { + return; + } + + const ageMinutes = (Date.now() - lastFetch.getTime()) / 60000; + if (!Number.isFinite(ageMinutes) || ageMinutes >= refreshMaxAgeMinutes) { + return; + } + + event.preventDefault(); + const confirmed = window.confirm( + `Der letzte gespeicherte Abruf ist juenger als ${refreshMaxAgeMinutes} Minuten. ` + + 'Ein manueller Abruf wuerde die externe API trotzdem erneut aufrufen. Jetzt trotzdem abrufen?' + ); + + if (!confirmed) { + return; + } + + url.searchParams.set('force', '1'); + window.location.href = url.toString(); + }); + }; const renderSnapshot = (snapshot) => { const rates = snapshot && snapshot.rates ? snapshot.rates : null; @@ -56,7 +132,7 @@ } const entries = Array.isArray(fetches) ? fetches : []; if (!entries.length) { - nodes.fetchesBody.innerHTML = 'Noch keine Abrufe vorhanden.'; + nodes.fetchesBody.innerHTML = 'Noch keine Abrufe vorhanden.'; return; } nodes.fetchesBody.innerHTML = entries.map((entry) => ` @@ -64,6 +140,7 @@ ${entry?.fetched_at_display || entry?.fetched_at || ''} ${entry?.base_currency || ''} ${entry?.provider || ''} + ${entry?.trigger_source_label || entry?.trigger_source || ''} `).join(''); }; @@ -226,6 +303,7 @@ }); renderFetches(page.recent_fetches || []); + bindManualRefreshAction(); loadLatest().catch(() => {}); loadHistory().catch(() => { diff --git a/modules/fx-rates/module.json b/modules/fx-rates/module.json index a537279..e57ce47 100644 --- a/modules/fx-rates/module.json +++ b/modules/fx-rates/module.json @@ -18,7 +18,7 @@ { "name": "api_url", "label": "FX API URL", "type": "text", "required": false, "help": "Nur die Basis-URL eintragen, z.B. https://api.currencyapi.com oder https://currencyapi.net." }, { "name": "api_key", "label": "FX API Key", "type": "password", "required": false }, { "name": "timeout_sec", "label": "Timeout (Sek.)", "type": "number", "required": false }, - { "name": "refresh_max_age_minutes", "label": "Max. Alter fuer API-Refresh (Min.)", "type": "number", "required": false, "help": "Refresh aktualisiert nur, wenn der letzte gespeicherte Abruf aelter ist." }, + { "name": "refresh_max_age_minutes", "label": "Max. Alter fuer API-Refresh (Min.)", "type": "number", "required": false, "help": "Blockiert neue API-Refresh-Aufrufe, solange der letzte gespeicherte Abruf juenger ist. Manuelle Abrufe koennen nach Hinweis trotzdem erzwungen werden; Cron ignoriert diesen Wert." }, { "name": "default_base_currency", "label": "Standard-Basiswaehrung", "type": "text", "required": false, "help": "Wird fuer taegliche Abrufe und Snapshot-Abfragen verwendet." }, { "name": "display_base_currency", "label": "Anzeige-Basiswaehrung", "type": "select", "required": false, "help": "Basis fuer die Anzeige der zuletzt gespeicherten Kurse im Modul." }, { "name": "preferred_currencies", "label": "Bevorzugte Waehrungen", "type": "multiselect", "required": false, "help": "Auswahl aus dem synchronisierten Waehrungskatalog." }, diff --git a/modules/fx-rates/pages/index.php b/modules/fx-rates/pages/index.php index ff86a54..601aaa7 100644 --- a/modules/fx-rates/pages/index.php +++ b/modules/fx-rates/pages/index.php @@ -18,13 +18,31 @@ $error = trim((string) ($_GET['error'] ?? '')); if ((string) ($_GET['refresh'] ?? '') === '1') { try { - $result = $service->refreshLatestRates(null, (string) ($settings['default_base_currency'] ?? '')); - $params = [ - 'notice' => sprintf( - 'Aktuelle Kurse gespeichert. %d Werte aktualisiert.', - (int) ($result['updated_count'] ?? 0) - ), - ]; + $force = !empty($_GET['force']); + if ($force) { + $result = $service->refreshLatestRates(null, (string) ($settings['default_base_currency'] ?? ''), 'manual'); + } else { + $result = $service->autoRefreshLatestRates( + (string) ($settings['default_base_currency'] ?? ''), + null, + (int) ($settings['refresh_max_age_minutes'] ?? 60), + 'manual' + ); + } + + $params = !empty($result['reused']) + ? [ + 'notice' => sprintf( + 'Kein neuer API-Abruf. Der letzte gespeicherte Snapshot ist juenger als %d Minuten. Fuer einen erzwungenen Abruf bitte bestaetigen.', + (int) ($settings['refresh_max_age_minutes'] ?? 60) + ), + ] + : [ + 'notice' => sprintf( + 'Aktuelle Kurse gespeichert. %d Werte aktualisiert.', + (int) ($result['updated_count'] ?? 0) + ), + ]; } catch (\Throwable $exception) { $params = ['error' => $exception->getMessage() !== '' ? $exception->getMessage() : 'Kurse konnten nicht aktualisiert werden.']; } @@ -143,17 +161,19 @@ $pageData = json_encode([ Datum Basis Provider + Ausloeser - Noch keine Abrufe vorhanden. + Noch keine Abrufe vorhanden. + diff --git a/modules/fx-rates/src/Api/Router.php b/modules/fx-rates/src/Api/Router.php index 7a09472..c32958c 100644 --- a/modules/fx-rates/src/Api/Router.php +++ b/modules/fx-rates/src/Api/Router.php @@ -98,8 +98,8 @@ final class Router $maxAgeMinutes = is_numeric($input['max_age_minutes'] ?? null) ? (int) $input['max_age_minutes'] : null; $result = $force - ? $this->service->refreshLatestRates(null, $base) - : $this->service->autoRefreshLatestRates($base, null, $maxAgeMinutes); + ? $this->service->refreshLatestRates(null, $base, 'api') + : $this->service->autoRefreshLatestRates($base, null, $maxAgeMinutes, 'api'); $this->respond(['data' => $result], 201); } diff --git a/modules/fx-rates/src/Domain/FxRatesService.php b/modules/fx-rates/src/Domain/FxRatesService.php index 1dcbe1f..393cb93 100644 --- a/modules/fx-rates/src/Domain/FxRatesService.php +++ b/modules/fx-rates/src/Domain/FxRatesService.php @@ -194,7 +194,7 @@ final class FxRatesService return $amount * (float) $rate['rate']; } - public function refreshLatestRates(?array $currencies = null, ?string $baseCurrency = null): array + public function refreshLatestRates(?array $currencies = null, ?string $baseCurrency = null, string $triggerSource = 'manual'): array { $requestedBase = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency()); $payload = $this->fetchLatestPayload($requestedBase, null); @@ -209,7 +209,8 @@ final class FxRatesService $this->provider(), $rateDate, $rates, - gmdate('Y-m-d H:i:s') + gmdate('Y-m-d H:i:s'), + $triggerSource ); return [ @@ -223,7 +224,7 @@ final class FxRatesService ]; } - public function ensureFreshLatestRates(float $maxAgeHours = 24.0, ?string $baseCurrency = null, ?array $currencies = null): array + public function ensureFreshLatestRates(float $maxAgeHours = 24.0, ?string $baseCurrency = null, ?array $currencies = null, string $triggerSource = 'manual'): array { $base = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency()); $latest = $this->repository->getLatestFetch($base); @@ -242,16 +243,16 @@ final class FxRatesService ]; } - $result = $this->refreshLatestRates($currencies, $base); + $result = $this->refreshLatestRates($currencies, $base, $triggerSource); $result['reused'] = false; return $result; } - public function autoRefreshLatestRates(?string $baseCurrency = null, ?array $currencies = null, ?int $maxAgeMinutes = null): array + public function autoRefreshLatestRates(?string $baseCurrency = null, ?array $currencies = null, ?int $maxAgeMinutes = null, string $triggerSource = 'api'): array { $minutes = $maxAgeMinutes ?? $this->refreshMaxAgeMinutes(); $hours = max(1, $minutes) / 60; - return $this->ensureFreshLatestRates($hours, $baseCurrency, $currencies); + return $this->ensureFreshLatestRates($hours, $baseCurrency, $currencies, $triggerSource); } public function history(string $fromCurrency, string $toCurrency, ?string $from = null, ?string $to = null, int $limit = 200): array @@ -302,7 +303,8 @@ final class FxRatesService public function runScheduledRefresh(array $context = []): array { - $result = $this->refreshLatestRates(null, $this->defaultBaseCurrency()); + $triggerSource = ($context['trigger'] ?? null) === 'manual_test' ? 'manual' : 'cron'; + $result = $this->refreshLatestRates(null, $this->defaultBaseCurrency(), $triggerSource); return [ 'ok' => true, 'message' => 'Geplanter FX-Abruf ausgefuehrt: ' . (int) ($result['updated_count'] ?? 0) . ' Kurse gespeichert.', @@ -751,6 +753,7 @@ final class FxRatesService $fetch['fetched_at_display'] = $this->formatDisplayTimestamp($fetch['fetched_at'] ?? null); $fetch['created_at_display'] = $this->formatDisplayTimestamp($fetch['created_at'] ?? null); + $fetch['trigger_source_label'] = $this->triggerSourceLabel((string) ($fetch['trigger_source'] ?? 'manual')); return $fetch; } @@ -962,4 +965,13 @@ final class FxRatesService { return $this->scheduleTimezone(); } + + private function triggerSourceLabel(string $source): string + { + return match (strtolower(trim($source))) { + 'cron' => 'Cron', + 'api' => 'API', + default => 'Manuell', + }; + } } diff --git a/modules/fx-rates/src/Infrastructure/FxRatesRepository.php b/modules/fx-rates/src/Infrastructure/FxRatesRepository.php index a340062..d36683d 100644 --- a/modules/fx-rates/src/Infrastructure/FxRatesRepository.php +++ b/modules/fx-rates/src/Infrastructure/FxRatesRepository.php @@ -25,11 +25,13 @@ final class FxRatesRepository $this->pdo->exec("CREATE TABLE IF NOT EXISTS {$fetchTable} ( id SERIAL PRIMARY KEY, provider VARCHAR(64) NOT NULL, + trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual', base_currency VARCHAR(10) NOT NULL, rate_date DATE NOT NULL, fetched_at TIMESTAMP NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP )"); + $this->pdo->exec("ALTER TABLE {$fetchTable} ADD COLUMN IF NOT EXISTS trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual'"); $this->pdo->exec("CREATE TABLE IF NOT EXISTS {$rateTable} ( id SERIAL PRIMARY KEY, fetch_id INTEGER NOT NULL REFERENCES {$fetchTable}(id) ON DELETE CASCADE, @@ -44,6 +46,7 @@ final class FxRatesRepository $this->pdo->exec("CREATE TABLE IF NOT EXISTS {$fetchTable} ( id INTEGER PRIMARY KEY AUTO_INCREMENT, provider VARCHAR(64) NOT NULL, + trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual', base_currency VARCHAR(10) NOT NULL, rate_date DATE NOT NULL, fetched_at DATETIME NOT NULL, @@ -51,6 +54,7 @@ final class FxRatesRepository KEY {$fetchTable}_base_fetch_idx (base_currency, fetched_at, id), KEY {$fetchTable}_rate_date_idx (rate_date) )"); + $this->ensureColumn($fetchTable, 'trigger_source', "ALTER TABLE {$fetchTable} ADD COLUMN trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual'"); $this->pdo->exec("CREATE TABLE IF NOT EXISTS {$rateTable} ( id INTEGER PRIMARY KEY AUTO_INCREMENT, fetch_id INTEGER NOT NULL, @@ -63,11 +67,13 @@ final class FxRatesRepository $this->pdo->exec("CREATE TABLE IF NOT EXISTS {$fetchTable} ( id INTEGER PRIMARY KEY AUTOINCREMENT, provider VARCHAR(64) NOT NULL, + trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual', base_currency VARCHAR(10) NOT NULL, rate_date DATE NOT NULL, fetched_at DATETIME NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )"); + $this->ensureColumn($fetchTable, 'trigger_source', "ALTER TABLE {$fetchTable} ADD COLUMN trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual'"); $this->pdo->exec("CREATE TABLE IF NOT EXISTS {$rateTable} ( id INTEGER PRIMARY KEY AUTOINCREMENT, fetch_id INTEGER NOT NULL, @@ -83,7 +89,7 @@ final class FxRatesRepository public function getLatestFetch(?string $baseCurrency = null): ?array { - $sql = 'SELECT id, provider, base_currency, rate_date, fetched_at, created_at FROM ' . $this->table('fetches'); + $sql = 'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at FROM ' . $this->table('fetches'); $params = []; if ($baseCurrency !== null && trim($baseCurrency) !== '') { $sql .= ' WHERE base_currency = :base_currency'; @@ -99,7 +105,7 @@ final class FxRatesRepository public function listLatestFetches(): array { $stmt = $this->pdo->query( - 'SELECT id, provider, base_currency, rate_date, fetched_at, created_at + 'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at FROM ' . $this->table('fetches') . ' ORDER BY fetched_at DESC, id DESC' ); @@ -120,7 +126,7 @@ final class FxRatesRepository public function listRecentFetches(int $limit = 20): array { $stmt = $this->pdo->prepare( - 'SELECT id, provider, base_currency, rate_date, fetched_at, created_at + 'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at FROM ' . $this->table('fetches') . ' ORDER BY fetched_at DESC, id DESC LIMIT :limit' @@ -161,7 +167,7 @@ final class FxRatesRepository foreach (['<=', '>='] as $operator) { $order = $operator === '<=' ? 'DESC' : 'ASC'; $stmt = $this->pdo->prepare( - 'SELECT id, provider, base_currency, rate_date, fetched_at, created_at + 'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at FROM ' . $this->table('fetches') . ' WHERE fetched_at ' . $operator . ' :target_at ORDER BY fetched_at ' . $order . ', id ' . $order . ' @@ -277,11 +283,12 @@ final class FxRatesRepository return array_map(fn (array $row): array => $this->normalizeRate($row), $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []); } - public function saveFetch(string $baseCurrency, string $provider, string $rateDate, array $rates, ?string $fetchedAt = null): array + public function saveFetch(string $baseCurrency, string $provider, string $rateDate, array $rates, ?string $fetchedAt = null, string $triggerSource = 'manual'): array { $baseCurrency = strtoupper(trim($baseCurrency)); $provider = trim($provider) !== '' ? trim($provider) : 'currencyapi'; $fetchedAt = trim((string) $fetchedAt) !== '' ? trim((string) $fetchedAt) : gmdate('Y-m-d H:i:s'); + $triggerSource = $this->normalizeTriggerSource($triggerSource); $normalizedRates = []; foreach ($rates as $currencyCode => $rate) { $currencyCode = strtoupper(trim((string) $currencyCode)); @@ -301,14 +308,15 @@ final class FxRatesRepository if ($this->driver === 'pgsql') { $fetchStmt = $this->pdo->prepare( 'INSERT INTO ' . $this->table('fetches') . ' ( - provider, base_currency, rate_date, fetched_at + provider, trigger_source, base_currency, rate_date, fetched_at ) VALUES ( - :provider, :base_currency, :rate_date, :fetched_at + :provider, :trigger_source, :base_currency, :rate_date, :fetched_at ) RETURNING *' ); $fetchStmt->execute([ 'provider' => $provider, + 'trigger_source' => $triggerSource, 'base_currency' => $baseCurrency, 'rate_date' => $rateDate, 'fetched_at' => $fetchedAt, @@ -317,13 +325,14 @@ final class FxRatesRepository } else { $fetchStmt = $this->pdo->prepare( 'INSERT INTO ' . $this->table('fetches') . ' ( - provider, base_currency, rate_date, fetched_at + provider, trigger_source, base_currency, rate_date, fetched_at ) VALUES ( - :provider, :base_currency, :rate_date, :fetched_at + :provider, :trigger_source, :base_currency, :rate_date, :fetched_at )' ); $fetchStmt->execute([ 'provider' => $provider, + 'trigger_source' => $triggerSource, 'base_currency' => $baseCurrency, 'rate_date' => $rateDate, 'fetched_at' => $fetchedAt, @@ -379,7 +388,7 @@ final class FxRatesRepository private function getFetchById(int $fetchId): ?array { $stmt = $this->pdo->prepare( - 'SELECT id, provider, base_currency, rate_date, fetched_at, created_at + 'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at FROM ' . $this->table('fetches') . ' WHERE id = :id LIMIT 1' @@ -393,10 +402,10 @@ final class FxRatesRepository { $order = $operator === '<=' ? 'DESC' : 'ASC'; $stmt = $this->pdo->prepare( - 'SELECT id, provider, base_currency, rate_date, fetched_at, created_at + 'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at FROM ' . $this->table('fetches') . ' WHERE base_currency = :base_currency - AND fetched_at ' . $operator . ' :target_at + AND fetched_at ' . $operator . ' :target_at ORDER BY fetched_at ' . $order . ', id ' . $order . ' LIMIT 1' ); @@ -456,6 +465,7 @@ final class FxRatesRepository return [ 'id' => isset($row['id']) ? (int) $row['id'] : null, 'provider' => (string) ($row['provider'] ?? ''), + 'trigger_source' => (string) ($row['trigger_source'] ?? 'manual'), 'base_currency' => strtoupper((string) ($row['base_currency'] ?? '')), 'rate_date' => (string) ($row['rate_date'] ?? ''), 'fetched_at' => (string) ($row['fetched_at'] ?? ''), @@ -463,6 +473,34 @@ final class FxRatesRepository ]; } + private function ensureColumn(string $table, string $column, string $alterSql): void + { + try { + $stmt = $this->pdo->query('SELECT * FROM ' . $table . ' LIMIT 1'); + if ($stmt instanceof \PDOStatement) { + $row = $stmt->fetch(PDO::FETCH_ASSOC) ?: []; + if (in_array(strtolower($column), array_map('strtolower', array_keys($row)), true)) { + return; + } + } + } catch (\Throwable) { + } + + try { + $this->pdo->exec($alterSql); + } catch (\Throwable) { + } + } + + private function normalizeTriggerSource(string $source): string + { + $source = strtolower(trim($source)); + return match ($source) { + 'cron', 'manual', 'api' => $source, + default => 'manual', + }; + } + private function normalizeRate(array $row): array { return [ diff --git a/partials/landingpages/modules/setup.php b/partials/landingpages/modules/setup.php index 8d96199..c4b5053 100644 --- a/partials/landingpages/modules/setup.php +++ b/partials/landingpages/modules/setup.php @@ -1737,6 +1737,7 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups) setStatusText(entry, 'Letzter Erfolg', status.last_success_at_local || status.state?.last_success_at || '-'); setStatusText(entry, 'Naechster Lauf lokal', status.enabled ? (status.next_due_at_local || '-') : '-'); setStatusText(entry, 'Status', status.state?.last_status || '-'); + setStatusText(entry, 'Meldung', status.state?.last_message || '-'); updateEntrySummary(entry); }); });