556 lines
30 KiB
PHP
556 lines
30 KiB
PHP
<?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();
|
||
// Mitarbeiter im Urlaub: stattdessen bekommt ihre Vertretung die Meldung
|
||
$staffIds = vacation_expand_ids($pdo, $staffIds);
|
||
if (!$staffIds) return;
|
||
// 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 s.is_on_vacation = 0 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_wait' => 'Ticket wartet auf Antwort', '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_wait' => 'Ticket wartet auf meine Antwort', '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['ticket_wait'], $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;
|
||
if (function_exists('sla_notify_mode') && sla_notify_mode() === 'admins') return; // nur Administratoren und nur per E-Mail
|
||
$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;
|
||
}
|