From a94a36444c4cec1be421a7c22c627416aa29f104 Mon Sep 17 00:00:00 2001 From: M_Viper Date: Wed, 21 Oct 2026 16:44:46 +0000 Subject: [PATCH] Upload via GUI (278 Dateien) --- DeskHub-Webseite/includes/fcm.php | 551 ++++++++++++++++++ DeskHub-Webseite/includes/functions.php | 2 + DeskHub-Webseite/includes/notifications.php | 2 + DeskHub-Webseite/includes/staff_activity.php | 108 ++++ DeskHub-Webseite/staff/availability.php | 77 ++- .../staff/availability_status.php | 1 + 6 files changed, 730 insertions(+), 11 deletions(-) create mode 100644 DeskHub-Webseite/includes/fcm.php create mode 100644 DeskHub-Webseite/includes/staff_activity.php diff --git a/DeskHub-Webseite/includes/fcm.php b/DeskHub-Webseite/includes/fcm.php new file mode 100644 index 00000000..ecb956ca --- /dev/null +++ b/DeskHub-Webseite/includes/fcm.php @@ -0,0 +1,551 @@ + ['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(); + // 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']; + $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']; +} + +/** + * 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; +} diff --git a/DeskHub-Webseite/includes/functions.php b/DeskHub-Webseite/includes/functions.php index 7e2c7d77..ccb8c6f6 100644 --- a/DeskHub-Webseite/includes/functions.php +++ b/DeskHub-Webseite/includes/functions.php @@ -1298,6 +1298,7 @@ function is_staff_available(PDO $pdo, int $staffId): bool // Echtzeit-Status try { + staff_run_auto_offline($pdo); $s = $pdo->prepare('SELECT status FROM staff_availability WHERE staff_id = ?'); $s->execute([$staffId]); $avail = $s->fetchColumn(); @@ -1830,6 +1831,7 @@ function save_mentions(PDO $pdo, int $ticketId, int $threadId, int $byStaffId, s if ($mentionedId === $byStaffId) continue; $pdo->prepare('INSERT INTO staff_mentions (ticket_id,thread_id,mentioned_staff_id,mentioned_by_staff_id,created_at) VALUES (?,?,?,?,?)') ->execute([$ticketId, $threadId, $mentionedId, $byStaffId, db_now()]); + fcm_notify_mention($ticket, $mentionedId, $byStaffId); // Push auf die Android-App // E-Mail an erwähnten Mitarbeiter $stmt = $pdo->prepare('SELECT email, name FROM staff WHERE id=?'); $stmt->execute([$mentionedId]); diff --git a/DeskHub-Webseite/includes/notifications.php b/DeskHub-Webseite/includes/notifications.php index 02d692ef..bb36bfae 100644 --- a/DeskHub-Webseite/includes/notifications.php +++ b/DeskHub-Webseite/includes/notifications.php @@ -64,6 +64,7 @@ function telegram_notify(string $message): bool /** Benachrichtigt E-Mail (Abteilung) + Discord + Telegram über ein neues Ticket */ function notify_new_ticket_channels(array $ticket, ?string $departmentEmail): void { + fcm_notify_new_ticket($ticket); // Push auf die Android-App (läuft nach dem Senden der Seite) if ($departmentEmail) { notify_department_new_ticket($ticket, $departmentEmail); } @@ -83,6 +84,7 @@ function notify_new_ticket_channels(array $ticket, ?string $departmentEmail): vo /** Benachrichtigt Discord + Telegram über eine neue Kundenantwort (kein Mail-Duplikat nötig) */ function notify_new_reply_channels(array $ticket, string $excerpt): void { + fcm_notify_new_reply($ticket); // Push auf die Android-App (läuft nach dem Senden der Seite) $short = mb_strlen($excerpt) > 200 ? mb_substr($excerpt, 0, 200) . '…' : $excerpt; $text = "💬 Neue Kundenantwort zu Ticket #{$ticket['ticket_number']}\n" . "Betreff: {$ticket['subject']}\n" diff --git a/DeskHub-Webseite/includes/staff_activity.php b/DeskHub-Webseite/includes/staff_activity.php new file mode 100644 index 00000000..52571b41 --- /dev/null +++ b/DeskHub-Webseite/includes/staff_activity.php @@ -0,0 +1,108 @@ +prepare('UPDATE staff SET last_active_at = ? WHERE id = ? AND (last_active_at IS NULL OR last_active_at < ?)') + ->execute([db_now(), $staffId, $cut]); + // War der Mitarbeiter automatisch offline gesetzt, kommt sein vorheriger Status zurück + $pdo->prepare("UPDATE staff_availability SET status = COALESCE(prev_status, 'available'), auto_offline = 0, prev_status = NULL, updated_at = ? WHERE staff_id = ? AND auto_offline = 1") + ->execute([db_now(), $staffId]); + } catch (Throwable $e) { + error_log('staff_touch_activity: ' . $e->getMessage()); + } +} + +/** + * Setzt alle Mitarbeiter offline, die länger als die eingestellte Zeit nicht aktiv waren. Läuft höchstens alle 30 Sekunden und wird + * von allen Stellen aufgerufen, die den Status lesen. Mitarbeiter, die noch nie aktiv gemeldet wurden (last_active_at leer), bleiben + * unverändert. @return int Anzahl der neu auf Offline gesetzten Mitarbeiter + */ +function staff_run_auto_offline(PDO $pdo, bool $force = false): int +{ + static $lastRun = 0; + $minutes = staff_auto_offline_minutes(); + if ($minutes <= 0 || (!$force && time() - $lastRun < 30)) return 0; + $lastRun = time(); + try { + $cutoff = date('Y-m-d H:i:s', time() - $minutes * 60); + $stmt = $pdo->prepare("UPDATE staff_availability SET prev_status = status, status = 'offline', auto_offline = 1, updated_at = ? + WHERE status <> 'offline' AND staff_id IN (SELECT id FROM staff WHERE is_active = 1 AND last_active_at IS NOT NULL AND last_active_at < ?)"); + $stmt->execute([db_now(), $cutoff]); + return $stmt->rowCount(); + } catch (Throwable $e) { + error_log('staff_run_auto_offline: ' . $e->getMessage()); + return 0; + } +} + +/** + * Setzt den Status eines Mitarbeiters von Hand (er selbst oder ein Admin). Ein manueller Status ist nie "automatisch" und wird + * deshalb nicht von selbst zurückgesetzt. Gibt false bei ungültigem Status zurück. + */ +function staff_set_status(PDO $pdo, int $staffId, string $status, string $note = ''): bool +{ + if (!in_array($status, STAFF_AVAILABILITY_STATUSES, true)) return false; + $note = mb_substr(trim($note), 0, 120); + $now = db_now(); + $drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + $sql = $drv === 'sqlite' + ? 'INSERT INTO staff_availability (staff_id, status, note, updated_at, auto_offline, prev_status) VALUES (?,?,?,?,0,NULL) + ON CONFLICT(staff_id) DO UPDATE SET status=excluded.status, note=excluded.note, updated_at=excluded.updated_at, auto_offline=0, prev_status=NULL' + : 'INSERT INTO staff_availability (staff_id, status, note, updated_at, auto_offline, prev_status) VALUES (?,?,?,?,0,NULL) + ON DUPLICATE KEY UPDATE status=VALUES(status), note=VALUES(note), updated_at=VALUES(updated_at), auto_offline=0, prev_status=NULL'; + $pdo->prepare($sql)->execute([$staffId, $status, $note !== '' ? $note : null, $now]); + return true; +} + +/** Ist die aktuelle Anfrage eine echte Seitennavigation des Nutzers (und kein Hintergrund-Abruf per fetch)? */ +function staff_is_user_navigation(): bool +{ + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET' && ($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') return false; + $mode = $_SERVER['HTTP_SEC_FETCH_MODE'] ?? ''; + if ($mode !== '') return $mode === 'navigate'; + // Ältere Browser ohne Sec-Fetch-Header: Seitenaufrufe erkennt man daran, dass kein X-Requested-With gesetzt ist und HTML erwartet wird + return empty($_SERVER['HTTP_X_REQUESTED_WITH']) && str_contains((string) ($_SERVER['HTTP_ACCEPT'] ?? ''), 'text/html'); +} + +/** "vor 5 Min", "vor 2 Std", "vor 3 Tagen" oder '' wenn unbekannt. */ +function staff_last_active_label(?string $lastActiveAt): string +{ + if (!$lastActiveAt) return ''; + $t = strtotime($lastActiveAt); + if (!$t) return ''; + $d = max(0, time() - $t); + if ($d < 90) return 'gerade eben'; + if ($d < 3600) return 'vor ' . (int) round($d / 60) . ' Min'; + if ($d < 86400) return 'vor ' . (int) round($d / 3600) . ' Std'; + return 'vor ' . (int) round($d / 86400) . ' Tagen'; +} diff --git a/DeskHub-Webseite/staff/availability.php b/DeskHub-Webseite/staff/availability.php index 205ee272..cfc15605 100644 --- a/DeskHub-Webseite/staff/availability.php +++ b/DeskHub-Webseite/staff/availability.php @@ -25,17 +25,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['status']) && !isset($ exit; } - $drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); - $availabilitySql = $drv === 'sqlite' - ? 'INSERT INTO staff_availability (staff_id, status, note, updated_at) VALUES (?,?,?,?) - ON CONFLICT(staff_id) DO UPDATE SET - status=excluded.status, note=excluded.note, updated_at=excluded.updated_at' - : 'INSERT INTO staff_availability (staff_id, status, note, updated_at) VALUES (?,?,?,?) - ON DUPLICATE KEY UPDATE - status=VALUES(status), note=VALUES(note), updated_at=VALUES(updated_at)'; - try { - $pdo->prepare($availabilitySql)->execute([$staff['id'], $newStatus, $note ?: null, db_now()]); + staff_set_status($pdo, (int) $staff['id'], $newStatus, $note); + staff_touch_activity($pdo, (int) $staff['id']); echo json_encode(['ok' => true, 'status' => $newStatus]); } catch (Throwable $e) { error_log('staff_availability: ' . $e->getMessage()); @@ -52,6 +44,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { verify_csrf(); $action = $_POST['action'] ?? ''; + // Admin: Status eines Mitarbeiters ändern + if ($action === 'set_staff_status' && $admin) { + $targetId = (int) ($_POST['staff_id'] ?? 0); + $newStatus = (string) ($_POST['status'] ?? ''); + $exists = $pdo->prepare('SELECT name FROM staff WHERE id = ? AND is_active = 1'); + $exists->execute([$targetId]); + $targetName = $exists->fetchColumn(); + if (!$targetName || !in_array($newStatus, STAFF_AVAILABILITY_STATUSES, true)) { + flash('error', 'Ungültiger Mitarbeiter oder Status.'); + } else { + staff_set_status($pdo, $targetId, $newStatus, (string) ($_POST['note'] ?? '')); + audit_log($admin['id'], "Status von Mitarbeiter #$targetId auf $newStatus gesetzt"); + flash('success', 'Status von ' . $targetName . ' geändert.'); + } + redirect('/staff/availability.php'); + } + // Admin: Zeit bis zum automatischen Offline einstellen + if ($action === 'save_auto_offline' && $admin) { + $m = max(0, min(1440, (int) ($_POST['auto_offline_minutes'] ?? 30))); + set_setting('staff_auto_offline_minutes', (string) $m); + audit_log($admin['id'], 'Automatisch offline nach ' . ($m > 0 ? $m . ' Minuten' : 'aus') . ' gesetzt'); + flash('success', $m > 0 ? "Mitarbeiter werden nach $m Minuten ohne Aktivität automatisch auf Offline gesetzt." : 'Automatisch offline ist ausgeschaltet.'); + redirect('/staff/availability.php'); + } + if ($action === 'save_shifts' && $admin) { $targetId = (int)($_POST['staff_id'] ?? 0); $__nameStmt = $pdo->prepare('SELECT name FROM staff WHERE id = ?'); @@ -81,8 +98,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } // ── Daten laden ─────────────────────────────────────────────────────────────── +staff_run_auto_offline($pdo); $agents = $pdo->query( - "SELECT s.*, sa.status AS avail_status, sa.note AS avail_note, sa.updated_at AS avail_updated + "SELECT s.*, sa.status AS avail_status, sa.auto_offline AS avail_auto, sa.note AS avail_note, sa.updated_at AS avail_updated FROM staff s LEFT JOIN staff_availability sa ON sa.staff_id = s.id WHERE s.is_active = 1 @@ -174,11 +192,48 @@ $myStatus = $myAvail['avail_status'] ?? 'available'; ⚙️ + +
offline' : '' ?>
+ + +
+
Status der Mitarbeiter ändern (Admin)
+

Ein von dir gesetzter Status bleibt, bis der Mitarbeiter ihn selbst ändert oder du ihn erneut änderst.

+ + + + + + + +
+
+ + + + + + +
+
+
+ + + + Minuten ohne Aktivität + + 0 = aus. Bei neuer Aktivität kommt der vorherige Status zurück. +
+
+ + query( "SELECT s.id, s.name, s.is_on_vacation, COALESCE(sa.status, 'offline') AS status,