['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 $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 $devices @param array $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 $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 $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_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 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; }