Upload via GUI (278 Dateien)

This commit is contained in:
2026-10-21 16:44:46 +00:00
parent 738b54048f
commit a94a36444c
6 changed files with 730 additions and 11 deletions
+551
View File
@@ -0,0 +1,551 @@
<?php
/**
* includes/fcm.php
*
* Push-Benachrichtigungen für die Android-App über Firebase Cloud Messaging (FCM, HTTP-v1-Schnittstelle).
* Kein Composer-Paket nötig: Das Dienstkonto-Token (OAuth2) wird mit einem selbst signierten JWT (RS256) geholt.
*
* Zwei Wege:
* 1. Standard (Relay): Das DeskHub meldet sich einmal automatisch beim Push-Dienst des Herausgebers an (push.php der Plugin-Webseite,
* Adresse aus catalog_defaults.php) und schickt nur Geräte-Token + Ticketnummer dorthin. Der Firebase-Schlüssel liegt nur beim Herausgeber.
* Der Kunde muss nichts einrichten.
* 2. Eigenes Firebase-Projekt: Dienstkonto-Schlüssel (JSON) im Adminportal hochladen oder in config/config.php
* 'fcm' => ['service_account_file' => '/pfad/zur/datei.json'] eintragen. Dann geht nichts über den Push-Dienst des Herausgebers.
*
* Datenschutz: Standardmäßig enthält die Nachricht nur die Ticketnummer ("Neues Ticket #T-123"). Betreff und Kundenname gehen
* nur an Google, wenn "Details anzeigen" ausdrücklich eingeschaltet wird.
*/
// Aktivität/Auto-Offline: hier mitladen, damit auch eine ältere includes/bootstrap.php (wird vom System-Update nicht überschrieben) genügt
require_once __DIR__ . '/staff_activity.php';
const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging';
const FCM_MAX_DEVICES_PER_STAFF = 10;
/** Dienstkonto-Zugangsdaten (client_email, private_key, project_id, token_uri) oder null, wenn nicht eingerichtet. */
function fcm_service_account(): ?array
{
static $cache = false;
if ($cache !== false && getenv('DESKHUB_TESTING') !== '1') return $cache;
$json = null;
$file = (string) (config()['fcm']['service_account_file'] ?? '');
if ($file !== '' && is_readable($file)) {
$json = (string) file_get_contents($file);
} else {
$enc = (string) get_setting('fcm_service_account', '');
if ($enc !== '') $json = decrypt_secret($enc);
}
$sa = $json !== null && $json !== '' ? fcm_parse_service_account($json) : null;
$cache = is_array($sa) && empty($sa['error']) ? $sa : null;
return $cache;
}
/**
* Prüft den Inhalt einer Dienstkonto-Datei. Gibt die benötigten Felder zurück oder ['error' => Text].
*
* @return array{client_email?:string,private_key?:string,project_id?:string,token_uri?:string,error?:string}
*/
function fcm_parse_service_account(string $json): array
{
$d = json_decode($json, true);
if (!is_array($d) || ($d['type'] ?? '') !== 'service_account') return ['error' => 'Das ist keine Dienstkonto-Datei (Typ "service_account" fehlt). Bitte in der Firebase-Konsole unter Projekteinstellungen → Dienstkonten einen neuen privaten Schlüssel erzeugen.'];
foreach (['client_email', 'private_key', 'project_id'] as $k) {
if (empty($d[$k]) || !is_string($d[$k])) return ['error' => 'In der Datei fehlt das Feld "' . $k . '".'];
}
if (!preg_match('/^[a-z0-9][a-z0-9\-]{4,60}$/i', $d['project_id'])) return ['error' => 'Die Projekt-ID in der Datei ist ungültig.'];
if (!filter_var($d['client_email'], FILTER_VALIDATE_EMAIL)) return ['error' => 'Die Dienstkonto-Adresse in der Datei ist ungültig.'];
if (!@openssl_pkey_get_private($d['private_key'])) return ['error' => 'Der private Schlüssel in der Datei ist nicht lesbar.'];
$tokenUri = (string) ($d['token_uri'] ?? 'https://oauth2.googleapis.com/token');
return ['client_email' => $d['client_email'], 'private_key' => $d['private_key'], 'project_id' => $d['project_id'], 'token_uri' => $tokenUri];
}
/** Adresse des Push-Dienstes des Herausgebers (push.php neben dem Katalog) oder '' wenn keiner eingetragen ist. Im Testbetrieb nur aus config 'fcm' => ['relay_url']. */
function fcm_relay_url(): string
{
if (getenv('DESKHUB_TESTING') === '1') return (string) (config()['fcm']['relay_url'] ?? '');
$p = parse_url(catalog_defaults()['plugins_url']);
if (!is_array($p) || ($p['scheme'] ?? '') !== 'https' || empty($p['host'])) return '';
$dir = rtrim(str_replace('\\', '/', dirname((string) ($p['path'] ?? '/'))), '/');
return 'https://' . $p['host'] . (isset($p['port']) ? ':' . $p['port'] : '') . $dir . '/push.php';
}
/** Welcher Weg gilt: 'own' (eigener Firebase-Schlüssel), 'relay' (Push-Dienst des Herausgebers) oder 'none'. */
function fcm_mode(): string
{
if (fcm_service_account() !== null) return 'own';
return fcm_relay_url() !== '' ? 'relay' : 'none';
}
function fcm_configured(): bool
{
return fcm_mode() !== 'none' && get_setting('fcm_enabled', '1') === '1';
}
/** Installations-ID und Geheimnis beim Push-Dienst (wird beim ersten Senden automatisch angelegt). @return array{url:string,id:string,secret:string}|null */
function fcm_relay_credentials(): ?array
{
$url = fcm_relay_url();
if ($url === '') return null;
$c = json_decode(decrypt_secret((string) get_setting('fcm_relay_install', '')), true);
if (is_array($c) && ($c['url'] ?? '') === $url && !empty($c['id']) && !empty($c['secret'])) return $c;
$headers = ['Content-Type: application/json'];
$key = catalog_defaults()['access_key'];
if ($key !== '') $headers[] = 'X-DeskHub-Key: ' . $key;
$host = (string) (parse_url(base_url(), PHP_URL_HOST) ?: 'deskhub');
$r = fcm_http('POST', $url, $headers, json_encode(['action' => 'register', 'name' => $host]));
$d = json_decode($r['body'], true);
if ($r['code'] !== 200 || empty($d['installId']) || empty($d['secret'])) {
$why = 'HTTP ' . $r['code'] . (is_array($d) && !empty($d['error']) ? ': ' . $d['error'] : '') . ($r['error'] !== '' ? ', ' . $r['error'] : '');
$GLOBALS['fcm_relay_reason'] = $why . ' bei ' . $url;
set_setting('fcm_last_error', date('Y-m-d H:i:s') . ' Anmeldung beim Push-Dienst fehlgeschlagen (' . $why . ')');
return null;
}
$c = ['url' => $url, 'id' => (string) $d['installId'], 'secret' => (string) $d['secret']];
set_setting('fcm_relay_install', encrypt_secret(json_encode($c)));
return $c;
}
/**
* Schickt eine Nachricht über den Push-Dienst an bis zu 50 Geräte. Im Standard geht nur Typ und Ticketnummer mit; Betreff/Kundenname
* nur, wenn "Details" im Adminportal eingeschaltet ist.
*
* @param string[] $tokens
* @param array<string,string> $data
* @return array{ok:bool,sent:int,failed:int,gone:string[],error:string}
*/
function fcm_relay_send(array $tokens, array $data): array
{
$fail = fn(string $e) => ['ok' => false, 'sent' => 0, 'failed' => count($tokens), 'gone' => [], 'error' => $e];
$cred = fcm_relay_credentials();
if (!$cred) return $fail('Anmeldung beim Push-Dienst nicht möglich (' . ($GLOBALS['fcm_relay_reason'] ?? 'unbekannt') . ')');
$details = get_setting('fcm_show_details', '0') === '1';
for ($try = 0; $try < 2; $try++) {
$body = ['action' => 'send', 'installId' => $cred['id'], 'secret' => $cred['secret'], 'tokens' => array_values($tokens),
'type' => (string) ($data['type'] ?? ''), 'ticket' => (string) ($data['ticket'] ?? ''), 'details' => $details];
if ($details) $body['body'] = (string) ($data['body'] ?? '');
if (!empty($data['brand'])) $body['brand'] = (string) $data['brand'];
$r = fcm_http('POST', $cred['url'], ['Content-Type: application/json'], json_encode($body, JSON_UNESCAPED_UNICODE));
$j = json_decode($r['body'], true);
// Installation dem Push-Dienst unbekannt (z. B. nach einem Neuaufbau): einmal neu anmelden
if ($r['code'] === 401 && is_array($j) && !empty($j['reregister']) && $try === 0) {
set_setting('fcm_relay_install', '');
$cred = fcm_relay_credentials();
if (!$cred) return $fail('Anmeldung beim Push-Dienst nicht möglich');
continue;
}
break;
}
if ($r['code'] === 200 && is_array($j) && isset($j['sent'])) {
return ['ok' => true, 'sent' => (int) $j['sent'], 'failed' => (int) ($j['failed'] ?? 0), 'gone' => array_map('strval', (array) ($j['gone'] ?? [])), 'error' => ''];
}
return $fail(is_array($j) && !empty($j['error']) ? (string) $j['error'] : 'HTTP ' . $r['code'] . ($r['error'] !== '' ? ' ' . $r['error'] : ''));
}
/**
* Stellt die Nachricht an die Geräte zu (je nach Weg direkt über Firebase oder über den Push-Dienst), entfernt abgemeldete Geräte und
* merkt sich Erfolg/Fehler für die Adminkarte. @param array<int,array{id:mixed,token:mixed}> $devices @param array<string,string> $data
* @return array{sent:int,failed:int,error:string}
*/
function fcm_deliver(PDO $pdo, array $devices, array $data): array
{
$brand = fcm_brand();
if ($brand !== '') $data['brand'] = $brand;
$sent = 0;
$failed = 0;
$error = '';
$idByToken = [];
foreach ($devices as $d) $idByToken[(string) $d['token']] = $d['id'];
$drop = function (string $token) use ($pdo, $idByToken): void {
if (isset($idByToken[$token])) $pdo->prepare('DELETE FROM push_devices WHERE id = ?')->execute([$idByToken[$token]]);
};
if (fcm_mode() === 'relay') {
foreach (array_chunk(array_map('strval', array_column($devices, 'token')), 50) as $chunk) {
$r = fcm_relay_send($chunk, $data);
$sent += $r['sent'];
$failed += $r['failed'];
if (!$r['ok']) $error = $r['error'];
foreach ($r['gone'] as $t) $drop($t);
}
} elseif (($sa = fcm_service_account()) !== null) {
foreach ($devices as $dev) {
$r = fcm_send_to_token($sa, (string) $dev['token'], $data);
if ($r['ok']) { $sent++; continue; }
$failed++;
if ($r['gone']) $drop((string) $dev['token']);
else $error = $r['error'];
}
}
if ($error !== '') set_setting('fcm_last_error', date('Y-m-d H:i:s') . ' Senden fehlgeschlagen: ' . $error);
elseif ($sent > 0 && $failed === 0) set_setting('fcm_last_error', '');
if ($sent > 0) set_setting('fcm_last_ok', date('Y-m-d H:i:s'));
return ['sent' => $sent, 'failed' => $failed, 'error' => $error];
}
/** Testnachricht an alle Geräte eines Mitarbeiters (sofort, ohne auf das Seitenende zu warten). @return array{sent:int,failed:int,error:string,devices:int} */
function fcm_send_test(PDO $pdo, int $staffId): array
{
$stmt = $pdo->prepare('SELECT id, token FROM push_devices WHERE staff_id = ?');
$stmt->execute([$staffId]);
$devices = $stmt->fetchAll();
if (!$devices) return ['sent' => 0, 'failed' => 0, 'error' => '', 'devices' => 0];
$r = fcm_deliver($pdo, $devices, ['type' => 'test', 'ticket' => '', 'title' => 'Testnachricht', 'body' => 'Die Push-Benachrichtigungen funktionieren.']);
return $r + ['devices' => count($devices)];
}
/** Adresse der FCM-Schnittstelle (nur im Testbetrieb überschreibbar). */
function fcm_api_base(): string
{
if (getenv('DESKHUB_TESTING') === '1' && ($b = getenv('DESKHUB_FCM_API_BASE'))) return rtrim((string) $b, '/');
return 'https://fcm.googleapis.com';
}
/** Ob die Adresse für den Token-Abruf erlaubt ist (in Produktion nur Googles Server, sonst würden Schlüssel an Fremde gehen). */
function fcm_token_uri_allowed(string $uri): bool
{
if (getenv('DESKHUB_TESTING') === '1') return true;
return str_starts_with($uri, 'https://oauth2.googleapis.com/');
}
function fcm_b64url(string $s): string
{
return rtrim(strtr(base64_encode($s), '+/', '-_'), '=');
}
/** Kleine HTTP-Anfrage (JSON oder Formular) mit kurzen Zeitlimits. @return array{code:int,body:string,error:string} */
function fcm_http(string $method, string $url, array $headers, string $body): array
{
if (!function_exists('curl_init')) return ['code' => 0, 'body' => '', 'error' => 'PHP cURL fehlt'];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => $headers,
CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => 10, CURLOPT_FOLLOWLOCATION => false, CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
]);
$out = curl_exec($ch);
$res = ['code' => (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE), 'body' => is_string($out) ? $out : '', 'error' => $out === false ? curl_error($ch) : ''];
curl_close($ch);
return $res;
}
/** Zugriffstoken für die FCM-Schnittstelle (wird bis kurz vor Ablauf zwischengespeichert). */
function fcm_access_token(array $sa, bool $force = false): ?string
{
if (!$force) {
$cached = json_decode(decrypt_secret((string) get_setting('fcm_access_token', '')), true);
if (is_array($cached) && ($cached['exp'] ?? 0) > time() + 120 && ($cached['project'] ?? '') === $sa['project_id']) return (string) $cached['token'];
}
if (!fcm_token_uri_allowed($sa['token_uri'])) return null;
$now = time();
$head = fcm_b64url(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
$claims = fcm_b64url(json_encode(['iss' => $sa['client_email'], 'scope' => FCM_SCOPE, 'aud' => $sa['token_uri'], 'iat' => $now, 'exp' => $now + 3600]));
$key = openssl_pkey_get_private($sa['private_key']);
if (!$key || !openssl_sign($head . '.' . $claims, $sig, $key, OPENSSL_ALGO_SHA256)) return null;
$r = fcm_http('POST', $sa['token_uri'], ['Content-Type: application/x-www-form-urlencoded'],
http_build_query(['grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', 'assertion' => $head . '.' . $claims . '.' . fcm_b64url($sig)]));
$d = json_decode($r['body'], true);
if ($r['code'] !== 200 || empty($d['access_token'])) {
set_setting('fcm_last_error', date('Y-m-d H:i:s') . ' Token-Abruf fehlgeschlagen (HTTP ' . $r['code'] . ($r['error'] !== '' ? ', ' . $r['error'] : '') . ')');
return null;
}
set_setting('fcm_access_token', encrypt_secret(json_encode(['token' => $d['access_token'], 'exp' => $now + (int) ($d['expires_in'] ?? 3000), 'project' => $sa['project_id']])));
return (string) $d['access_token'];
}
/**
* Schickt eine Nachricht an ein Gerät. Die Nachricht ist eine reine Daten-Nachricht mit hoher Priorität: Die App baut daraus selbst
* die Benachrichtigung (damit ein Tipp das richtige Ticket öffnet).
*
* @param array<string,string> $data
* @return array{ok:bool,gone:bool,status:int,error:string}
*/
function fcm_send_to_token(array $sa, string $token, array $data, int $ttlSeconds = 3600): array
{
$access = fcm_access_token($sa);
if ($access === null) return ['ok' => false, 'gone' => false, 'status' => 0, 'error' => 'Kein Zugriffstoken (Dienstkonto prüfen)'];
$payload = json_encode(['message' => [
'token' => $token,
'data' => array_map('strval', $data),
'android' => ['priority' => 'HIGH', 'ttl' => $ttlSeconds . 's'],
]], JSON_UNESCAPED_UNICODE);
$url = fcm_api_base() . '/v1/projects/' . rawurlencode($sa['project_id']) . '/messages:send';
for ($try = 0; $try < 2; $try++) {
$r = fcm_http('POST', $url, ['Authorization: Bearer ' . $access, 'Content-Type: application/json; charset=utf-8'], $payload);
if ($r['code'] === 401 && $try === 0) { $access = fcm_access_token($sa, true); if ($access === null) break; continue; }
break;
}
if ($r['code'] === 200) return ['ok' => true, 'gone' => false, 'status' => 200, 'error' => ''];
$err = json_decode($r['body'], true)['error'] ?? [];
$status = (string) ($err['status'] ?? '');
// Token ungültig/abgemeldet: das Gerät aus der Liste nehmen
$gone = $r['code'] === 404 || $status === 'NOT_FOUND' || $status === 'UNREGISTERED' || ($r['code'] === 400 && $status === 'INVALID_ARGUMENT');
return ['ok' => false, 'gone' => $gone, 'status' => $r['code'], 'error' => $status !== '' ? $status . ': ' . (string) ($err['message'] ?? '') : ('HTTP ' . $r['code'] . ($r['error'] !== '' ? ' ' . $r['error'] : ''))];
}
/** Führt $fn nach dem Senden der Antwort aus (im Web), auf der Kommandozeile sofort. Fehler stören die Seite nie. */
function fcm_after_response(callable $fn): void
{
$run = function () use ($fn) {
try {
$fn();
} catch (Throwable $e) {
error_log('fcm: ' . $e->getMessage());
}
};
if (PHP_SAPI === 'cli' || getenv('DESKHUB_FCM_SYNC') === '1') { $run(); return; }
register_shutdown_function(function () use ($run) {
if (function_exists('fastcgi_finish_request')) @fastcgi_finish_request();
@ignore_user_abort(true);
$run();
});
}
/**
* Schickt eine Nachricht an alle Geräte der genannten Mitarbeiter (nach dem Senden der Seitenantwort). Ungültige Geräte werden entfernt.
*
* @param int[] $staffIds
* @param array<string,string> $data
*/
function fcm_notify_staff(array $staffIds, array $data, ?int $departmentId = null, ?callable $extraFilter = null): void
{
$staffIds = array_values(array_unique(array_filter(array_map('intval', $staffIds))));
if (!$staffIds || !fcm_configured()) return;
fcm_after_response(function () use ($staffIds, $data, $departmentId, $extraFilter) {
$pdo = get_db();
// Rechte: Nur wer Zugriff auf die Abteilung des Tickets hat (und für diese Art von Push berechtigt ist), bekommt etwas
if ($departmentId !== null) {
$staffIds = fcm_staff_allowed($pdo, $staffIds, $departmentId, fcm_event_permission((string) ($data['type'] ?? '')), $extraFilter);
if (!$staffIds) return;
}
$in = implode(',', array_fill(0, count($staffIds), '?'));
$stmt = $pdo->prepare("SELECT d.id, d.token, d.staff_id FROM push_devices d JOIN staff s ON s.id = d.staff_id WHERE s.is_active = 1 AND d.staff_id IN ($in)");
$stmt->execute($staffIds);
// Einstellungen je Mitarbeiter: abgewählte Ereignisse und stille Zeiten überspringen
$devices = array_values(array_filter($stmt->fetchAll(), fn($d) => fcm_wants(fcm_prefs($pdo, (int) $d['staff_id']), (string) ($data['type'] ?? ''))));
if ($devices) fcm_deliver($pdo, $devices, $data);
});
}
/** Welche Berechtigung für eine Art von Push nötig ist (null = nur Zugriff auf die Abteilung). */
function fcm_event_permission(string $type): ?string
{
return in_array($type, ['ticket_new', 'ticket_reply', 'chat_new'], true) ? 'reply_ticket' : null;
}
/**
* Filtert Mitarbeiter auf die, die das Ticket der Abteilung überhaupt sehen dürfen (Admin oder Mitglied der Abteilung) und – wenn
* verlangt – die passende Berechtigung in dieser Abteilung haben. Optional ein zusätzlicher Filter (Mitarbeiter-Zeile => bool).
*
* @param int[] $staffIds
* @return int[]
*/
function fcm_staff_allowed(PDO $pdo, array $staffIds, int $departmentId, ?string $permission = null, ?callable $extra = null): array
{
$staffIds = array_values(array_unique(array_filter(array_map('intval', $staffIds))));
if (!$staffIds) return [];
$in = implode(',', array_fill(0, count($staffIds), '?'));
$stmt = $pdo->prepare("SELECT * FROM staff WHERE is_active = 1 AND id IN ($in)");
$stmt->execute($staffIds);
$ok = [];
foreach ($stmt->fetchAll() as $s) {
$depts = staff_department_ids($s);
if ($depts !== null && !in_array($departmentId, $depts, true)) continue;
if ($permission !== null && !has_permission($s, $permission, $departmentId)) continue;
if ($extra !== null && !$extra($s)) continue;
$ok[] = (int) $s['id'];
}
return $ok;
}
/** Mitarbeiter, die ein neues Ticket der Abteilung mitbekommen sollen (wie bei der E-Mail: Admins, Hauptabteilung, zusätzliche Abteilung mit Benachrichtigung). */
function fcm_recipients_for_department(PDO $pdo, ?int $deptId): array
{
if ($deptId) {
$stmt = $pdo->prepare("SELECT DISTINCT id FROM staff WHERE is_active = 1 AND (role = 'admin' OR department_id = ? OR id IN (SELECT staff_id FROM staff_department WHERE department_id = ? AND notify = 1))");
$stmt->execute([$deptId, $deptId]);
} else {
$stmt = $pdo->query("SELECT id FROM staff WHERE is_active = 1 AND role = 'admin'");
}
return array_map('intval', $stmt->fetchAll(PDO::FETCH_COLUMN));
}
/** Text der Benachrichtigung: standardmäßig nur mit Ticketnummer (privat), mit "Details" auch Betreff und Kunde. */
function fcm_message(string $type, array $ticket): array
{
$titles = ['ticket_new' => 'Neues Ticket', 'ticket_reply' => 'Neue Antwort', 'ticket_assigned' => 'Ticket zugewiesen', 'chat_new' => 'Neuer Live-Chat', 'ticket_sla' => 'SLA überschritten', 'ticket_mention' => 'Erwähnung'];
$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<string,string> Schlüssel => Beschriftung
*/
function fcm_event_types_for(array $staff): array
{
$types = fcm_event_types();
if (!has_permission($staff, 'reply_ticket')) unset($types['ticket_new'], $types['ticket_reply'], $types['chat_new']);
if (get_setting('live_chat_enabled', '0') !== '1') unset($types['chat_new']);
return $types;
}
/** Push-Einstellungen eines Mitarbeiters; ohne Eintrag gilt: alles an, keine stillen Zeiten. @return array{events:string[],quiet_from:string,quiet_to:string} */
function fcm_prefs(PDO $pdo, int $staffId): array
{
$all = array_keys(fcm_event_types());
$stmt = $pdo->prepare('SELECT events, quiet_from, quiet_to FROM push_prefs WHERE staff_id = ?');
$stmt->execute([$staffId]);
$row = $stmt->fetch();
if (!$row) return ['events' => $all, 'quiet_from' => '', 'quiet_to' => ''];
return ['events' => array_values(array_intersect($all, array_filter(explode(',', (string) $row['events'])))), 'quiet_from' => (string) ($row['quiet_from'] ?? ''), 'quiet_to' => (string) ($row['quiet_to'] ?? '')];
}
function fcm_time_valid(string $t): bool
{
return preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $t) === 1;
}
/** @param string[] $events */
function fcm_save_prefs(PDO $pdo, int $staffId, array $events, string $quietFrom, string $quietTo, ?array $shownTypes = null): void
{
// Arten, die der Mitarbeiter nicht zu sehen bekommt (kein Recht), bleiben "an": gesendet wird dafür ohnehin nur mit Recht
$hidden = $shownTypes === null ? [] : array_diff(array_keys(fcm_event_types()), $shownTypes);
$events = array_values(array_intersect(array_keys(fcm_event_types()), array_merge($shownTypes === null ? $events : array_intersect($events, $shownTypes), $hidden)));
if (!fcm_time_valid($quietFrom) || !fcm_time_valid($quietTo)) { $quietFrom = ''; $quietTo = ''; }
$pdo->prepare('DELETE FROM push_prefs WHERE staff_id = ?')->execute([$staffId]);
$pdo->prepare('INSERT INTO push_prefs (staff_id, events, quiet_from, quiet_to) VALUES (?,?,?,?)')
->execute([$staffId, implode(',', $events), $quietFrom !== '' ? $quietFrom : null, $quietTo !== '' ? $quietTo : null]);
}
/** Ob die Zeit in den stillen Zeitraum fällt (auch über Mitternacht, z. B. 22:00 bis 06:00). */
function fcm_in_quiet(array $prefs, ?string $now = null): bool
{
[$from, $to] = [$prefs['quiet_from'], $prefs['quiet_to']];
if (!fcm_time_valid($from) || !fcm_time_valid($to) || $from === $to) return false;
$now ??= date('H:i');
return $from < $to ? ($now >= $from && $now < $to) : ($now >= $from || $now < $to);
}
/** Will dieser Mitarbeiter diese Art von Push jetzt bekommen? Testnachrichten kommen immer an. */
function fcm_wants(array $prefs, string $type): bool
{
if ($type === 'test') return true;
return in_array($type, $prefs['events'], true) && !fcm_in_quiet($prefs);
}
/**
* Ticket ist neu überfällig (SLA): nur an den zugewiesenen Mitarbeiter, an Admins und an Mitarbeiter mit dem Recht
* "Eskalationsregeln verwalten" (jeweils mit Zugriff auf die Abteilung). Normale Abteilungsmitglieder bekommen es nicht.
*/
function fcm_notify_overdue(array $ticket): void
{
if (!fcm_configured() || empty($ticket['ticket_number'])) return;
$dept = (int) ($ticket['department_id'] ?? 0);
$assigned = (int) ($ticket['assigned_staff_id'] ?? 0);
$to = fcm_recipients_for_department(get_db(), $dept ?: null);
if ($assigned > 0) $to[] = $assigned;
fcm_notify_staff($to, fcm_data('ticket_sla', $ticket), $dept, fn(array $s) => (int) $s['id'] === $assigned || has_permission($s, 'manage_escalations', $dept));
}
/** Mitarbeiter wurde in einer internen Notiz erwähnt. */
function fcm_notify_mention(array $ticket, int $staffId, ?int $actorId = null): void
{
if (!fcm_configured() || $staffId <= 0 || $staffId === $actorId || empty($ticket['ticket_number'])) return;
fcm_notify_staff([$staffId], fcm_data('ticket_mention', $ticket), (int) ($ticket['department_id'] ?? 0));
}
// ── Geräte (Tokens der Android-App) ──────────────────────────────────────────
/** Meldet ein Gerät an (ein Token gehört immer nur einem Mitarbeiter, der zuletzt angemeldete gewinnt). */
function fcm_register_device(PDO $pdo, int $staffId, string $token, string $deviceName = '', string $appVersion = ''): void
{
$hash = hash('sha256', $token);
$now = db_now();
$deviceName = mb_substr($deviceName, 0, 120);
$appVersion = mb_substr($appVersion, 0, 30);
$stmt = $pdo->prepare('SELECT id FROM push_devices WHERE token_hash = ?');
$stmt->execute([$hash]);
$id = $stmt->fetchColumn();
if ($id) {
$pdo->prepare('UPDATE push_devices SET staff_id = ?, device_name = ?, app_version = ?, last_seen_at = ? WHERE id = ?')->execute([$staffId, $deviceName, $appVersion, $now, $id]);
} else {
$pdo->prepare('INSERT INTO push_devices (staff_id, token, token_hash, platform, device_name, app_version, created_at, last_seen_at) VALUES (?,?,?,?,?,?,?,?)')
->execute([$staffId, $token, $hash, 'android', $deviceName, $appVersion, $now, $now]);
}
// höchstens FCM_MAX_DEVICES_PER_STAFF Geräte je Mitarbeiter: die ältesten fallen heraus
$ids = $pdo->prepare('SELECT id FROM push_devices WHERE staff_id = ? ORDER BY last_seen_at DESC, id DESC');
$ids->execute([$staffId]);
foreach (array_slice($ids->fetchAll(PDO::FETCH_COLUMN), FCM_MAX_DEVICES_PER_STAFF) as $old) {
$pdo->prepare('DELETE FROM push_devices WHERE id = ?')->execute([$old]);
}
}
function fcm_unregister_device(PDO $pdo, int $staffId, string $token): void
{
$pdo->prepare('DELETE FROM push_devices WHERE staff_id = ? AND token_hash = ?')->execute([$staffId, hash('sha256', $token)]);
}
/** Ein FCM-Token hat nur diese Zeichen; alles andere wird abgelehnt. */
function fcm_token_valid(string $token): bool
{
return strlen($token) >= 20 && strlen($token) <= 4096 && preg_match('/^[A-Za-z0-9_:\-\.]+$/', $token) === 1;
}
+2
View File
@@ -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]);
@@ -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"
@@ -0,0 +1,108 @@
<?php
/**
* includes/staff_activity.php
*
* Aktivität der Mitarbeiter und automatischer Offline-Status: Wer länger als die eingestellte Zeit (Standard 30 Minuten) nichts
* gemacht hat, wird auf "Offline" gesetzt und bekommt seinen vorherigen Status zurück, sobald er wieder aktiv ist. Wer den Status
* von Hand ändert (oder ein Admin ihn ändert), behält ihn, bis er ihn selbst wieder ändert.
*
* Als "aktiv" zählt echte Nutzung: Seitenaufrufe im Browser, die Klick-/Tipp-Pings der Weboberfläche (staff/session_ping.php),
* Aktionen in der App (Schreib-Aufrufe der API) und die Berührungs-Pings der App (api/staff/v1/presence.php). Reines Abfragen im
* Hintergrund (Polling, Push-Registrierung) hält niemanden online.
*/
const STAFF_AVAILABILITY_STATUSES = ['available', 'busy', 'break', 'offline'];
/** Minuten ohne Aktivität bis "Offline" (0 = Funktion aus). */
function staff_auto_offline_minutes(): int
{
$m = (int) get_setting('staff_auto_offline_minutes', '30');
return max(0, min(1440, $m));
}
/**
* Merkt sich, dass der Mitarbeiter gerade aktiv ist (höchstens einmal pro Minute in die Datenbank geschrieben) und holt ihn aus
* dem automatischen Offline zurück. Fehler (z. B. Migration noch nicht gelaufen) stören nie.
*/
function staff_touch_activity(PDO $pdo, int $staffId, bool $force = false): void
{
static $last = [];
$now = time();
if (!$force && isset($last[$staffId]) && $now - $last[$staffId] < 60) return;
$last[$staffId] = $now;
try {
$cut = date('Y-m-d H:i:s', $now - 60);
$pdo->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';
}
+66 -11
View File
@@ -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';
<a href="?edit=<?= $ag['id'] ?>" title="Schichtplan bearbeiten"
style="color:var(--muted);font-size:.78rem;text-decoration:none;flex-shrink:0">⚙️</a>
<?php endif; ?>
<?php $__la = staff_last_active_label($ag['last_active_at'] ?? null); ?>
<?php if ($__la !== '' || !empty($ag['avail_auto'])): ?><div style="font-size:.68rem;color:var(--muted);flex-shrink:0;text-align:right"><?= !empty($ag['avail_auto']) ? 'automatisch<br>offline' : '' ?><?= $__la !== '' && empty($ag['avail_auto']) ? 'aktiv ' . e($__la) : '' ?></div><?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<?php if ($admin): ?>
<!-- ── Admin: Status der Mitarbeiter ändern + Auto-Offline ───────────────────── -->
<div class="card" style="margin-bottom:20px">
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:6px">Status der Mitarbeiter ändern (Admin)</div>
<p class="muted" style="font-size:.8rem;margin-bottom:12px">Ein von dir gesetzter Status bleibt, bis der Mitarbeiter ihn selbst ändert oder du ihn erneut änderst.</p>
<table style="width:100%;border-collapse:collapse;font-size:.85rem">
<?php foreach ($agents as $ag): ?>
<tr style="border-top:1px solid var(--border)">
<td style="padding:6px 8px"><?= e($ag['name']) ?></td>
<td style="padding:6px 8px">
<form method="post" style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">
<?= csrf_field() ?>
<input type="hidden" name="action" value="set_staff_status">
<input type="hidden" name="staff_id" value="<?= (int) $ag['id'] ?>">
<select name="status" style="width:auto">
<?php foreach ($statusLabels as $sk => [$sl]): ?><option value="<?= $sk ?>" <?= ($ag['avail_status'] ?? 'offline') === $sk ? 'selected' : '' ?>><?= $sl ?></option><?php endforeach; ?>
</select>
<input type="text" name="note" value="<?= e($ag['avail_note'] ?? '') ?>" placeholder="Notiz (optional)" maxlength="120" style="width:180px">
<button type="submit" class="btn secondary">Setzen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</table>
<form method="post" style="margin-top:14px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<?= csrf_field() ?>
<input type="hidden" name="action" value="save_auto_offline">
<label style="margin:0;font-weight:500">Automatisch offline nach</label>
<input type="number" name="auto_offline_minutes" min="0" max="1440" value="<?= (int) staff_auto_offline_minutes() ?>" style="width:90px"> Minuten ohne Aktivität
<button type="submit" class="btn secondary">Speichern</button>
<span class="muted" style="font-size:.78rem">0 = aus. Bei neuer Aktivität kommt der vorherige Status zurück.</span>
</form>
</div>
<?php endif; ?>
<!-- ── Schichtplan-Editor (Admin) ─────────────────────────────────────────────── -->
<?php if ($admin && $editStaffId): ?>
<?php
@@ -12,6 +12,7 @@ header('Content-Type: application/json');
header('Cache-Control: no-cache');
try {
staff_run_auto_offline($pdo);
$rows = $pdo->query(
"SELECT s.id, s.name, s.is_on_vacation,
COALESCE(sa.status, 'offline') AS status,