Add Google Analytics dashboard widget (GA4 Data API integration)
New Dashboard section shows users/sessions/pageviews for the last 7 days plus top pages, fetched server-side via the GA4 Data API using a Google service account (JWT-bearer flow, no OAuth consent screen needed) - the same approach WordPress plugins like MonsterInsights use to surface GA stats inline instead of linking out to analytics.google.com. Backend: GoogleAnalyticsReporting support class (signs its own JWT with openssl, exchanges it for an access token, calls runReport - no need for the full Google API PHP client library for one endpoint) and AnalyticsController (settings show/update, report fetch). The service account JSON key is written to a gitignored config file, mirroring the existing db.php/setup.php pattern for environment-specific secrets. Admin: new "Dashboard-Anbindung" section in Website Settings (Property ID + service account JSON paste, independent save action). Dashboard gracefully shows a setup hint when not yet configured instead of an error. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
93e245d3b9
commit
24df7a602c
9 changed files with 418 additions and 0 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -28,6 +28,10 @@
|
||||||
# pro Umgebung unterschiedlich und darf nicht öffentlich einsehbar sein
|
# pro Umgebung unterschiedlich und darf nicht öffentlich einsehbar sein
|
||||||
/hifi/api/config/setup.php
|
/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
|
# Claude-Code-Tooling (Skills/lokale Einstellungen) - gehört nicht zum Website-Projekt
|
||||||
/hifi/.claude/
|
/hifi/.claude/
|
||||||
/hifi/.agents/
|
/hifi/.agents/
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,94 @@ const Icon = ({ path, className = 'h-6 w-6' }) => (
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<section className="mt-8 rounded-xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
|
||||||
|
<h2 className="mb-1 font-semibold text-neutral-900 dark:text-white">Google Analytics</h2>
|
||||||
|
<p className="text-sm text-red-600">{error}</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!report) return null;
|
||||||
|
|
||||||
|
if (!report.configured) {
|
||||||
|
return (
|
||||||
|
<section className="mt-8 rounded-xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
|
||||||
|
<h2 className="mb-1 font-semibold text-neutral-900 dark:text-white">Google Analytics</h2>
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
Noch nicht eingerichtet. Unter{' '}
|
||||||
|
<Link to="/admin/settings/website" className="text-brand-600 hover:underline dark:text-brand-400">
|
||||||
|
Einstellungen → Website
|
||||||
|
</Link>{' '}
|
||||||
|
kannst du die Dashboard-Anbindung konfigurieren, um hier Besucherzahlen zu sehen.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxPageviews = Math.max(1, ...report.daily.map((d) => d.pageviews));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mt-8 rounded-xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
|
||||||
|
<h2 className="mb-1 font-semibold text-neutral-900 dark:text-white">Google Analytics</h2>
|
||||||
|
<p className="mb-5 text-sm text-neutral-500 dark:text-neutral-400">Letzte 7 Tage.</p>
|
||||||
|
|
||||||
|
<div className="mb-6 grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-extrabold text-brand-600 dark:text-brand-400">{report.totals.users}</p>
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">Nutzer</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-extrabold text-brand-600 dark:text-brand-400">{report.totals.sessions}</p>
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">Sitzungen</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-extrabold text-brand-600 dark:text-brand-400">{report.totals.pageviews}</p>
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">Seitenaufrufe</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{report.daily.length > 0 && (
|
||||||
|
<div className="mb-6 flex h-24 items-end gap-1.5">
|
||||||
|
{report.daily.map((d) => (
|
||||||
|
<div key={d.date} className="flex flex-1 flex-col items-center gap-1">
|
||||||
|
<div
|
||||||
|
className="w-full rounded-t bg-brand-400 dark:bg-brand-500"
|
||||||
|
style={{ height: `${Math.max(4, (d.pageviews / maxPageviews) * 80)}px` }}
|
||||||
|
title={`${d.date}: ${d.pageviews} Seitenaufrufe`}
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-neutral-400">{d.date.slice(6, 8)}.{d.date.slice(4, 6)}.</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{report.top_pages.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400">Meistbesuchte Seiten</p>
|
||||||
|
<ul className="space-y-1 text-sm">
|
||||||
|
{report.top_pages.map((p) => (
|
||||||
|
<li key={p.path} className="flex items-center justify-between gap-3 text-neutral-700 dark:text-neutral-300">
|
||||||
|
<span className="truncate font-mono text-xs">{p.path}</span>
|
||||||
|
<span className="shrink-0 font-semibold">{p.pageviews}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const { user, hasPermission } = useAuth();
|
const { user, hasPermission } = useAuth();
|
||||||
const [stats, setStats] = useState({});
|
const [stats, setStats] = useState({});
|
||||||
|
|
@ -70,6 +158,8 @@ export default function Dashboard() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{hasPermission('settings.manage') && <AnalyticsWidget />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,13 @@ export default function WebsiteSettings() {
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const [gaPropertyId, setGaPropertyId] = useState('');
|
||||||
|
const [gaServiceAccountJson, setGaServiceAccountJson] = useState('');
|
||||||
|
const [gaHasCredentials, setGaHasCredentials] = useState(false);
|
||||||
|
const [gaBusy, setGaBusy] = useState(false);
|
||||||
|
const [gaError, setGaError] = useState('');
|
||||||
|
const [gaSaved, setGaSaved] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api
|
api
|
||||||
.get('/site-settings')
|
.get('/site-settings')
|
||||||
|
|
@ -24,8 +31,31 @@ export default function WebsiteSettings() {
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
|
api.get('/settings/analytics').then((res) => {
|
||||||
|
setGaPropertyId(res.ga_property_id || '');
|
||||||
|
setGaHasCredentials(res.has_credentials);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleGaDashboardSave = async () => {
|
||||||
|
setGaBusy(true);
|
||||||
|
setGaError('');
|
||||||
|
setGaSaved(false);
|
||||||
|
try {
|
||||||
|
const res = await api.post('/settings/analytics', {
|
||||||
|
ga_property_id: gaPropertyId,
|
||||||
|
service_account_json: gaServiceAccountJson,
|
||||||
|
});
|
||||||
|
setGaHasCredentials(res.has_credentials);
|
||||||
|
setGaServiceAccountJson('');
|
||||||
|
setGaSaved(true);
|
||||||
|
} catch (err) {
|
||||||
|
setGaError(err.message);
|
||||||
|
} finally {
|
||||||
|
setGaBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const update = (field, value) => {
|
const update = (field, value) => {
|
||||||
setForm((f) => ({ ...f, [field]: value }));
|
setForm((f) => ({ ...f, [field]: value }));
|
||||||
setSaved(false);
|
setSaved(false);
|
||||||
|
|
@ -119,6 +149,48 @@ export default function WebsiteSettings() {
|
||||||
className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900"
|
className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 border-t border-neutral-200 pt-5 dark:border-neutral-800">
|
||||||
|
<h3 className="mb-1 text-sm font-semibold text-neutral-900 dark:text-white">Dashboard-Anbindung (optional)</h3>
|
||||||
|
<p className="mb-3 text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
Zeigt Besucherzahlen direkt in deinem Admin-Dashboard an. Braucht ein Google-Cloud-Service-Account mit
|
||||||
|
Lesezugriff auf diese GA4-Property.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Property-ID</label>
|
||||||
|
<input
|
||||||
|
value={gaPropertyId}
|
||||||
|
onChange={(e) => { setGaPropertyId(e.target.value); setGaSaved(false); }}
|
||||||
|
placeholder="z. B. 123456789"
|
||||||
|
className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-neutral-400">Nicht die Measurement-ID (G-...) - zu finden unter GA4 → Verwaltung → Property-Details.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
|
Service-Account-JSON-Schlüssel {gaHasCredentials ? '(leer lassen = unverändert)' : ''}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
value={gaServiceAccountJson}
|
||||||
|
onChange={(e) => { setGaServiceAccountJson(e.target.value); setGaSaved(false); }}
|
||||||
|
placeholder={gaHasCredentials ? '{ "type": "service_account", ... } (bereits hinterlegt)' : '{ "type": "service_account", ... }'}
|
||||||
|
className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 font-mono text-xs dark:border-neutral-700 dark:bg-neutral-900"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{gaError && <p className="mt-2 text-sm text-red-600">{gaError}</p>}
|
||||||
|
{gaSaved && <p className="mt-2 text-sm text-green-600 dark:text-green-400">Gespeichert.</p>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleGaDashboardSave}
|
||||||
|
disabled={gaBusy}
|
||||||
|
className="mt-3 rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-50 disabled:opacity-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
|
||||||
|
>
|
||||||
|
{gaBusy ? 'Speichere…' : 'Dashboard-Anbindung speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
|
|
||||||
7
hifi/api/database/migration_analytics_dashboard.sql
Normal file
7
hifi/api/database/migration_analytics_dashboard.sql
Normal file
|
|
@ -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;
|
||||||
|
|
@ -25,6 +25,7 @@ CREATE TABLE app_settings (
|
||||||
contact_email VARCHAR(150) NULL,
|
contact_email VARCHAR(150) NULL,
|
||||||
hero_image_path VARCHAR(255) NULL,
|
hero_image_path VARCHAR(255) NULL,
|
||||||
ga_measurement_id VARCHAR(20) NULL,
|
ga_measurement_id VARCHAR(20) NULL,
|
||||||
|
ga_property_id VARCHAR(30) NULL,
|
||||||
mail_host VARCHAR(255) NULL,
|
mail_host VARCHAR(255) NULL,
|
||||||
mail_port INT NULL,
|
mail_port INT NULL,
|
||||||
mail_username VARCHAR(255) NULL,
|
mail_username VARCHAR(255) NULL,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ ob_start();
|
||||||
require __DIR__ . '/../vendor/autoload.php';
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
use App\Controllers\AdminUserController;
|
use App\Controllers\AdminUserController;
|
||||||
|
use App\Controllers\AnalyticsController;
|
||||||
use App\Controllers\AuthController;
|
use App\Controllers\AuthController;
|
||||||
use App\Controllers\BrandController;
|
use App\Controllers\BrandController;
|
||||||
use App\Controllers\ContactController;
|
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', $perm('settings.manage', fn($p) => MailSettingsController::update()));
|
||||||
$router->post('/settings/mail/test', $perm('settings.manage', fn($p) => MailSettingsController::test()));
|
$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->get('/maintenance', fn($p) => MaintenanceController::status());
|
||||||
$router->post('/maintenance', $perm('settings.manage', fn($p) => MaintenanceController::update()));
|
$router->post('/maintenance', $perm('settings.manage', fn($p) => MaintenanceController::update()));
|
||||||
|
|
||||||
|
|
|
||||||
75
hifi/api/src/Controllers/AnalyticsController.php
Normal file
75
hifi/api/src/Controllers/AnalyticsController.php
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Config\Database;
|
||||||
|
use App\Support\GoogleAnalyticsReporting;
|
||||||
|
use App\Support\Http;
|
||||||
|
|
||||||
|
class AnalyticsController
|
||||||
|
{
|
||||||
|
public static function show(): 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) - 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
162
hifi/api/src/Support/GoogleAnalyticsReporting.php
Normal file
162
hifi/api/src/Support/GoogleAnalyticsReporting.php
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serverseitige Anbindung an die Google Analytics Data API (GA4), damit sich Kennzahlen
|
||||||
|
* im eigenen Admin-Dashboard anzeigen lassen - wie z.B. MonsterInsights das fuer WordPress
|
||||||
|
* macht. Authentifizierung per Service-Account (JWT-Bearer-Flow), bewusst ohne die grosse
|
||||||
|
* offizielle Google-API-PHP-Client-Bibliothek: nur ein signiertes JWT + zwei HTTP-Aufrufe,
|
||||||
|
* das reicht fuer diesen einen Report-Endpunkt komplett aus.
|
||||||
|
*/
|
||||||
|
class GoogleAnalyticsReporting
|
||||||
|
{
|
||||||
|
private const TOKEN_URI = 'https://oauth2.googleapis.com/token';
|
||||||
|
private const SCOPE = 'https://www.googleapis.com/auth/analytics.readonly';
|
||||||
|
private const API_BASE = 'https://analyticsdata.googleapis.com/v1beta';
|
||||||
|
|
||||||
|
public static function credentialsPath(): string
|
||||||
|
{
|
||||||
|
return __DIR__ . '/../../config/ga-service-account.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function hasCredentials(): bool
|
||||||
|
{
|
||||||
|
return is_file(self::credentialsPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function base64UrlEncode(string $data): string
|
||||||
|
{
|
||||||
|
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function credentials(): array
|
||||||
|
{
|
||||||
|
$raw = file_get_contents(self::credentialsPath());
|
||||||
|
$data = json_decode((string) $raw, true);
|
||||||
|
if (!is_array($data) || empty($data['client_email']) || empty($data['private_key'])) {
|
||||||
|
throw new \RuntimeException('Service-Account-JSON ist unvollständig oder ungültig.');
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-zu-Server-Authentifizierung per JWT-Bearer-Flow - kein Nutzer-Login/Consent-Screen
|
||||||
|
* noetig, siehe https://developers.google.com/identity/protocols/oauth2/service-account.
|
||||||
|
*/
|
||||||
|
private static function fetchAccessToken(array $credentials): string
|
||||||
|
{
|
||||||
|
$now = time();
|
||||||
|
$header = self::base64UrlEncode(json_encode(['alg' => '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')),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -36,6 +36,7 @@ class Schema
|
||||||
contact_email VARCHAR(150) NULL,
|
contact_email VARCHAR(150) NULL,
|
||||||
hero_image_path VARCHAR(255) NULL,
|
hero_image_path VARCHAR(255) NULL,
|
||||||
ga_measurement_id VARCHAR(20) NULL,
|
ga_measurement_id VARCHAR(20) NULL,
|
||||||
|
ga_property_id VARCHAR(30) NULL,
|
||||||
mail_host VARCHAR(255) NULL,
|
mail_host VARCHAR(255) NULL,
|
||||||
mail_port INT NULL,
|
mail_port INT NULL,
|
||||||
mail_username VARCHAR(255) NULL,
|
mail_username VARCHAR(255) NULL,
|
||||||
|
|
@ -244,6 +245,7 @@ class Schema
|
||||||
'contact_email' => 'VARCHAR(150) NULL',
|
'contact_email' => 'VARCHAR(150) NULL',
|
||||||
'hero_image_path' => 'VARCHAR(255) NULL',
|
'hero_image_path' => 'VARCHAR(255) NULL',
|
||||||
'ga_measurement_id' => 'VARCHAR(20) NULL',
|
'ga_measurement_id' => 'VARCHAR(20) NULL',
|
||||||
|
'ga_property_id' => 'VARCHAR(30) NULL',
|
||||||
'mail_host' => 'VARCHAR(255) NULL',
|
'mail_host' => 'VARCHAR(255) NULL',
|
||||||
'mail_port' => 'INT NULL',
|
'mail_port' => 'INT NULL',
|
||||||
'mail_username' => 'VARCHAR(255) NULL',
|
'mail_username' => 'VARCHAR(255) NULL',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue