diff --git a/.gitignore b/.gitignore
index b1a16b0..ac20ff6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,6 +28,10 @@
# pro Umgebung unterschiedlich und darf nicht öffentlich einsehbar sein
/hifi/api/config/setup.php
+# Google-Service-Account-Schlüssel für die Analytics-Data-API-Anbindung im Dashboard
+# (wird vom Admin-Panel automatisch neu geschrieben, enthält einen privaten Schlüssel)
+/hifi/api/config/ga-service-account.json
+
# Claude-Code-Tooling (Skills/lokale Einstellungen) - gehört nicht zum Website-Projekt
/hifi/.claude/
/hifi/.agents/
diff --git a/hifi-src/src/pages/admin/Dashboard.jsx b/hifi-src/src/pages/admin/Dashboard.jsx
index 20a06d6..4a10c71 100644
--- a/hifi-src/src/pages/admin/Dashboard.jsx
+++ b/hifi-src/src/pages/admin/Dashboard.jsx
@@ -16,6 +16,94 @@ const Icon = ({ path, className = 'h-6 w-6' }) => (
);
+function AnalyticsWidget() {
+ const [report, setReport] = useState(null);
+ const [error, setError] = useState('');
+
+ useEffect(() => {
+ api.get('/analytics/report').then(setReport).catch((e) => setError(e.message));
+ }, []);
+
+ if (error) {
+ return (
+ {error}
+ Noch nicht eingerichtet. Unter{' '}
+
+ Einstellungen → Website
+ {' '}
+ kannst du die Dashboard-Anbindung konfigurieren, um hier Besucherzahlen zu sehen.
+ Letzte 7 Tage. {report.totals.users} Nutzer {report.totals.sessions} Sitzungen {report.totals.pageviews} Seitenaufrufe Meistbesuchte SeitenGoogle Analytics
+ Google Analytics
+ Google Analytics
+
+ {report.top_pages.map((p) => (
+
+
+ Zeigt Besucherzahlen direkt in deinem Admin-Dashboard an. Braucht ein Google-Cloud-Service-Account mit + Lesezugriff auf diese GA4-Property. +
+Nicht die Measurement-ID (G-...) - zu finden unter GA4 → Verwaltung → Property-Details.
+{gaError}
} + {gaSaved &&Gespeichert.
} + +{error}
} diff --git a/hifi/api/database/migration_analytics_dashboard.sql b/hifi/api/database/migration_analytics_dashboard.sql new file mode 100644 index 0000000..964c2be --- /dev/null +++ b/hifi/api/database/migration_analytics_dashboard.sql @@ -0,0 +1,7 @@ +-- GA4-Property-ID admin-konfigurierbar machen, fuer die Dashboard-Kennzahlen-Anbindung +-- ueber die Google Analytics Data API. Additiv, nichts wird geloescht. +-- Hinweis: Auf der Live-Seite reicht stattdessen ein Klick auf "Datenbankstruktur aktualisieren" +-- unter Admin-Panel -> Einstellungen -> Datenbank. + +ALTER TABLE app_settings + ADD COLUMN IF NOT EXISTS ga_property_id VARCHAR(30) NULL; diff --git a/hifi/api/database/schema.sql b/hifi/api/database/schema.sql index c55da29..078e114 100644 --- a/hifi/api/database/schema.sql +++ b/hifi/api/database/schema.sql @@ -25,6 +25,7 @@ CREATE TABLE app_settings ( contact_email VARCHAR(150) NULL, hero_image_path VARCHAR(255) NULL, ga_measurement_id VARCHAR(20) NULL, + ga_property_id VARCHAR(30) NULL, mail_host VARCHAR(255) NULL, mail_port INT NULL, mail_username VARCHAR(255) NULL, diff --git a/hifi/api/public/index.php b/hifi/api/public/index.php index 098db03..3f59a07 100644 --- a/hifi/api/public/index.php +++ b/hifi/api/public/index.php @@ -9,6 +9,7 @@ ob_start(); require __DIR__ . '/../vendor/autoload.php'; use App\Controllers\AdminUserController; +use App\Controllers\AnalyticsController; use App\Controllers\AuthController; use App\Controllers\BrandController; use App\Controllers\ContactController; @@ -179,6 +180,10 @@ $router->get('/settings/mail', $perm('settings.manage', fn($p) => MailSettingsCo $router->post('/settings/mail', $perm('settings.manage', fn($p) => MailSettingsController::update())); $router->post('/settings/mail/test', $perm('settings.manage', fn($p) => MailSettingsController::test())); +$router->get('/settings/analytics', $perm('settings.manage', fn($p) => AnalyticsController::show())); +$router->post('/settings/analytics', $perm('settings.manage', fn($p) => AnalyticsController::update())); +$router->get('/analytics/report', $admin(fn($p) => AnalyticsController::report())); + $router->get('/maintenance', fn($p) => MaintenanceController::status()); $router->post('/maintenance', $perm('settings.manage', fn($p) => MaintenanceController::update())); diff --git a/hifi/api/src/Controllers/AnalyticsController.php b/hifi/api/src/Controllers/AnalyticsController.php new file mode 100644 index 0000000..ec7c81b --- /dev/null +++ b/hifi/api/src/Controllers/AnalyticsController.php @@ -0,0 +1,75 @@ +query('SELECT ga_property_id FROM app_settings WHERE id = 1'); + $row = $stmt->fetch(); + $propertyId = ($row['ga_property_id'] ?? '') !== '' ? $row['ga_property_id'] : null; + } catch (\Throwable $e) { + // Spalte fehlt noch (vor Schema-Migration) - Default (null) bleibt bestehen. + } + + Http::send([ + 'ga_property_id' => $propertyId, + 'has_credentials' => GoogleAnalyticsReporting::hasCredentials(), + ]); + } + + public static function update(): void + { + $body = Http::jsonBody(); + $propertyId = trim($body['ga_property_id'] ?? ''); + $serviceAccountJson = trim($body['service_account_json'] ?? ''); + + if ($serviceAccountJson !== '') { + $decoded = json_decode($serviceAccountJson, true); + if (!is_array($decoded) || empty($decoded['client_email']) || empty($decoded['private_key'])) { + Http::error('Das eingefügte JSON sieht nicht wie ein gültiger Google-Service-Account-Schlüssel aus (client_email/private_key fehlen).', 422); + } + if (@file_put_contents(GoogleAnalyticsReporting::credentialsPath(), $serviceAccountJson) === false) { + Http::error('Service-Account-JSON konnte nicht gespeichert werden (Dateirechte prüfen).', 500); + } + } + + $db = Database::connection(); + $db->exec('INSERT IGNORE INTO app_settings (id) VALUES (1)'); + $stmt = $db->prepare('UPDATE app_settings SET ga_property_id = ? WHERE id = 1'); + $stmt->execute([$propertyId ?: null]); + + self::show(); + } + + public static function report(): void + { + $propertyId = null; + try { + $stmt = Database::connection()->query('SELECT ga_property_id FROM app_settings WHERE id = 1'); + $row = $stmt->fetch(); + $propertyId = ($row['ga_property_id'] ?? '') !== '' ? $row['ga_property_id'] : null; + } catch (\Throwable $e) { + // Spalte fehlt noch (vor Schema-Migration). + } + + if (!$propertyId || !GoogleAnalyticsReporting::hasCredentials()) { + Http::send(['configured' => false]); + } + + try { + $report = GoogleAnalyticsReporting::fetchDashboardReport($propertyId); + } catch (\Throwable $e) { + Http::error($e->getMessage(), 502); + } + + Http::send(['configured' => true] + $report); + } +} diff --git a/hifi/api/src/Support/GoogleAnalyticsReporting.php b/hifi/api/src/Support/GoogleAnalyticsReporting.php new file mode 100644 index 0000000..f0eec3c --- /dev/null +++ b/hifi/api/src/Support/GoogleAnalyticsReporting.php @@ -0,0 +1,162 @@ + 'RS256', 'typ' => 'JWT'])); + $claims = self::base64UrlEncode(json_encode([ + 'iss' => $credentials['client_email'], + 'scope' => self::SCOPE, + 'aud' => self::TOKEN_URI, + 'iat' => $now, + 'exp' => $now + 3600, + ])); + + $signatureInput = $header . '.' . $claims; + $privateKey = openssl_pkey_get_private($credentials['private_key']); + if ($privateKey === false) { + throw new \RuntimeException('Privater Schlüssel im Service-Account-JSON ist ungültig.'); + } + openssl_sign($signatureInput, $signature, $privateKey, OPENSSL_ALGO_SHA256); + $jwt = $signatureInput . '.' . self::base64UrlEncode($signature); + + $response = self::post(self::TOKEN_URI, http_build_query([ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'assertion' => $jwt, + ]), ['Content-Type: application/x-www-form-urlencoded']); + + if (empty($response['access_token'])) { + throw new \RuntimeException('Google-Token-Anfrage fehlgeschlagen: ' . ($response['error_description'] ?? $response['error'] ?? 'unbekannter Fehler')); + } + + return $response['access_token']; + } + + private static function post(string $url, string $body, array $headers): array + { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_POSTFIELDS => $body, + CURLOPT_TIMEOUT => 15, + ]); + $raw = curl_exec($ch); + if ($raw === false) { + $error = curl_error($ch); + curl_close($ch); + throw new \RuntimeException('Verbindung zu Google fehlgeschlagen: ' . $error); + } + curl_close($ch); + + $data = json_decode((string) $raw, true); + return is_array($data) ? $data : []; + } + + private static function runReport(string $propertyId, string $token, array $body): array + { + $response = self::post( + self::API_BASE . '/properties/' . $propertyId . ':runReport', + json_encode($body), + ['Authorization: Bearer ' . $token, 'Content-Type: application/json'] + ); + + if (isset($response['error'])) { + throw new \RuntimeException('Google Analytics Data API: ' . ($response['error']['message'] ?? 'unbekannter Fehler')); + } + + return $response; + } + + /** + * Kennzahlen (Nutzer/Sitzungen/Seitenaufrufe je Tag) sowie die meistbesuchten Seiten + * der letzten $days Tage - genau das, was das Dashboard-Widget anzeigt. + */ + public static function fetchDashboardReport(string $propertyId, int $days = 7): array + { + $credentials = self::credentials(); + $token = self::fetchAccessToken($credentials); + + $daily = self::runReport($propertyId, $token, [ + 'dateRanges' => [['startDate' => $days . 'daysAgo', 'endDate' => 'today']], + 'dimensions' => [['name' => 'date']], + 'metrics' => [['name' => 'activeUsers'], ['name' => 'sessions'], ['name' => 'screenPageViews']], + 'orderBys' => [['dimension' => ['dimensionName' => 'date']]], + ]); + + $topPages = self::runReport($propertyId, $token, [ + 'dateRanges' => [['startDate' => $days . 'daysAgo', 'endDate' => 'today']], + 'dimensions' => [['name' => 'pagePath']], + 'metrics' => [['name' => 'screenPageViews']], + 'orderBys' => [['metric' => ['metricName' => 'screenPageViews'], 'desc' => true]], + 'limit' => 5, + ]); + + $dailyRows = array_map(fn($row) => [ + 'date' => $row['dimensionValues'][0]['value'], + 'users' => (int) $row['metricValues'][0]['value'], + 'sessions' => (int) $row['metricValues'][1]['value'], + 'pageviews' => (int) $row['metricValues'][2]['value'], + ], $daily['rows'] ?? []); + + $topPageRows = array_map(fn($row) => [ + 'path' => $row['dimensionValues'][0]['value'], + 'pageviews' => (int) $row['metricValues'][0]['value'], + ], $topPages['rows'] ?? []); + + return [ + 'daily' => $dailyRows, + 'top_pages' => $topPageRows, + 'totals' => [ + 'users' => array_sum(array_column($dailyRows, 'users')), + 'sessions' => array_sum(array_column($dailyRows, 'sessions')), + 'pageviews' => array_sum(array_column($dailyRows, 'pageviews')), + ], + ]; + } +} diff --git a/hifi/api/src/Support/Schema.php b/hifi/api/src/Support/Schema.php index b8499f9..6a90693 100644 --- a/hifi/api/src/Support/Schema.php +++ b/hifi/api/src/Support/Schema.php @@ -36,6 +36,7 @@ class Schema contact_email VARCHAR(150) NULL, hero_image_path VARCHAR(255) NULL, ga_measurement_id VARCHAR(20) NULL, + ga_property_id VARCHAR(30) NULL, mail_host VARCHAR(255) NULL, mail_port INT NULL, mail_username VARCHAR(255) NULL, @@ -244,6 +245,7 @@ class Schema 'contact_email' => 'VARCHAR(150) NULL', 'hero_image_path' => 'VARCHAR(255) NULL', 'ga_measurement_id' => 'VARCHAR(20) NULL', + 'ga_property_id' => 'VARCHAR(30) NULL', 'mail_host' => 'VARCHAR(255) NULL', 'mail_port' => 'INT NULL', 'mail_username' => 'VARCHAR(255) NULL',