Files
deskhub/DeskHub-Webseite/includes/fcm.php
T
2026-10-23 17:22:17 +00:00

552 lines
29 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* includes/fcm.php
*
* Push-Benachrichtigungen für die Android-App über Firebase Cloud Messaging (FCM, HTTP-v1-Schnittstelle).
* Kein Composer-Paket nötig: Das Dienstkonto-Token (OAuth2) wird mit einem selbst signierten JWT (RS256) geholt.
*
* Zwei Wege:
* 1. Standard (Relay): Das DeskHub meldet sich einmal automatisch beim Push-Dienst des Herausgebers an (push.php der Plugin-Webseite,
* Adresse aus catalog_defaults.php) und schickt nur Geräte-Token + Ticketnummer dorthin. Der Firebase-Schlüssel liegt nur beim Herausgeber.
* Der Kunde muss nichts einrichten.
* 2. Eigenes Firebase-Projekt: Dienstkonto-Schlüssel (JSON) im Adminportal hochladen oder in config/config.php
* 'fcm' => ['service_account_file' => '/pfad/zur/datei.json'] eintragen. Dann geht nichts über den Push-Dienst des Herausgebers.
*
* Datenschutz: Standardmäßig enthält die Nachricht nur die Ticketnummer ("Neues Ticket #T-123"). Betreff und Kundenname gehen
* nur an Google, wenn "Details anzeigen" ausdrücklich eingeschaltet wird.
*/
// Aktivität/Auto-Offline: hier mitladen, damit auch eine ältere includes/bootstrap.php (wird vom System-Update nicht überschrieben) genügt
require_once __DIR__ . '/staff_activity.php';
const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging';
const FCM_MAX_DEVICES_PER_STAFF = 10;
/** Dienstkonto-Zugangsdaten (client_email, private_key, project_id, token_uri) oder null, wenn nicht eingerichtet. */
function fcm_service_account(): ?array
{
static $cache = false;
if ($cache !== false && getenv('DESKHUB_TESTING') !== '1') return $cache;
$json = null;
$file = (string) (config()['fcm']['service_account_file'] ?? '');
if ($file !== '' && is_readable($file)) {
$json = (string) file_get_contents($file);
} else {
$enc = (string) get_setting('fcm_service_account', '');
if ($enc !== '') $json = decrypt_secret($enc);
}
$sa = $json !== null && $json !== '' ? fcm_parse_service_account($json) : null;
$cache = is_array($sa) && empty($sa['error']) ? $sa : null;
return $cache;
}
/**
* Prüft den Inhalt einer Dienstkonto-Datei. Gibt die benötigten Felder zurück oder ['error' => Text].
*
* @return array{client_email?:string,private_key?:string,project_id?:string,token_uri?:string,error?:string}
*/
function fcm_parse_service_account(string $json): array
{
$d = json_decode($json, true);
if (!is_array($d) || ($d['type'] ?? '') !== 'service_account') return ['error' => 'Das ist keine Dienstkonto-Datei (Typ "service_account" fehlt). Bitte in der Firebase-Konsole unter Projekteinstellungen → Dienstkonten einen neuen privaten Schlüssel erzeugen.'];
foreach (['client_email', 'private_key', 'project_id'] as $k) {
if (empty($d[$k]) || !is_string($d[$k])) return ['error' => 'In der Datei fehlt das Feld "' . $k . '".'];
}
if (!preg_match('/^[a-z0-9][a-z0-9\-]{4,60}$/i', $d['project_id'])) return ['error' => 'Die Projekt-ID in der Datei ist ungültig.'];
if (!filter_var($d['client_email'], FILTER_VALIDATE_EMAIL)) return ['error' => 'Die Dienstkonto-Adresse in der Datei ist ungültig.'];
if (!@openssl_pkey_get_private($d['private_key'])) return ['error' => 'Der private Schlüssel in der Datei ist nicht lesbar.'];
$tokenUri = (string) ($d['token_uri'] ?? 'https://oauth2.googleapis.com/token');
return ['client_email' => $d['client_email'], 'private_key' => $d['private_key'], 'project_id' => $d['project_id'], 'token_uri' => $tokenUri];
}
/** Adresse des Push-Dienstes des Herausgebers (push.php neben dem Katalog) oder '' wenn keiner eingetragen ist. Im Testbetrieb nur aus config 'fcm' => ['relay_url']. */
function fcm_relay_url(): string
{
if (getenv('DESKHUB_TESTING') === '1') return (string) (config()['fcm']['relay_url'] ?? '');
$p = parse_url(catalog_defaults()['plugins_url']);
if (!is_array($p) || ($p['scheme'] ?? '') !== 'https' || empty($p['host'])) return '';
$dir = rtrim(str_replace('\\', '/', dirname((string) ($p['path'] ?? '/'))), '/');
return 'https://' . $p['host'] . (isset($p['port']) ? ':' . $p['port'] : '') . $dir . '/push.php';
}
/** Welcher Weg gilt: 'own' (eigener Firebase-Schlüssel), 'relay' (Push-Dienst des Herausgebers) oder 'none'. */
function fcm_mode(): string
{
if (fcm_service_account() !== null) return 'own';
return fcm_relay_url() !== '' ? 'relay' : 'none';
}
function fcm_configured(): bool
{
return fcm_mode() !== 'none' && get_setting('fcm_enabled', '1') === '1';
}
/** Installations-ID und Geheimnis beim Push-Dienst (wird beim ersten Senden automatisch angelegt). @return array{url:string,id:string,secret:string}|null */
function fcm_relay_credentials(): ?array
{
$url = fcm_relay_url();
if ($url === '') return null;
$c = json_decode(decrypt_secret((string) get_setting('fcm_relay_install', '')), true);
if (is_array($c) && ($c['url'] ?? '') === $url && !empty($c['id']) && !empty($c['secret'])) return $c;
$headers = ['Content-Type: application/json'];
$key = catalog_defaults()['access_key'];
if ($key !== '') $headers[] = 'X-DeskHub-Key: ' . $key;
$host = (string) (parse_url(base_url(), PHP_URL_HOST) ?: 'deskhub');
$r = fcm_http('POST', $url, $headers, json_encode(['action' => 'register', 'name' => $host]));
$d = json_decode($r['body'], true);
if ($r['code'] !== 200 || empty($d['installId']) || empty($d['secret'])) {
$why = 'HTTP ' . $r['code'] . (is_array($d) && !empty($d['error']) ? ': ' . $d['error'] : '') . ($r['error'] !== '' ? ', ' . $r['error'] : '');
$GLOBALS['fcm_relay_reason'] = $why . ' bei ' . $url;
set_setting('fcm_last_error', date('Y-m-d H:i:s') . ' Anmeldung beim Push-Dienst fehlgeschlagen (' . $why . ')');
return null;
}
$c = ['url' => $url, 'id' => (string) $d['installId'], 'secret' => (string) $d['secret']];
set_setting('fcm_relay_install', encrypt_secret(json_encode($c)));
return $c;
}
/**
* Schickt eine Nachricht über den Push-Dienst an bis zu 50 Geräte. Im Standard geht nur Typ und Ticketnummer mit; Betreff/Kundenname
* nur, wenn "Details" im Adminportal eingeschaltet ist.
*
* @param string[] $tokens
* @param array<string,string> $data
* @return array{ok:bool,sent:int,failed:int,gone:string[],error:string}
*/
function fcm_relay_send(array $tokens, array $data): array
{
$fail = fn(string $e) => ['ok' => false, 'sent' => 0, 'failed' => count($tokens), 'gone' => [], 'error' => $e];
$cred = fcm_relay_credentials();
if (!$cred) return $fail('Anmeldung beim Push-Dienst nicht möglich (' . ($GLOBALS['fcm_relay_reason'] ?? 'unbekannt') . ')');
$details = get_setting('fcm_show_details', '0') === '1';
for ($try = 0; $try < 2; $try++) {
$body = ['action' => 'send', 'installId' => $cred['id'], 'secret' => $cred['secret'], 'tokens' => array_values($tokens),
'type' => (string) ($data['type'] ?? ''), 'ticket' => (string) ($data['ticket'] ?? ''), 'details' => $details];
if ($details) $body['body'] = (string) ($data['body'] ?? '');
if (!empty($data['brand'])) $body['brand'] = (string) $data['brand'];
$r = fcm_http('POST', $cred['url'], ['Content-Type: application/json'], json_encode($body, JSON_UNESCAPED_UNICODE));
$j = json_decode($r['body'], true);
// Installation dem Push-Dienst unbekannt (z. B. nach einem Neuaufbau): einmal neu anmelden
if ($r['code'] === 401 && is_array($j) && !empty($j['reregister']) && $try === 0) {
set_setting('fcm_relay_install', '');
$cred = fcm_relay_credentials();
if (!$cred) return $fail('Anmeldung beim Push-Dienst nicht möglich');
continue;
}
break;
}
if ($r['code'] === 200 && is_array($j) && isset($j['sent'])) {
return ['ok' => true, 'sent' => (int) $j['sent'], 'failed' => (int) ($j['failed'] ?? 0), 'gone' => array_map('strval', (array) ($j['gone'] ?? [])), 'error' => ''];
}
return $fail(is_array($j) && !empty($j['error']) ? (string) $j['error'] : 'HTTP ' . $r['code'] . ($r['error'] !== '' ? ' ' . $r['error'] : ''));
}
/**
* Stellt die Nachricht an die Geräte zu (je nach Weg direkt über Firebase oder über den Push-Dienst), entfernt abgemeldete Geräte und
* merkt sich Erfolg/Fehler für die Adminkarte. @param array<int,array{id:mixed,token:mixed}> $devices @param array<string,string> $data
* @return array{sent:int,failed:int,error:string}
*/
function fcm_deliver(PDO $pdo, array $devices, array $data): array
{
$brand = fcm_brand();
if ($brand !== '') $data['brand'] = $brand;
$sent = 0;
$failed = 0;
$error = '';
$idByToken = [];
foreach ($devices as $d) $idByToken[(string) $d['token']] = $d['id'];
$drop = function (string $token) use ($pdo, $idByToken): void {
if (isset($idByToken[$token])) $pdo->prepare('DELETE FROM push_devices WHERE id = ?')->execute([$idByToken[$token]]);
};
if (fcm_mode() === 'relay') {
foreach (array_chunk(array_map('strval', array_column($devices, 'token')), 50) as $chunk) {
$r = fcm_relay_send($chunk, $data);
$sent += $r['sent'];
$failed += $r['failed'];
if (!$r['ok']) $error = $r['error'];
foreach ($r['gone'] as $t) $drop($t);
}
} elseif (($sa = fcm_service_account()) !== null) {
foreach ($devices as $dev) {
$r = fcm_send_to_token($sa, (string) $dev['token'], $data);
if ($r['ok']) { $sent++; continue; }
$failed++;
if ($r['gone']) $drop((string) $dev['token']);
else $error = $r['error'];
}
}
if ($error !== '') set_setting('fcm_last_error', date('Y-m-d H:i:s') . ' Senden fehlgeschlagen: ' . $error);
elseif ($sent > 0 && $failed === 0) set_setting('fcm_last_error', '');
if ($sent > 0) set_setting('fcm_last_ok', date('Y-m-d H:i:s'));
return ['sent' => $sent, 'failed' => $failed, 'error' => $error];
}
/** Testnachricht an alle Geräte eines Mitarbeiters (sofort, ohne auf das Seitenende zu warten). @return array{sent:int,failed:int,error:string,devices:int} */
function fcm_send_test(PDO $pdo, int $staffId): array
{
$stmt = $pdo->prepare('SELECT id, token FROM push_devices WHERE staff_id = ?');
$stmt->execute([$staffId]);
$devices = $stmt->fetchAll();
if (!$devices) return ['sent' => 0, 'failed' => 0, 'error' => '', 'devices' => 0];
$r = fcm_deliver($pdo, $devices, ['type' => 'test', 'ticket' => '', 'title' => 'Testnachricht', 'body' => 'Die Push-Benachrichtigungen funktionieren.']);
return $r + ['devices' => count($devices)];
}
/** Adresse der FCM-Schnittstelle (nur im Testbetrieb überschreibbar). */
function fcm_api_base(): string
{
if (getenv('DESKHUB_TESTING') === '1' && ($b = getenv('DESKHUB_FCM_API_BASE'))) return rtrim((string) $b, '/');
return 'https://fcm.googleapis.com';
}
/** Ob die Adresse für den Token-Abruf erlaubt ist (in Produktion nur Googles Server, sonst würden Schlüssel an Fremde gehen). */
function fcm_token_uri_allowed(string $uri): bool
{
if (getenv('DESKHUB_TESTING') === '1') return true;
return str_starts_with($uri, 'https://oauth2.googleapis.com/');
}
function fcm_b64url(string $s): string
{
return rtrim(strtr(base64_encode($s), '+/', '-_'), '=');
}
/** Kleine HTTP-Anfrage (JSON oder Formular) mit kurzen Zeitlimits. @return array{code:int,body:string,error:string} */
function fcm_http(string $method, string $url, array $headers, string $body): array
{
if (!function_exists('curl_init')) return ['code' => 0, 'body' => '', 'error' => 'PHP cURL fehlt'];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => $headers,
CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => 10, CURLOPT_FOLLOWLOCATION => false, CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
]);
$out = curl_exec($ch);
$res = ['code' => (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE), 'body' => is_string($out) ? $out : '', 'error' => $out === false ? curl_error($ch) : ''];
curl_close($ch);
return $res;
}
/** Zugriffstoken für die FCM-Schnittstelle (wird bis kurz vor Ablauf zwischengespeichert). */
function fcm_access_token(array $sa, bool $force = false): ?string
{
if (!$force) {
$cached = json_decode(decrypt_secret((string) get_setting('fcm_access_token', '')), true);
if (is_array($cached) && ($cached['exp'] ?? 0) > time() + 120 && ($cached['project'] ?? '') === $sa['project_id']) return (string) $cached['token'];
}
if (!fcm_token_uri_allowed($sa['token_uri'])) return null;
$now = time();
$head = fcm_b64url(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
$claims = fcm_b64url(json_encode(['iss' => $sa['client_email'], 'scope' => FCM_SCOPE, 'aud' => $sa['token_uri'], 'iat' => $now, 'exp' => $now + 3600]));
$key = openssl_pkey_get_private($sa['private_key']);
if (!$key || !openssl_sign($head . '.' . $claims, $sig, $key, OPENSSL_ALGO_SHA256)) return null;
$r = fcm_http('POST', $sa['token_uri'], ['Content-Type: application/x-www-form-urlencoded'],
http_build_query(['grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', 'assertion' => $head . '.' . $claims . '.' . fcm_b64url($sig)]));
$d = json_decode($r['body'], true);
if ($r['code'] !== 200 || empty($d['access_token'])) {
set_setting('fcm_last_error', date('Y-m-d H:i:s') . ' Token-Abruf fehlgeschlagen (HTTP ' . $r['code'] . ($r['error'] !== '' ? ', ' . $r['error'] : '') . ')');
return null;
}
set_setting('fcm_access_token', encrypt_secret(json_encode(['token' => $d['access_token'], 'exp' => $now + (int) ($d['expires_in'] ?? 3000), 'project' => $sa['project_id']])));
return (string) $d['access_token'];
}
/**
* Schickt eine Nachricht an ein Gerät. Die Nachricht ist eine reine Daten-Nachricht mit hoher Priorität: Die App baut daraus selbst
* die Benachrichtigung (damit ein Tipp das richtige Ticket öffnet).
*
* @param array<string,string> $data
* @return array{ok:bool,gone:bool,status:int,error:string}
*/
function fcm_send_to_token(array $sa, string $token, array $data, int $ttlSeconds = 3600): array
{
$access = fcm_access_token($sa);
if ($access === null) return ['ok' => false, 'gone' => false, 'status' => 0, 'error' => 'Kein Zugriffstoken (Dienstkonto prüfen)'];
$payload = json_encode(['message' => [
'token' => $token,
'data' => array_map('strval', $data),
'android' => ['priority' => 'HIGH', 'ttl' => $ttlSeconds . 's'],
]], JSON_UNESCAPED_UNICODE);
$url = fcm_api_base() . '/v1/projects/' . rawurlencode($sa['project_id']) . '/messages:send';
for ($try = 0; $try < 2; $try++) {
$r = fcm_http('POST', $url, ['Authorization: Bearer ' . $access, 'Content-Type: application/json; charset=utf-8'], $payload);
if ($r['code'] === 401 && $try === 0) { $access = fcm_access_token($sa, true); if ($access === null) break; continue; }
break;
}
if ($r['code'] === 200) return ['ok' => true, 'gone' => false, 'status' => 200, 'error' => ''];
$err = json_decode($r['body'], true)['error'] ?? [];
$status = (string) ($err['status'] ?? '');
// Token ungültig/abgemeldet: das Gerät aus der Liste nehmen
$gone = $r['code'] === 404 || $status === 'NOT_FOUND' || $status === 'UNREGISTERED' || ($r['code'] === 400 && $status === 'INVALID_ARGUMENT');
return ['ok' => false, 'gone' => $gone, 'status' => $r['code'], 'error' => $status !== '' ? $status . ': ' . (string) ($err['message'] ?? '') : ('HTTP ' . $r['code'] . ($r['error'] !== '' ? ' ' . $r['error'] : ''))];
}
/** Führt $fn nach dem Senden der Antwort aus (im Web), auf der Kommandozeile sofort. Fehler stören die Seite nie. */
function fcm_after_response(callable $fn): void
{
$run = function () use ($fn) {
try {
$fn();
} catch (Throwable $e) {
error_log('fcm: ' . $e->getMessage());
}
};
if (PHP_SAPI === 'cli' || getenv('DESKHUB_FCM_SYNC') === '1') { $run(); return; }
register_shutdown_function(function () use ($run) {
if (function_exists('fastcgi_finish_request')) @fastcgi_finish_request();
@ignore_user_abort(true);
$run();
});
}
/**
* Schickt eine Nachricht an alle Geräte der genannten Mitarbeiter (nach dem Senden der Seitenantwort). Ungültige Geräte werden entfernt.
*
* @param int[] $staffIds
* @param array<string,string> $data
*/
function fcm_notify_staff(array $staffIds, array $data, ?int $departmentId = null, ?callable $extraFilter = null): void
{
$staffIds = array_values(array_unique(array_filter(array_map('intval', $staffIds))));
if (!$staffIds || !fcm_configured()) return;
fcm_after_response(function () use ($staffIds, $data, $departmentId, $extraFilter) {
$pdo = get_db();
// Rechte: Nur wer Zugriff auf die Abteilung des Tickets hat (und für diese Art von Push berechtigt ist), bekommt etwas
if ($departmentId !== null) {
$staffIds = fcm_staff_allowed($pdo, $staffIds, $departmentId, fcm_event_permission((string) ($data['type'] ?? '')), $extraFilter);
if (!$staffIds) return;
}
$in = implode(',', array_fill(0, count($staffIds), '?'));
$stmt = $pdo->prepare("SELECT d.id, d.token, d.staff_id FROM push_devices d JOIN staff s ON s.id = d.staff_id WHERE s.is_active = 1 AND d.staff_id IN ($in)");
$stmt->execute($staffIds);
// Einstellungen je Mitarbeiter: abgewählte Ereignisse und stille Zeiten überspringen
$devices = array_values(array_filter($stmt->fetchAll(), fn($d) => fcm_wants(fcm_prefs($pdo, (int) $d['staff_id']), (string) ($data['type'] ?? ''))));
if ($devices) fcm_deliver($pdo, $devices, $data);
});
}
/** Welche Berechtigung für eine Art von Push nötig ist (null = nur Zugriff auf die Abteilung). */
function fcm_event_permission(string $type): ?string
{
return in_array($type, ['ticket_new', 'ticket_reply', 'chat_new'], true) ? 'reply_ticket' : null;
}
/**
* Filtert Mitarbeiter auf die, die das Ticket der Abteilung überhaupt sehen dürfen (Admin oder Mitglied der Abteilung) und – wenn
* verlangt – die passende Berechtigung in dieser Abteilung haben. Optional ein zusätzlicher Filter (Mitarbeiter-Zeile => bool).
*
* @param int[] $staffIds
* @return int[]
*/
function fcm_staff_allowed(PDO $pdo, array $staffIds, int $departmentId, ?string $permission = null, ?callable $extra = null): array
{
$staffIds = array_values(array_unique(array_filter(array_map('intval', $staffIds))));
if (!$staffIds) return [];
$in = implode(',', array_fill(0, count($staffIds), '?'));
$stmt = $pdo->prepare("SELECT * FROM staff WHERE is_active = 1 AND id IN ($in)");
$stmt->execute($staffIds);
$ok = [];
foreach ($stmt->fetchAll() as $s) {
$depts = staff_department_ids($s);
if ($depts !== null && !in_array($departmentId, $depts, true)) continue;
if ($permission !== null && !has_permission($s, $permission, $departmentId)) continue;
if ($extra !== null && !$extra($s)) continue;
$ok[] = (int) $s['id'];
}
return $ok;
}
/** Mitarbeiter, die ein neues Ticket der Abteilung mitbekommen sollen (wie bei der E-Mail: Admins, Hauptabteilung, zusätzliche Abteilung mit Benachrichtigung). */
function fcm_recipients_for_department(PDO $pdo, ?int $deptId): array
{
if ($deptId) {
$stmt = $pdo->prepare("SELECT DISTINCT id FROM staff WHERE is_active = 1 AND (role = 'admin' OR department_id = ? OR id IN (SELECT staff_id FROM staff_department WHERE department_id = ? AND notify = 1))");
$stmt->execute([$deptId, $deptId]);
} else {
$stmt = $pdo->query("SELECT id FROM staff WHERE is_active = 1 AND role = 'admin'");
}
return array_map('intval', $stmt->fetchAll(PDO::FETCH_COLUMN));
}
/** Text der Benachrichtigung: standardmäßig nur mit Ticketnummer (privat), mit "Details" auch Betreff und Kunde. */
function fcm_message(string $type, array $ticket): array
{
$titles = ['ticket_new' => 'Neues Ticket', 'ticket_reply' => 'Neue Antwort', 'ticket_assigned' => 'Ticket zugewiesen', 'chat_new' => 'Neuer Live-Chat', 'ticket_sla' => 'SLA überschritten', 'ticket_mention' => 'Erwähnung', 'ticket_snooze' => 'Wiedervorlage fällig', 'ticket_follow' => 'Beobachtetes Ticket'];
$title = $titles[$type] ?? 'DeskHub';
$number = (string) ($ticket['ticket_number'] ?? '');
if (get_setting('fcm_show_details', '0') === '1') {
$who = trim((string) ($ticket['requester_name'] ?? ''));
$body = mb_strimwidth(($who !== '' ? $who . ': ' : '') . (string) ($ticket['subject'] ?? ''), 0, 120, '…');
return [$title . ' #' . $number, $body];
}
return [$title, '#' . $number];
}
/** Bereinigt den frei wählbaren Namen für die Benachrichtigung (nur Text, höchstens 40 Zeichen). */
function fcm_clean_brand(string $s): string
{
$s = trim(preg_replace('/[\x00-\x1F\x7F]+/u', ' ', strip_tags($s)) ?? '');
return mb_substr($s, 0, 40);
}
/** Name, der in den Popups neben dem App-Namen steht (Einstellung des Admins; leer = nichts). */
function fcm_brand(): string
{
return fcm_clean_brand((string) get_setting('push_brand', ''));
}
function fcm_data(string $type, array $ticket): array
{
[$title, $body] = fcm_message($type, $ticket);
return ['type' => $type, 'ticket' => (string) ($ticket['ticket_number'] ?? ''), 'title' => $title, 'body' => $body];
}
function fcm_notify_new_ticket(array $ticket): void
{
if (!fcm_configured() || empty($ticket['ticket_number'])) return;
$type = (($ticket['source'] ?? '') === 'chat') ? 'chat_new' : 'ticket_new';
$dept = isset($ticket['department_id']) ? (int) $ticket['department_id'] : 0;
fcm_notify_staff(fcm_recipients_for_department(get_db(), $dept ?: null), fcm_data($type, $ticket), $dept);
}
/** Neue Kundenantwort: an den zugewiesenen Mitarbeiter, sonst an die Abteilung. */
function fcm_notify_new_reply(array $ticket): void
{
if (!fcm_configured() || empty($ticket['ticket_number'])) return;
$pdo = get_db();
$to = !empty($ticket['assigned_staff_id'])
? [(int) $ticket['assigned_staff_id']]
: fcm_recipients_for_department($pdo, isset($ticket['department_id']) ? (int) $ticket['department_id'] : null);
fcm_notify_staff($to, fcm_data('ticket_reply', $ticket), (int) ($ticket['department_id'] ?? 0));
}
/** Ticket wurde einem Mitarbeiter zugewiesen: nur an diesen (und nicht an den, der es selbst zugewiesen hat). */
function fcm_notify_assignment(array $ticket, int $staffId, ?int $actorId = null): void
{
if (!fcm_configured() || $staffId <= 0 || $staffId === $actorId || empty($ticket['ticket_number'])) return;
fcm_notify_staff([$staffId], fcm_data('ticket_assigned', $ticket), (int) ($ticket['department_id'] ?? 0));
}
/** Ereignisse, die per Push kommen können (Schlüssel => Beschriftung). */
function fcm_event_types(): array
{
return ['ticket_new' => 'Neues Ticket', 'ticket_reply' => 'Neue Kundenantwort', 'ticket_assigned' => 'Ticket mir zugewiesen', 'chat_new' => 'Neuer Live-Chat',
'ticket_sla' => 'SLA überschritten', 'ticket_mention' => 'Ich wurde erwähnt', 'ticket_snooze' => 'Wiedervorlage fällig', 'ticket_follow' => 'Beobachtetes Ticket aktualisiert'];
}
/**
* Die Push-Arten, die für diesen Mitarbeiter überhaupt in Frage kommen: ohne Recht zum Antworten gibt es keine Popups für neue
* Tickets, Antworten und Live-Chats; Live-Chat nur, wenn er eingeschaltet ist.
*
* @return array<string,string> Schlüssel => Beschriftung
*/
function fcm_event_types_for(array $staff): array
{
$types = fcm_event_types();
if (!has_permission($staff, 'reply_ticket')) unset($types['ticket_new'], $types['ticket_reply'], $types['chat_new']);
if (get_setting('live_chat_enabled', '0') !== '1') unset($types['chat_new']);
return $types;
}
/** Push-Einstellungen eines Mitarbeiters; ohne Eintrag gilt: alles an, keine stillen Zeiten. @return array{events:string[],quiet_from:string,quiet_to:string} */
function fcm_prefs(PDO $pdo, int $staffId): array
{
$all = array_keys(fcm_event_types());
$stmt = $pdo->prepare('SELECT events, quiet_from, quiet_to FROM push_prefs WHERE staff_id = ?');
$stmt->execute([$staffId]);
$row = $stmt->fetch();
if (!$row) return ['events' => $all, 'quiet_from' => '', 'quiet_to' => ''];
return ['events' => array_values(array_intersect($all, array_filter(explode(',', (string) $row['events'])))), 'quiet_from' => (string) ($row['quiet_from'] ?? ''), 'quiet_to' => (string) ($row['quiet_to'] ?? '')];
}
function fcm_time_valid(string $t): bool
{
return preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $t) === 1;
}
/** @param string[] $events */
function fcm_save_prefs(PDO $pdo, int $staffId, array $events, string $quietFrom, string $quietTo, ?array $shownTypes = null): void
{
// Arten, die der Mitarbeiter nicht zu sehen bekommt (kein Recht), bleiben "an": gesendet wird dafür ohnehin nur mit Recht
$hidden = $shownTypes === null ? [] : array_diff(array_keys(fcm_event_types()), $shownTypes);
$events = array_values(array_intersect(array_keys(fcm_event_types()), array_merge($shownTypes === null ? $events : array_intersect($events, $shownTypes), $hidden)));
if (!fcm_time_valid($quietFrom) || !fcm_time_valid($quietTo)) { $quietFrom = ''; $quietTo = ''; }
$pdo->prepare('DELETE FROM push_prefs WHERE staff_id = ?')->execute([$staffId]);
$pdo->prepare('INSERT INTO push_prefs (staff_id, events, quiet_from, quiet_to) VALUES (?,?,?,?)')
->execute([$staffId, implode(',', $events), $quietFrom !== '' ? $quietFrom : null, $quietTo !== '' ? $quietTo : null]);
}
/** Ob die Zeit in den stillen Zeitraum fällt (auch über Mitternacht, z. B. 22:00 bis 06:00). */
function fcm_in_quiet(array $prefs, ?string $now = null): bool
{
[$from, $to] = [$prefs['quiet_from'], $prefs['quiet_to']];
if (!fcm_time_valid($from) || !fcm_time_valid($to) || $from === $to) return false;
$now ??= date('H:i');
return $from < $to ? ($now >= $from && $now < $to) : ($now >= $from || $now < $to);
}
/** Will dieser Mitarbeiter diese Art von Push jetzt bekommen? Testnachrichten kommen immer an. */
function fcm_wants(array $prefs, string $type): bool
{
if ($type === 'test') return true;
return in_array($type, $prefs['events'], true) && !fcm_in_quiet($prefs);
}
/**
* Ticket ist neu überfällig (SLA): nur an den zugewiesenen Mitarbeiter, an Admins und an Mitarbeiter mit dem Recht
* "Eskalationsregeln verwalten" (jeweils mit Zugriff auf die Abteilung). Normale Abteilungsmitglieder bekommen es nicht.
*/
function fcm_notify_overdue(array $ticket): void
{
if (!fcm_configured() || empty($ticket['ticket_number'])) return;
$dept = (int) ($ticket['department_id'] ?? 0);
$assigned = (int) ($ticket['assigned_staff_id'] ?? 0);
$to = fcm_recipients_for_department(get_db(), $dept ?: null);
if ($assigned > 0) $to[] = $assigned;
fcm_notify_staff($to, fcm_data('ticket_sla', $ticket), $dept, fn(array $s) => (int) $s['id'] === $assigned || has_permission($s, 'manage_escalations', $dept));
}
/** Mitarbeiter wurde in einer internen Notiz erwähnt. */
function fcm_notify_mention(array $ticket, int $staffId, ?int $actorId = null): void
{
if (!fcm_configured() || $staffId <= 0 || $staffId === $actorId || empty($ticket['ticket_number'])) return;
fcm_notify_staff([$staffId], fcm_data('ticket_mention', $ticket), (int) ($ticket['department_id'] ?? 0));
}
// ── Geräte (Tokens der Android-App) ──────────────────────────────────────────
/** Meldet ein Gerät an (ein Token gehört immer nur einem Mitarbeiter, der zuletzt angemeldete gewinnt). */
function fcm_register_device(PDO $pdo, int $staffId, string $token, string $deviceName = '', string $appVersion = ''): void
{
$hash = hash('sha256', $token);
$now = db_now();
$deviceName = mb_substr($deviceName, 0, 120);
$appVersion = mb_substr($appVersion, 0, 30);
$stmt = $pdo->prepare('SELECT id FROM push_devices WHERE token_hash = ?');
$stmt->execute([$hash]);
$id = $stmt->fetchColumn();
if ($id) {
$pdo->prepare('UPDATE push_devices SET staff_id = ?, device_name = ?, app_version = ?, last_seen_at = ? WHERE id = ?')->execute([$staffId, $deviceName, $appVersion, $now, $id]);
} else {
$pdo->prepare('INSERT INTO push_devices (staff_id, token, token_hash, platform, device_name, app_version, created_at, last_seen_at) VALUES (?,?,?,?,?,?,?,?)')
->execute([$staffId, $token, $hash, 'android', $deviceName, $appVersion, $now, $now]);
}
// höchstens FCM_MAX_DEVICES_PER_STAFF Geräte je Mitarbeiter: die ältesten fallen heraus
$ids = $pdo->prepare('SELECT id FROM push_devices WHERE staff_id = ? ORDER BY last_seen_at DESC, id DESC');
$ids->execute([$staffId]);
foreach (array_slice($ids->fetchAll(PDO::FETCH_COLUMN), FCM_MAX_DEVICES_PER_STAFF) as $old) {
$pdo->prepare('DELETE FROM push_devices WHERE id = ?')->execute([$old]);
}
}
function fcm_unregister_device(PDO $pdo, int $staffId, string $token): void
{
$pdo->prepare('DELETE FROM push_devices WHERE staff_id = ? AND token_hash = ?')->execute([$staffId, hash('sha256', $token)]);
}
/** Ein FCM-Token hat nur diese Zeichen; alles andere wird abgelehnt. */
function fcm_token_valid(string $token): bool
{
return strlen($token) >= 20 && strlen($token) <= 4096 && preg_match('/^[A-Za-z0-9_:\-\.]+$/', $token) === 1;
}