@@ -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 ;
}