Upload via GUI (360 Dateien)

This commit is contained in:
2026-10-30 21:49:58 +00:00
parent aed0db39ba
commit 91ae4659b5
10 changed files with 711 additions and 9 deletions
+7
View File
@@ -52,7 +52,9 @@ function staff_login(array $staff): void
session_regenerate_id(true);
$_SESSION['staff_id'] = $staff['id'];
$_SESSION['staff_last_activity'] = time();
$_SESSION['staff_login_at'] = time();
staff_touch_activity(get_db(), (int) $staff['id']);
login_guard_success($staff, 'web');
}
function staff_logout(): void
@@ -77,6 +79,11 @@ function current_staff(): ?array
$stmt = get_db()->prepare('SELECT * FROM staff WHERE id = ? AND is_active = 1');
$stmt->execute([$_SESSION['staff_id']]);
$staff = $stmt->fetch();
// „Das war ich nicht“ (Anmelde-Wächter): ältere Browser-Sitzungen dieses Kontos sind ungültig
if ($staff && login_guard_session_revoked((int) $staff['id'])) {
unset($_SESSION['staff_id']);
return null;
}
return $cache = ($staff ?: null);
}
+2
View File
@@ -13,6 +13,8 @@ require_once __DIR__ . '/pseudo_cron.php';
require_once __DIR__ . '/auth.php';
require_once __DIR__ . '/smtp_mailer.php';
require_once __DIR__ . '/mail_queue.php';
require_once __DIR__ . '/login_guard.php';
require_once __DIR__ . '/vacation.php';
require_once __DIR__ . '/mailer.php';
require_once __DIR__ . '/email_addresses.php';
require_once __DIR__ . '/health.php';
+4 -1
View File
@@ -311,13 +311,16 @@ function fcm_notify_staff(array $staffIds, array $data, ?int $departmentId = nul
if (!$staffIds || !fcm_configured()) return;
fcm_after_response(function () use ($staffIds, $data, $departmentId, $extraFilter) {
$pdo = get_db();
// Mitarbeiter im Urlaub: stattdessen bekommt ihre Vertretung die Meldung
$staffIds = vacation_expand_ids($pdo, $staffIds);
if (!$staffIds) return;
// Rechte: Nur wer Zugriff auf die Abteilung des Tickets hat (und für diese Art von Push berechtigt ist), bekommt etwas
if ($departmentId !== null) {
$staffIds = fcm_staff_allowed($pdo, $staffIds, $departmentId, fcm_event_permission((string) ($data['type'] ?? '')), $extraFilter);
if (!$staffIds) return;
}
$in = implode(',', array_fill(0, count($staffIds), '?'));
$stmt = $pdo->prepare("SELECT d.id, d.token, d.staff_id FROM push_devices d JOIN staff s ON s.id = d.staff_id WHERE s.is_active = 1 AND d.staff_id IN ($in)");
$stmt = $pdo->prepare("SELECT d.id, d.token, d.staff_id FROM push_devices d JOIN staff s ON s.id = d.staff_id WHERE s.is_active = 1 AND s.is_on_vacation = 0 AND d.staff_id IN ($in)");
$stmt->execute($staffIds);
// Einstellungen je Mitarbeiter: abgewählte Ereignisse und stille Zeiten überspringen
$devices = array_values(array_filter($stmt->fetchAll(), fn($d) => fcm_wants(fcm_prefs($pdo, (int) $d['staff_id']), (string) ($data['type'] ?? ''))));
+62 -1
View File
@@ -608,6 +608,18 @@ function staff_can_access_department(array $staff, int $departmentId): bool
return $ids === null || in_array($departmentId, $ids, true);
}
/** Ist der Mitarbeiter gerade abwesend (Urlaubsmodus)? Im Urlaub gibt es keine Benachrichtigungen (Mail, Push, Android). */
function staff_is_on_vacation(int $staffId): bool
{
try {
$q = get_db()->prepare('SELECT is_on_vacation FROM staff WHERE id = ?');
$q->execute([$staffId]);
return (int) $q->fetchColumn() === 1;
} catch (Throwable $e) {
return false;
}
}
function staff_department_ids(array $staff): ?array
{
if ($staff['role'] === 'admin') {
@@ -622,9 +634,56 @@ function staff_department_ids(array $staff): ?array
foreach ($stmt->fetchAll() as $row) {
$ids[] = (int) $row['department_id'];
}
foreach (staff_substitution_department_ids($staff) as $d) $ids[] = $d;
return array_values(array_unique($ids));
}
/**
* Abteilungen, auf die ein Mitarbeiter nur als Vertretung Zugriff hat: Ist ein Kollege im Urlaub (Profil → Abwesenheit oder Plugin
* „Urlaub und Vertretung“) und hat diesen Mitarbeiter als Vertretung eingetragen, gelten dessen Abteilungen (Haupt- und zusätzliche) mit,
* solange die Abwesenheit dauert. Ketten (A vertritt B, B ist selbst im Urlaub und von C vertreten) folgen bis zu drei Stufen.
* Ist der Abwesende Administrator (sieht alle Abteilungen), kommen nur die Abteilungen der Tickets dazu, die der Vertretung gerade
* zugewiesen sind. Ohne die Spalten (Datenbank noch nicht aktualisiert) ergibt sich eine leere Liste.
* @return list<int>
*/
function staff_substitution_department_ids(array $staff): array
{
if (($staff['role'] ?? '') === 'admin' || empty($staff['id'])) return [];
$pdo = get_db();
$out = [];
try {
$frontier = [(int) $staff['id']];
$seen = $frontier;
for ($level = 0; $level < 3 && $frontier; $level++) {
$in = implode(',', array_fill(0, count($frontier), '?'));
$q = $pdo->prepare("SELECT id, role, department_id FROM staff WHERE is_active = 1 AND is_on_vacation = 1 AND vacation_substitute_id IN ($in)");
$q->execute($frontier);
$next = [];
$adminAbsent = false;
foreach ($q->fetchAll() as $abs) {
$aid = (int) $abs['id'];
if (in_array($aid, $seen, true)) continue;
$seen[] = $aid;
$next[] = $aid;
if ($abs['role'] === 'admin') { $adminAbsent = true; continue; }
if (!empty($abs['department_id'])) $out[] = (int) $abs['department_id'];
$d = $pdo->prepare('SELECT department_id FROM staff_department WHERE staff_id = ?');
$d->execute([$aid]);
foreach ($d->fetchAll(PDO::FETCH_COLUMN) as $did) $out[] = (int) $did;
}
if ($adminAbsent) {
$t = $pdo->prepare("SELECT DISTINCT department_id FROM tickets WHERE assigned_staff_id = ? AND status NOT IN ('resolved','closed')");
$t->execute([(int) $staff['id']]);
foreach ($t->fetchAll(PDO::FETCH_COLUMN) as $did) $out[] = (int) $did;
}
$frontier = $next;
}
} catch (Throwable $e) {
return [];
}
return array_values(array_unique(array_filter($out)));
}
/**
* Beim Öffnen eines noch niemandem zugewiesenen Tickets wird es dem öffnenden Mitarbeiter zugewiesen (Einstellung "Beim Öffnen
* automatisch zuweisen", Standard an). Nicht bei geschlossenen, gelösten, zusammengeführten oder als Spam markierten Tickets und nicht,
@@ -2162,8 +2221,10 @@ function save_mentions(PDO $pdo, int $ticketId, int $threadId, int $byStaffId, s
->execute([$ticketId, $threadId, $mentionedId, $byStaffId, db_now()]);
fcm_notify_mention($ticket, $mentionedId, $byStaffId); // Push auf die Android-App
// E-Mail an erwähnten Mitarbeiter
// Im Urlaub geht die Mail an die Vertretung
$mailTo = vacation_expand_ids($pdo, [$mentionedId]);
$stmt = $pdo->prepare('SELECT email, name FROM staff WHERE id=?');
$stmt->execute([$mentionedId]);
$stmt->execute([$mailTo ? $mailTo[0] : 0]);
$mentioned = $stmt->fetch();
if ($mentioned) {
$by = $pdo->prepare('SELECT name FROM staff WHERE id=?');
+282
View File
@@ -0,0 +1,282 @@
<?php
/**
* Anmelde-Wächter: merkt sich die Geräte und Orte, von denen sich Mitarbeiter anmelden, und warnt per Mail, wenn ein neues Gerät
* oder ein neuer Ort auftaucht. Fehlversuche und Sperren stehen im Protokoll (staff/login_log.php). Wer eine Warnung bekommt und die
* Anmeldung nicht selbst war, sichert das Konto über den Link „Das war ich nicht“ in der Mail (staff/login_alert.php).
*
* Ein Gerät ist ein Browser (Kennung in einem Cookie) bzw. eine App (Gerätename). Ein Ort ist das Land (nur wenn Cloudflare es
* mitschickt) oder sonst das Netz der IP-Adresse; echte Standortdaten gibt es ohne Fremddienst nicht.
* Fehler im Wächter dürfen eine Anmeldung nie verhindern: alles ist in try/catch gefasst.
*/
const LOGIN_GUARD_COOKIE = 'dh_device';
function login_guard_enabled(): bool
{
return get_setting('login_guard', '1') === '1';
}
function login_guard_max_attempts(): int
{
return max(3, min(20, (int) get_setting('login_guard_max_attempts', '5')));
}
function login_guard_lock_minutes(): int
{
return max(5, min(240, (int) get_setting('login_guard_lock_minutes', '15')));
}
function login_guard_retention_days(): int
{
return max(7, min(1095, (int) get_setting('login_guard_retention_days', '180')));
}
/** Anzeigename des Geräts aus dem User-Agent, z. B. „Chrome auf Windows“. */
function login_guard_describe_device(string $ua): string
{
$ua = trim($ua);
if ($ua === '') return 'Unbekanntes Gerät';
if (stripos($ua, 'okhttp') !== false || stripos($ua, 'DeskHub') !== false) return 'DeskHub-App';
$browser = 'Browser';
foreach (['Edg' => 'Edge', 'OPR' => 'Opera', 'Firefox' => 'Firefox', 'Chrome' => 'Chrome', 'Safari' => 'Safari'] as $needle => $name) {
if (stripos($ua, $needle) !== false) { $browser = $name; break; }
}
$os = 'unbekanntem System';
foreach (['Windows' => 'Windows', 'Android' => 'Android', 'iPhone' => 'iOS', 'iPad' => 'iOS', 'Macintosh' => 'macOS', 'Mac OS' => 'macOS', 'Linux' => 'Linux'] as $needle => $name) {
if (stripos($ua, $needle) !== false) { $os = $name; break; }
}
return $browser . ' auf ' . $os;
}
/** Ort der Anmeldung: [Schlüssel, Anzeigetext]. Land nur, wenn Cloudflare es über einen vertrauten Proxy mitschickt. */
function login_guard_place(string $ip): array
{
if (request_from_trusted_proxy()) {
$country = strtoupper(trim((string) ($_SERVER['HTTP_CF_IPCOUNTRY'] ?? '')));
if (preg_match('/^[A-Z]{2}$/', $country) && !in_array($country, ['XX', 'T1'], true)) return ['C:' . $country, 'Land ' . $country];
}
if (!filter_var($ip, FILTER_VALIDATE_IP)) return ['unknown', 'Unbekannt'];
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) return ['local', 'Lokales Netz'];
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$p = explode('.', $ip);
return ['N:' . $p[0] . '.' . $p[1], 'Netz ' . $p[0] . '.' . $p[1] . '.x.x'];
}
$bin = inet_pton($ip);
$h = $bin === false ? ['0', '0'] : array_map(fn($w) => dechex($w), array_values(unpack('n2', substr($bin, 0, 4))));
return ['N6:' . $h[0] . ':' . $h[1], 'Netz ' . $h[0] . ':' . $h[1] . '::/32'];
}
/** Schreibt einen Protokolleintrag; gibt die Eintrags-Nummer zurück. */
function login_guard_record(PDO $pdo, string $event, ?int $staffId, string $email, string $channel, string $detail = '', ?string $device = null): int
{
$ip = client_ip();
[, $placeLabel] = login_guard_place($ip);
$ua = (string) ($_SERVER['HTTP_USER_AGENT'] ?? '');
$pdo->prepare('INSERT INTO login_events (staff_id, email, event, channel, ip, place, device, detail, created_at) VALUES (?,?,?,?,?,?,?,?,?)')
->execute([$staffId, mb_substr($email, 0, 255), $event, $channel, mb_substr($ip, 0, 64), mb_substr($placeLabel, 0, 100),
mb_substr($device ?? login_guard_describe_device($ua), 0, 160), mb_substr($detail, 0, 255), db_now()]);
if (random_int(1, 40) === 1) login_guard_purge($pdo);
return (int) $pdo->lastInsertId();
}
function login_guard_purge(PDO $pdo): void
{
$cut = date('Y-m-d H:i:s', time() - login_guard_retention_days() * 86400);
$pdo->prepare('DELETE FROM login_events WHERE created_at < ?')->execute([$cut]);
}
/** Kennung dieses Browsers (Cookie); legt sie an, wenn sie fehlt. */
function login_guard_device_id(string $channel, ?string $deviceName): string
{
if ($channel === 'app') return 'app:' . mb_strtolower(trim((string) $deviceName)) . '|' . login_guard_describe_device((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''));
$id = (string) ($_COOKIE[LOGIN_GUARD_COOKIE] ?? '');
if (!preg_match('/^[a-f0-9]{32}$/', $id)) $id = bin2hex(random_bytes(16));
if (!headers_sent() && PHP_SAPI !== 'cli') {
setcookie(LOGIN_GUARD_COOKIE, $id, ['expires' => time() + 400 * 86400, 'path' => '/', 'secure' => !empty($_SERVER['HTTPS']), 'httponly' => true, 'samesite' => 'Lax']);
}
$_COOKIE[LOGIN_GUARD_COOKIE] = $id;
return $id;
}
/**
* Nach erfolgreicher Anmeldung aufrufen (Web: staff_login(); App: api/staff/v1/login.php).
* Merkt Gerät und Ort, schreibt das Protokoll und warnt bei Neuem. Gibt das Ergebnis zurück: first | known | new_device | new_place | off.
*/
function login_guard_success(array $staff, string $channel = 'web', ?string $deviceName = null): string
{
if (!login_guard_enabled()) return 'off';
try {
$pdo = get_db();
$sid = (int) $staff['id'];
$now = db_now();
$ip = client_ip();
[$placeKey, $placeLabel] = login_guard_place($ip);
$label = $channel === 'app' && trim((string) $deviceName) !== '' ? mb_substr(trim((string) $deviceName), 0, 120) : login_guard_describe_device((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''));
$hash = hash('sha256', $sid . '|' . login_guard_device_id($channel, $deviceName));
$st = $pdo->prepare('SELECT id FROM login_devices WHERE staff_id = ? AND device_hash = ?');
$st->execute([$sid, $hash]);
$devId = $st->fetchColumn();
$st = $pdo->prepare('SELECT id FROM login_places WHERE staff_id = ? AND place_key = ?');
$st->execute([$sid, $placeKey]);
$plId = $st->fetchColumn();
$st = $pdo->prepare('SELECT COUNT(*) FROM login_devices WHERE staff_id = ?');
$st->execute([$sid]);
$hadAny = (int) $st->fetchColumn() > 0;
if ($devId) {
$pdo->prepare('UPDATE login_devices SET last_seen = ?, last_ip = ?, place = ?, label = ? WHERE id = ?')->execute([$now, $ip, $placeLabel, $label, $devId]);
} else {
$pdo->prepare('INSERT INTO login_devices (staff_id, device_hash, label, channel, first_ip, last_ip, place, first_seen, last_seen) VALUES (?,?,?,?,?,?,?,?,?)')
->execute([$sid, $hash, $label, $channel, $ip, $ip, $placeLabel, $now, $now]);
}
if ($plId) {
$pdo->prepare('UPDATE login_places SET last_seen = ? WHERE id = ?')->execute([$now, $plId]);
} else {
$pdo->prepare('INSERT INTO login_places (staff_id, place_key, label, first_seen, last_seen) VALUES (?,?,?,?,?)')->execute([$sid, $placeKey, $placeLabel, $now, $now]);
}
$email = (string) $staff['email'];
if (!$hadAny) {
login_guard_record($pdo, 'success', $sid, $email, $channel, 'Erste Anmeldung, Gerät und Ort gemerkt', $label);
return 'first';
}
if (!$devId) {
$eid = login_guard_record($pdo, 'new_device', $sid, $email, $channel, 'Neues Gerät' . (!$plId ? ' und neuer Ort' : ''), $label);
login_guard_alert($pdo, $staff, $eid, 'Neues Gerät', $label, $placeLabel, $ip, $channel);
return 'new_device';
}
if (!$plId && get_setting('login_guard_warn_place', '1') === '1') {
$eid = login_guard_record($pdo, 'new_place', $sid, $email, $channel, 'Neuer Ort', $label);
login_guard_alert($pdo, $staff, $eid, 'Neuer Ort', $label, $placeLabel, $ip, $channel);
return 'new_place';
}
login_guard_record($pdo, 'success', $sid, $email, $channel, '', $label);
return 'known';
} catch (Throwable $e) {
error_log('login_guard_success: ' . $e->getMessage());
return 'off';
}
}
/** Warn-Mail an den Mitarbeiter mit einmaligem Link „Das war ich nicht“. */
function login_guard_alert(PDO $pdo, array $staff, int $eventId, string $reason, string $device, string $place, string $ip, string $channel): void
{
$token = bin2hex(random_bytes(24));
$pdo->prepare('UPDATE login_events SET token_hash = ? WHERE id = ?')->execute([hash('sha256', $token), $eventId]);
$link = base_url() . '/staff/login_alert.php?t=' . $token;
if (getenv('DESKHUB_TESTING') === '1') $GLOBALS['login_guard_last_link'] = $link; // nur für Tests
$name = e((string) ($staff['name'] ?? ''));
$inner = '<h2 style="margin:0 0 12px;font-size:20px;color:#1a202c">Neue Anmeldung bei Ihrem Konto</h2>'
. '<p style="margin:0 0 12px;color:#334155">Hallo ' . $name . ', Ihr Mitarbeiter-Konto wurde gerade von einem bisher unbekannten ' . ($reason === 'Neuer Ort' ? 'Ort' : 'Gerät') . ' aus genutzt.</p>'
. '<table style="margin:0 0 16px;font-size:14px;color:#334155"><tr><td style="padding:2px 14px 2px 0;color:#64748b">Zeit</td><td>' . e(date('d.m.Y H:i')) . '</td></tr>'
. '<tr><td style="padding:2px 14px 2px 0;color:#64748b">Gerät</td><td>' . e($device) . ($channel === 'app' ? ' (App)' : '') . '</td></tr>'
. '<tr><td style="padding:2px 14px 2px 0;color:#64748b">Ort</td><td>' . e($place) . '</td></tr>'
. '<tr><td style="padding:2px 14px 2px 0;color:#64748b">IP-Adresse</td><td>' . e($ip) . '</td></tr></table>'
. '<p style="margin:0 0 16px;color:#334155"><strong>Das waren Sie?</strong> Dann müssen Sie nichts tun.</p>'
. '<p style="margin:0 0 16px;color:#334155"><strong>Das waren Sie nicht?</strong> Dann sichern Sie Ihr Konto sofort: Das Passwort wird ungültig, alle App-Anmeldungen werden beendet und die Administratoren erfahren davon.</p>'
. '<p style="margin:0 0 8px"><a href="' . e($link) . '" style="display:inline-block;background:#dc2626;color:#ffffff;text-decoration:none;padding:11px 20px;border-radius:8px;font-weight:600">Das war ich nicht – Konto sichern</a></p>'
. '<p style="margin:12px 0 0;font-size:12px;color:#64748b">Der Link gilt 7 Tage und kann einmal verwendet werden.</p>';
send_mail((string) $staff['email'], 'Neue Anmeldung bei Ihrem Konto (' . $reason . ')', email_wrap($inner));
}
/** Fehlversuch ins Protokoll. $staff ist null, wenn es die Adresse nicht gibt. */
function login_guard_failed(?array $staff, string $email, string $channel, string $detail): void
{
if (!login_guard_enabled()) return;
try {
login_guard_record(get_db(), 'failed', $staff ? (int) $staff['id'] : null, $email, $channel, $detail);
} catch (Throwable $e) {
error_log('login_guard_failed: ' . $e->getMessage());
}
}
/** Die Konto-Sperre hat gegriffen: einmal je Sperrzeit ins Protokoll, Mail an den Kontoinhaber und die Administratoren. */
function login_guard_locked(string $email, string $channel): void
{
if (!login_guard_enabled()) return;
try {
$pdo = get_db();
$cut = date('Y-m-d H:i:s', time() - login_guard_lock_minutes() * 60);
$st = $pdo->prepare("SELECT COUNT(*) FROM login_events WHERE event = 'locked' AND LOWER(email) = LOWER(?) AND created_at >= ?");
$st->execute([$email, $cut]);
if ((int) $st->fetchColumn() > 0) return;
$st = $pdo->prepare('SELECT * FROM staff WHERE LOWER(email) = LOWER(?) AND is_active = 1');
$st->execute([$email]);
$staff = $st->fetch() ?: null;
login_guard_record($pdo, 'locked', $staff ? (int) $staff['id'] : null, $email, $channel, 'Zu viele Fehlversuche, Anmeldung für ' . login_guard_lock_minutes() . ' Minuten gesperrt');
$ip = client_ip();
$msg = '<h2 style="margin:0 0 12px;font-size:20px;color:#1a202c">Anmeldung gesperrt</h2>'
. '<p style="margin:0 0 12px;color:#334155">Für das Konto <strong>' . e($email) . '</strong> gab es zu viele falsche Anmeldeversuche. Die Anmeldung ist für ' . login_guard_lock_minutes() . ' Minuten gesperrt.</p>'
. '<p style="margin:0 0 12px;color:#334155">Letzte IP-Adresse: ' . e($ip) . '. Waren Sie das nicht, ändern Sie bitte Ihr Passwort, sobald die Sperre vorbei ist.</p>';
if ($staff) send_mail((string) $staff['email'], 'Ihre Anmeldung wurde gesperrt', email_wrap($msg));
login_guard_notify_admins('Anmeldung gesperrt: ' . $email, $msg, $staff ? (string) $staff['email'] : null);
} catch (Throwable $e) {
error_log('login_guard_locked: ' . $e->getMessage());
}
}
/** Mail an alle aktiven Administratoren (abschaltbar). $skipEmail: dieser Empfänger hat die Mail schon bekommen. */
function login_guard_notify_admins(string $subject, string $innerHtml, ?string $skipEmail = null): void
{
if (get_setting('login_guard_notify_admins', '1') !== '1') return;
foreach (get_db()->query("SELECT email FROM staff WHERE role = 'admin' AND is_active = 1")->fetchAll(PDO::FETCH_COLUMN) as $adminMail) {
if ($skipEmail !== null && strcasecmp((string) $adminMail, $skipEmail) === 0) continue;
if (filter_var($adminMail, FILTER_VALIDATE_EMAIL)) send_mail((string) $adminMail, $subject, email_wrap($innerHtml));
}
}
/** Alarm-Link prüfen: gibt die Protokollzeile zurück, wenn der Link gültig ist (7 Tage, einmal verwendbar). */
function login_guard_find_alert(PDO $pdo, string $token): ?array
{
if (!preg_match('/^[a-f0-9]{48}$/', $token)) return null;
$st = $pdo->prepare('SELECT * FROM login_events WHERE token_hash = ? AND token_used = 0 AND created_at >= ?');
$st->execute([hash('sha256', $token), date('Y-m-d H:i:s', time() - 7 * 86400)]);
return $st->fetch() ?: null;
}
/**
* „Das war ich nicht“: Passwort ungültig machen, App-Anmeldungen beenden, alle gemerkten Geräte und Orte vergessen, laufende
* Browser-Sitzungen beenden (current_staff() prüft den Zeitpunkt), Protokoll und Mail an die Administratoren.
*/
function login_guard_revoke(PDO $pdo, array $event): bool
{
$sid = (int) ($event['staff_id'] ?? 0);
$st = $pdo->prepare('SELECT * FROM staff WHERE id = ?');
$st->execute([$sid]);
$staff = $st->fetch();
if (!$staff) return false;
$pdo->prepare('UPDATE login_events SET token_used = 1 WHERE id = ?')->execute([(int) $event['id']]);
$pdo->prepare('UPDATE staff SET password_hash = ? WHERE id = ?')->execute([password_hash(bin2hex(random_bytes(24)), PASSWORD_DEFAULT), $sid]);
$pdo->prepare('DELETE FROM mobile_api_tokens WHERE staff_id = ?')->execute([$sid]);
$pdo->prepare('DELETE FROM login_devices WHERE staff_id = ?')->execute([$sid]);
$pdo->prepare('DELETE FROM login_places WHERE staff_id = ?')->execute([$sid]);
set_setting('login_guard_revoke_' . $sid, (string) time());
login_guard_record($pdo, 'revoked', $sid, (string) $staff['email'], 'web', 'Konto gesichert: „Das war ich nicht“ nach Eintrag ' . (int) $event['id']);
$msg = '<h2 style="margin:0 0 12px;font-size:20px;color:#1a202c">Konto gesichert</h2>'
. '<p style="margin:0 0 12px;color:#334155">' . e((string) $staff['name']) . ' (' . e((string) $staff['email']) . ') hat eine Anmeldung als „nicht von mir“ gemeldet. Das Passwort wurde ungültig gemacht, alle App-Anmeldungen wurden beendet.</p>'
. '<p style="margin:0 0 12px;color:#334155">Gemeldet war: ' . e((string) $event['device']) . ', ' . e((string) $event['place']) . ', IP ' . e((string) $event['ip']) . '. Bitte prüfen Sie das Protokoll im Adminportal unter „Anmelde-Wächter“.</p>';
login_guard_notify_admins('Konto gesichert: ' . $staff['email'], $msg);
send_mail((string) $staff['email'], 'Ihr Konto wurde gesichert', email_wrap('<h2 style="margin:0 0 12px;font-size:20px;color:#1a202c">Ihr Konto wurde gesichert</h2><p style="margin:0;color:#334155">Das Passwort ist ungültig. Setzen Sie über „Passwort vergessen?“ auf der Anmeldeseite ein neues Passwort.</p>'));
return true;
}
/** Nach „Das war ich nicht“ sind ältere Browser-Sitzungen ungültig. */
function login_guard_session_revoked(int $staffId): bool
{
$revoked = (int) get_setting('login_guard_revoke_' . $staffId, '0');
return $revoked > 0 && (int) ($_SESSION['staff_login_at'] ?? 0) <= $revoked;
}
/** Namen der Ereignisarten für die Anzeige. */
function login_guard_event_labels(): array
{
return [
'success' => 'Angemeldet',
'new_device' => 'Neues Gerät',
'new_place' => 'Neuer Ort',
'failed' => 'Fehlversuch',
'locked' => 'Gesperrt',
'revoked' => 'Konto gesichert',
];
}
+96 -4
View File
@@ -94,6 +94,7 @@ function send_mail_now(string $to, string $subject, string $htmlBody, ?string $f
}
$headers = "MIME-Version: 1.0\r\n";
$headers .= "X-DeskHub-Mail: 1\r\n"; // erkennt der E-Mail-Abruf wieder und macht daraus kein Ticket
$headers .= "Content-type: text/html; charset=UTF-8\r\n";
$headers .= "From: {$fromHeader}\r\n";
@@ -456,7 +457,7 @@ function notify_staff_new_ticket(array $ticket): void
if ($deptId) {
$stmt = $pdo->prepare(
"SELECT DISTINCT email FROM staff WHERE is_active = 1 AND (
"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)
@@ -464,9 +465,10 @@ function notify_staff_new_ticket(array $ticket): void
);
$stmt->execute([$deptId, $deptId]);
} else {
$stmt = $pdo->query("SELECT email FROM staff WHERE is_active = 1 AND role = 'admin'");
$stmt = $pdo->query("SELECT id FROM staff WHERE is_active = 1 AND role = 'admin'");
}
$recipients = $stmt->fetchAll(PDO::FETCH_COLUMN);
// Mitarbeiter im Urlaub: stattdessen bekommt ihre Vertretung die Mail
$recipients = staff_emails_by_ids($pdo, vacation_expand_ids($pdo, $stmt->fetchAll(PDO::FETCH_COLUMN)));
if (!$recipients) return;
[$subject, $body] = render_email_template('new_ticket_department', [
@@ -487,7 +489,8 @@ function notify_staff_new_ticket(array $ticket): void
/** Benachrichtigt alle aktiven Admins per E-Mail, wenn ein Ticket neu als überfällig erkannt wurde */
function notify_admins_overdue_ticket(array $ticket): void
{
$admins = get_db()->query("SELECT email FROM staff WHERE is_active = 1 AND role = 'admin'")->fetchAll(PDO::FETCH_COLUMN);
$pdo = get_db();
$admins = staff_emails_by_ids($pdo, vacation_expand_ids($pdo, $pdo->query("SELECT id FROM staff WHERE is_active = 1 AND role = 'admin'")->fetchAll(PDO::FETCH_COLUMN)));
if (!$admins) return;
[$subject, $body] = render_email_template('ticket_overdue', [
@@ -502,3 +505,92 @@ function notify_admins_overdue_ticket(array $ticket): void
email_send_and_log($email, $subject, $body, null, 'Ticket-überfällig-Mail', $ticket['ticket_number']);
}
}
// ── SLA-Überschreitung: nur an Betroffene ─────────────────────────────────────
/**
* Wer bei einer SLA-Überschreitung eine Mail bekommt: der Bearbeiter des Tickets (ist er im Urlaub, seine Vertretung), sonst die
* Mitarbeiter der Abteilung (Hauptabteilung oder zusätzliche Abteilung mit Benachrichtigung), ohne zugewiesenes Ticket und ohne Abteilungs-
* Mitarbeiter die aktiven Administratoren. Nie die Postfach-Adresse der Abteilung: Sie wird vom E-Mail-Abruf gelesen und würde Tickets erzeugen.
* @return list<string> E-Mail-Adressen
*/
function sla_overdue_recipients(PDO $pdo, array $ticket): array
{
$emails = [];
$assignee = (int) ($ticket['assigned_staff_id'] ?? 0);
if ($assignee > 0) {
// Im Urlaub: an die Vertretung; ohne nutzbare Vertretung gar nicht an den Abwesenden (dann gehen die Abteilung bzw. die Administratoren vor)
$target = staff_is_on_vacation($assignee) ? vacation_target($pdo, $assignee) : $assignee;
if ($target !== null) {
$q = $pdo->prepare('SELECT email FROM staff WHERE id = ? AND is_active = 1 AND is_on_vacation = 0');
$q->execute([$target]);
$e = $q->fetchColumn();
if ($e) $emails[] = (string) $e;
}
}
if (!$emails && !empty($ticket['department_id'])) {
$q = $pdo->prepare(
"SELECT DISTINCT id FROM staff WHERE is_active = 1 AND (department_id = ?
OR id IN (SELECT staff_id FROM staff_department WHERE department_id = ? AND notify = 1))"
);
$q->execute([(int) $ticket['department_id'], (int) $ticket['department_id']]);
$emails = staff_emails_by_ids($pdo, vacation_expand_ids($pdo, $q->fetchAll(PDO::FETCH_COLUMN)));
}
if (!$emails) {
$emails = staff_emails_by_ids($pdo, vacation_expand_ids($pdo, $pdo->query("SELECT id FROM staff WHERE is_active = 1 AND role = 'admin'")->fetchAll(PDO::FETCH_COLUMN)));
}
$own = mail_own_system_addresses($pdo);
$out = [];
foreach ($emails as $e) {
$e = trim($e);
if ($e !== '' && filter_var($e, FILTER_VALIDATE_EMAIL) && !in_array(strtolower($e), $own, true)) $out[strtolower($e)] = $e;
}
return array_values($out);
}
/** Sendet die SLA-Mail (Vorlage „ticket_overdue“) an die Betroffenen. Liefert die Zahl der Empfänger. */
function notify_overdue_ticket_staff(array $ticket): int
{
$pdo = get_db();
$recipients = sla_overdue_recipients($pdo, $ticket);
if (!$recipients) return 0;
[$subject, $body] = render_email_template('ticket_overdue', [
'ticket_number' => $ticket['ticket_number'],
'subject' => e($ticket['subject']),
'department_name' => e($ticket['department_name'] ?? ''),
'ticket_url' => base_url() . '/staff/ticket_view.php?n=' . urlencode($ticket['ticket_number']),
], "SLA überschritten: Ticket #{ticket_number}", email_ticket_overdue_default_body());
$body = email_wrap($body);
foreach ($recipients as $email) email_send_and_log($email, $subject, $body, null, 'SLA-überschritten-Mail', $ticket['ticket_number']);
return count($recipients);
}
// ── Schutz vor Mail-Schleifen ────────────────────────────────────────────────
/** Adressen (klein geschrieben), von denen DeskHub selbst Mails verschickt: Standard-Absender und Absender-/Abteilungsadressen. */
function mail_own_system_addresses(PDO $pdo): array
{
$out = [];
$add = function ($a) use (&$out) {
$a = strtolower(trim((string) $a));
if ($a !== '' && str_contains($a, '@')) $out[$a] = true;
};
$add(get_setting('smtp_from_email', config()['mail']['from_email'] ?? ''));
$add(config()['mail']['from_email'] ?? '');
try {
foreach ($pdo->query('SELECT email FROM email_addresses')->fetchAll(PDO::FETCH_COLUMN) as $a) $add($a);
foreach ($pdo->query('SELECT email FROM departments')->fetchAll(PDO::FETCH_COLUMN) as $a) $add($a);
} catch (Throwable $e) {
}
return array_keys($out);
}
/**
* Ist das eine Mail, die DeskHub selbst verschickt hat (und die über ein abgerufenes Postfach zurückkommt)? Daraus darf kein Ticket werden.
* Erkennung: unsere Kopfzeile X-DeskHub-Mail oder ein Absender, der eine unserer eigenen Systemadressen ist.
*/
function mail_is_own_system_mail(PDO $pdo, string $fromEmail, string $rawHeader = ''): bool
{
if ($rawHeader !== '' && preg_match('/^X-DeskHub-Mail:/mi', $rawHeader)) return true;
return in_array(strtolower(trim($fromEmail)), mail_own_system_addresses($pdo), true);
}
+8 -2
View File
@@ -119,13 +119,19 @@ function notify_team_assignment(PDO $pdo, array $ticket, int $teamId, ?int $acto
$team = $stmt->fetch();
if (!$team || !$team['is_active'] || $team['no_alerts']) return 0;
$members = $pdo->prepare('SELECT s.id, s.email FROM team_members tm JOIN staff s ON s.id = tm.staff_id WHERE tm.team_id = ? AND s.is_active = 1');
$members = $pdo->prepare('SELECT s.id FROM team_members tm JOIN staff s ON s.id = tm.staff_id WHERE tm.team_id = ? AND s.is_active = 1');
$members->execute([$teamId]);
// Mitglieder im Urlaub: stattdessen ihre Vertretung
$memberRows = [];
foreach (vacation_expand_ids($pdo, $members->fetchAll(PDO::FETCH_COLUMN)) as $mid) {
$em = staff_emails_by_ids($pdo, [$mid]);
if ($em) $memberRows[] = ['id' => $mid, 'email' => $em[0]];
}
$sent = 0;
$subject = 'Ticket #' . $ticket['ticket_number'] . ' wurde Team „' . $team['name'] . '“ zugewiesen';
$body = '<p>Das Ticket <strong>#' . e($ticket['ticket_number']) . '</strong> („' . e($ticket['subject']) . '“) wurde dem Team <strong>'
. e($team['name']) . '</strong> zugewiesen.</p>';
foreach ($members->fetchAll() as $m) {
foreach ($memberRows as $m) {
if ($actorId !== null && (int) $m['id'] === $actorId) continue;
if (send_mail($m['email'], $subject, $body)) $sent++;
}
+112 -1
View File
@@ -34,13 +34,15 @@ function plugin_known_permissions(): array
'api.endpoints' => 'Eigene Endpunkte in der Mitarbeiter-API',
'api.public' => 'Öffentliche Webhook-Adresse (zum Beispiel für Discord oder Telegram); das Plugin prüft die Echtheit der Anfragen selbst',
'db.migrations' => 'Eigene Datenbank-Tabellen anlegen',
'auth.customer' => 'Kunden über einen externen Dienst anmelden und Kundenkonten anlegen (zum Beispiel authentik): Knopf auf der Anmeldeseite des Kundenportals. ACHTUNG: Das Plugin entscheidet selbst, wer sich anmelden darf und ob Konten entstehen.',
'auth.login' => 'Mitarbeiter über einen externen Dienst anmelden (zum Beispiel authentik): Knopf auf der Anmeldeseite und eigene Anmelde-Adresse. ACHTUNG: Das Plugin entscheidet selbst, wer sich anmelden darf.',
];
}
/** Gemeinsamer Zustand (Hooks, Menüs, aktuell ausgeführtes Plugin). */
function &plugin_state(): array
{
static $state = ['hooks' => [], 'filters' => [], 'menus' => [], 'api' => [], 'webhooks' => [], 'perms' => [], 'loaded' => [], 'current' => null, 'booted' => false, 'shutdown' => false];
static $state = ['hooks' => [], 'filters' => [], 'menus' => [], 'api' => [], 'webhooks' => [], 'logins' => [], 'login_loggedin' => [], 'customer_logins' => [], 'perms' => [], 'loaded' => [], 'current' => null, 'booted' => false, 'shutdown' => false];
return $state;
}
@@ -128,6 +130,8 @@ function plugin_filter_permission(string $hook): ?string
{
if ($hook === 'ticket.view.sidebar' || $hook === 'staff.page.top') return 'ui.sidebar';
if (str_starts_with($hook, 'ticket.')) return 'hooks.tickets';
if ($hook === 'staff.login.buttons') return 'auth.login'; // Knöpfe auf der Mitarbeiter-Anmeldeseite
if ($hook === 'customer.login.buttons') return 'auth.customer'; // Knöpfe auf der Kunden-Anmeldeseite
if ($hook === 'backup.list.rows') return 'ui.menu'; // wer einen Reiter unter „Backup“ anbieten darf, darf auch die Liste der Backups ergänzen
return null;
}
@@ -182,6 +186,37 @@ function plugin_register_webhook(string $endpoint, callable $handler): void
$state['webhooks'][$slug][$endpoint] = $handler;
}
/**
* Registriert eine Anmelde-Adresse für Mitarbeiter: GET /staff/plugin_login.php?p=<slug>&e=<name>. Gedacht für externe Anmeldedienste (OpenID Connect, SAML …).
* Der Handler erhält ['query' => $_GET, 'ip' => …] (die Sitzung $_SESSION steht zur Verfügung) und gibt eines davon zurück:
* ['redirect' => 'https://…'] weiter zum Anmeldedienst (nur https, oder http zu localhost)
* ['staff_id' => 12] dieser Mitarbeiter ist geprüft: DeskHub meldet ihn an (nur aktive Konten, mit Eintrag im Admin-Protokoll)
* ['error' => 'Text'] zurück zur Anmeldeseite mit dieser Meldung
* Den Link zum Starten baut plugin_login_url(). Wer anmelden darf, entscheidet allein das Plugin (Berechtigung auth.login).
*
* Mit $whenLoggedIn = true läuft die Adresse auch für bereits angemeldete Mitarbeiter (zum Beispiel, um ein Konto beim Anmeldedienst zu verknüpfen).
* Der Handler bekommt dann zusätzlich ['staff' => <angemeldeter Mitarbeiter>] und darf nur zurückgeben:
* ['redirect' => 'https://…'] weiter zum Anmeldedienst
* ['local' => '/staff/…', 'notice' => 'Text'] zurück in den Mitarbeiterbereich, mit Hinweis
* ['error' => 'Text', 'back' => '/staff/…'] zurück mit Fehlermeldung
* Ein anderer Mitarbeiter kann so nie angemeldet werden (staff_id wird dann abgelehnt).
*/
function plugin_register_login(string $endpoint, callable $handler, bool $whenLoggedIn = false): void
{
$state = &plugin_state();
$slug = $state['current'];
if ($slug === null || !preg_match('/^[a-z0-9_-]{1,40}$/', $endpoint)) return;
if (!plugin_permission_allowed('auth.login')) return;
$state['logins'][$slug][$endpoint] = $handler;
$state['login_loggedin'][$slug][$endpoint] = $whenLoggedIn;
}
/** Adresse (ohne Domain) einer mit plugin_register_login() angemeldeten Anmelde-Adresse. */
function plugin_login_url(string $slug, string $endpoint): string
{
return '/staff/plugin_login.php?p=' . rawurlencode($slug) . '&e=' . rawurlencode($endpoint);
}
function plugin_register_api(string $endpoint, callable $handler, ?string $permission = null): void
{
if (!plugin_permission_allowed('api.endpoints')) return;
@@ -983,3 +1018,79 @@ function catalog_thumb(string $kind, array $c): string
}
return '<div aria-hidden="true" style="' . $box . 'display:flex;align-items:center;justify-content:center;font-weight:700;color:var(--muted,#64748b);font-size:1.3rem">' . e(mb_strtoupper(mb_substr((string) $c['name'], 0, 1))) . '</div>';
}
// ── Anmeldung von Kunden über Plugins (Berechtigung auth.customer) ──────────────────────────────────────────────────────────────
/**
* Registriert eine Anmelde-Adresse für Kunden: GET /plugin_login.php?p=<slug>&e=<name> (öffentlich, Kundenportal).
* Der Handler erhält ['query' => $_GET, 'ip' => …] und gibt zurück:
* ['redirect' => 'https://…'] weiter zum Anmeldedienst (nur https, oder http zu localhost)
* ['customer' => ['email' => …, 'name' => …, 'create' => true|false, 'via' => 'Dienst']]
* diese Person ist vom Dienst bestätigt: DeskHub meldet das Kundenkonto mit dieser
* E-Mail-Adresse an; gibt es keines und 'create' ist true, wird es angelegt
* ['error' => 'Text'] zurück zur Kunden-Anmeldeseite mit dieser Meldung
* Die Rückkehr vom Dienst darf auch über eine mit plugin_register_login() angemeldete Adresse kommen, wenn das Plugin beide Berechtigungen hat.
* Das Plugin trägt die Verantwortung, dass die E-Mail-Adresse wirklich bestätigt ist.
*/
function plugin_register_customer_login(string $endpoint, callable $handler): void
{
$state = &plugin_state();
$slug = $state['current'];
if ($slug === null || !preg_match('/^[a-z0-9_-]{1,40}$/', $endpoint)) return;
if (!plugin_permission_allowed('auth.customer')) return;
$state['customer_logins'][$slug][$endpoint] = $handler;
}
/** Adresse (ohne Domain) einer mit plugin_register_customer_login() angemeldeten Kunden-Anmelde-Adresse. */
function plugin_customer_login_url(string $slug, string $endpoint): string
{
return '/plugin_login.php?p=' . rawurlencode($slug) . '&e=' . rawurlencode($endpoint);
}
/** Gilt die Weiterleitung zu einem Anmeldedienst? Nur https (oder http zu localhost zum Testen), keine Zeichen < Leerzeichen. */
function plugin_login_external_url_ok(string $url): bool
{
if ($url === '' || strlen($url) > 4000 || preg_match('/[\x00-\x20]/', $url)) return false;
$p = parse_url($url);
if (!$p || empty($p['host'])) return false;
$scheme = $p['scheme'] ?? '';
return $scheme === 'https' || ($scheme === 'http' && in_array($p['host'], ['localhost', '127.0.0.1', '[::1]'], true));
}
/**
* Meldet einen vom Plugin bestätigten Kunden an (und legt das Konto an, wenn erlaubt). Endet immer mit einer Weiterleitung.
* Ein Plugin ohne Berechtigung auth.customer kommt hier nicht durch.
* @param array{email?:string,name?:string,create?:bool,via?:string} $c
*/
function plugin_customer_login_finish(string $slug, array $c): never
{
$fail = function (string $message): never {
flash('error', $message);
redirect('/login.php');
};
if (!in_array('auth.customer', plugin_state()['perms'][$slug] ?? [], true)) $fail('Diese Anmeldung ist nicht erlaubt.');
$email = strtolower(trim((string) ($c['email'] ?? '')));
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > 150) $fail('Die E-Mail-Adresse ist ungültig.');
if (is_banned($email)) $fail('Mit dieser E-Mail-Adresse ist keine Anmeldung möglich.');
$pdo = get_db();
$st = $pdo->prepare('SELECT * FROM users WHERE LOWER(email) = ?');
$st->execute([$email]);
$user = $st->fetch();
$created = false;
$via = mb_substr((string) ($c['via'] ?? $slug), 0, 60);
if (!$user) {
if (empty($c['create'])) $fail('Für diese E-Mail-Adresse gibt es kein Kundenkonto.');
$name = trim((string) preg_replace('/\s+/u', ' ', strip_tags((string) ($c['name'] ?? ''))));
if ($name === '') $name = ucfirst(explode('@', $email)[0]);
$name = mb_substr($name, 0, 120);
// Ein Passwort gibt es nicht: Wer eines möchte, nutzt „Passwort vergessen“
$pdo->prepare('INSERT INTO users (name, email, password_hash, created_at) VALUES (?,?,?,?)')
->execute([$name, $email, password_hash(bin2hex(random_bytes(24)), PASSWORD_DEFAULT), db_now()]);
$user = ['id' => (int) $pdo->lastInsertId(), 'name' => $name, 'email' => $email];
audit_log(null, 'Kundenkonto über ' . $via . ' angelegt: ' . mb_substr($email, 0, 120));
$created = true;
}
customer_login($user);
if ($created) flash('success', 'Willkommen! Ihr Konto wurde erstellt.');
redirect('/home.php');
}
@@ -135,6 +135,7 @@ class SmtpMailer
$headers[] = "From: {$from}";
$headers[] = "Subject: {$encodedSubject}";
$headers[] = "MIME-Version: 1.0";
$headers[] = "X-DeskHub-Mail: 1"; // erkennt der E-Mail-Abruf wieder und macht daraus kein Ticket
$headers[] = "Content-Type: text/html; charset=UTF-8";
$headers[] = "Content-Transfer-Encoding: base64";
return implode("\r\n", $headers) . "\r\n";
+137
View File
@@ -0,0 +1,137 @@
<?php
/**
* Abwesenheit im Profil: Offene Tickets an die Vertretung weitergeben und nach dem Urlaub zurückgeben.
* Das Plugin „Urlaub und Vertretung“ macht dasselbe nach Datum; ist dort eine Abwesenheit aktiv, übernimmt das Plugin und der Server hält sich heraus.
* Welche Tickets weitergegeben wurden, steht in vacation_moves: Zurückgegeben wird nur, was noch bei der Vertretung liegt und offen ist
* (hat jemand ein Ticket inzwischen umgehängt oder erledigt, bleibt es so).
*/
/** Wer arbeitet an Stelle von $staffId? Folgt Vertretungsketten (höchstens drei Stufen, ohne Schleifen); null ohne nutzbare Vertretung. */
function vacation_target(PDO $pdo, int $staffId): ?int
{
$get = $pdo->prepare('SELECT id, is_active, is_on_vacation, vacation_substitute_id FROM staff WHERE id = ?');
$get->execute([$staffId]);
$row = $get->fetch();
if (!$row || !(int) $row['is_on_vacation']) return null;
$seen = [$staffId];
for ($i = 0; $i < 3; $i++) {
$sub = (int) ($row['vacation_substitute_id'] ?? 0);
if ($sub <= 0 || in_array($sub, $seen, true)) return null;
$get->execute([$sub]);
$row = $get->fetch();
if (!$row || !(int) $row['is_active']) return null;
if (!(int) $row['is_on_vacation']) return $sub;
$seen[] = $sub;
}
return null;
}
/** Übernimmt das Plugin „Urlaub und Vertretung“ gerade diese Abwesenheit? */
function vacation_plugin_owns(PDO $pdo, int $staffId): bool
{
try {
$q = $pdo->prepare("SELECT COUNT(*) FROM plugin_vertretung_absences WHERE staff_id = ? AND status = 'active'");
$q->execute([$staffId]);
return (int) $q->fetchColumn() > 0;
} catch (Throwable $e) {
return false; // Plugin nicht installiert
}
}
/** Gibt die offenen Tickets von $staffId an die Vertretung weiter (die Abwesenheit muss schon eingetragen sein). Liefert die Zahl. */
function vacation_hand_over(PDO $pdo, int $staffId): int
{
if (vacation_plugin_owns($pdo, $staffId)) return 0;
$to = vacation_target($pdo, $staffId);
if ($to === null || $to === $staffId) return 0;
$name = (string) $pdo->query('SELECT name FROM staff WHERE id = ' . $staffId)->fetchColumn();
$toName = (string) $pdo->query('SELECT name FROM staff WHERE id = ' . $to)->fetchColumn();
$t = $pdo->prepare("SELECT id, ticket_number, subject FROM tickets WHERE assigned_staff_id = ? AND is_spam = 0 AND status NOT IN ('resolved','closed') ORDER BY id");
$t->execute([$staffId]);
$tickets = $t->fetchAll();
foreach ($tickets as $row) {
$pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$to, $row['id']]);
$pdo->prepare('INSERT INTO vacation_moves (ticket_id, from_staff_id, to_staff_id, created_at) VALUES (?,?,?,?)')->execute([$row['id'], $staffId, $to, db_now()]);
log_activity($pdo, (int) $row['id'], $staffId, 'An die Vertretung ' . $toName . ' weitergegeben (' . $name . ' ist abwesend)');
}
if ($tickets) {
$list = '';
foreach (array_slice($tickets, 0, 15) as $row) $list .= '<li>#' . e((string) $row['ticket_number']) . ' – ' . e(mb_strimwidth((string) $row['subject'], 0, 70, '…')) . '</li>';
$mail = $pdo->prepare('SELECT email FROM staff WHERE id = ?');
$mail->execute([$to]);
$addr = (string) $mail->fetchColumn();
if ($addr !== '') send_mail($addr, 'Vertretung für ' . $name . ': ' . count($tickets) . ' Tickets', email_wrap(
'<h2 style="margin:0 0 12px;font-size:20px;color:#1a202c">Sie vertreten ' . e($name) . '</h2>'
. '<p style="margin:0 0 12px;color:#334155">' . count($tickets) . ' offene Tickets wurden Ihnen zugewiesen. Neue Tickets von ' . e($name) . ' kommen ebenfalls zu Ihnen, solange die Abwesenheit dauert.</p>'
. '<ul style="color:#334155">' . $list . '</ul>'));
}
return count($tickets);
}
/** Gibt weitergegebene Tickets zurück, die noch bei der Vertretung liegen und offen sind. Liefert die Zahl der zurückgegebenen Tickets. */
function vacation_take_back(PDO $pdo, int $staffId): int
{
if (vacation_plugin_owns($pdo, $staffId)) return 0;
$m = $pdo->prepare('SELECT m.id, m.ticket_id, m.to_staff_id, t.assigned_staff_id, t.status FROM vacation_moves m JOIN tickets t ON t.id = m.ticket_id WHERE m.from_staff_id = ? AND m.returned_at IS NULL ORDER BY m.id');
$m->execute([$staffId]);
$back = 0;
$name = (string) $pdo->query('SELECT name FROM staff WHERE id = ' . $staffId)->fetchColumn();
foreach ($m->fetchAll() as $row) {
$stillThere = (int) $row['assigned_staff_id'] === (int) $row['to_staff_id'] && !in_array($row['status'], ['resolved', 'closed'], true);
if ($stillThere) {
$pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$staffId, $row['ticket_id']]);
log_activity($pdo, (int) $row['ticket_id'], $staffId, 'Zurück an ' . $name . ' (Abwesenheit beendet)');
$back++;
}
$pdo->prepare('UPDATE vacation_moves SET returned_at = ? WHERE id = ?')->execute([db_now(), $row['id']]);
}
return $back;
}
/**
* Wird nach dem Speichern der Abwesenheit im Profil aufgerufen. $before/$after: [is_on_vacation, vacation_substitute_id].
* Beim Speichern mit Haken: weitergeben (auch erneut, es zählen nur Tickets, die noch beim Abwesenden liegen); Ende → zurückgeben; neue Vertretung → erst zurück, dann an die neue.
* Liefert [weitergegeben, zurückgegeben].
*/
function vacation_apply_change(PDO $pdo, int $staffId, array $before, array $after, bool $handOver): array
{
$handed = $back = 0;
$wasOn = (int) $before[0] === 1;
$isOn = (int) $after[0] === 1;
$changedSub = (int) ($before[1] ?? 0) !== (int) ($after[1] ?? 0);
if ($wasOn && (!$isOn || $changedSub)) $back = vacation_take_back($pdo, $staffId);
if ($isOn && $handOver) $handed = vacation_hand_over($pdo, $staffId); // idempotent: nur Tickets, die noch beim Abwesenden liegen
return [$handed, $back];
}
/**
* Ersetzt Mitarbeiter im Urlaub durch ihre Vertretung (folgt Ketten), damit Benachrichtigungen dort ankommen, wo jemand arbeitet.
* Ohne nutzbare Vertretung entfällt der Eintrag. Ergebnis: eindeutige Mitarbeiter-IDs, die aktiv und anwesend sind.
* @param int[] $ids
* @return int[]
*/
function vacation_expand_ids(PDO $pdo, array $ids): array
{
$out = [];
foreach (array_unique(array_map('intval', $ids)) as $id) {
if ($id <= 0) continue;
$target = staff_is_on_vacation($id) ? vacation_target($pdo, $id) : $id;
if ($target !== null) $out[$target] = $target;
}
if (!$out) return [];
$in = implode(',', array_fill(0, count($out), '?'));
$q = $pdo->prepare("SELECT id FROM staff WHERE is_active = 1 AND is_on_vacation = 0 AND id IN ($in)");
$q->execute(array_values($out));
return array_map('intval', $q->fetchAll(PDO::FETCH_COLUMN));
}
/** E-Mail-Adressen der Mitarbeiter-IDs (nur aktive, anwesende). @param int[] $ids @return string[] */
function staff_emails_by_ids(PDO $pdo, array $ids): array
{
$ids = array_values(array_unique(array_map('intval', $ids)));
if (!$ids) return [];
$in = implode(',', array_fill(0, count($ids), '?'));
$q = $pdo->prepare("SELECT DISTINCT email FROM staff WHERE is_active = 1 AND is_on_vacation = 0 AND id IN ($in)");
$q->execute($ids);
return array_map('strval', $q->fetchAll(PDO::FETCH_COLUMN));
}