diff --git a/DeskHub-Webseite/includes/auth.php b/DeskHub-Webseite/includes/auth.php index cadf5e28..f1191d13 100644 --- a/DeskHub-Webseite/includes/auth.php +++ b/DeskHub-Webseite/includes/auth.php @@ -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); } diff --git a/DeskHub-Webseite/includes/bootstrap.php b/DeskHub-Webseite/includes/bootstrap.php index abe3a1ab..c00690ae 100644 --- a/DeskHub-Webseite/includes/bootstrap.php +++ b/DeskHub-Webseite/includes/bootstrap.php @@ -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'; diff --git a/DeskHub-Webseite/includes/fcm.php b/DeskHub-Webseite/includes/fcm.php index cc61b544..755bb583 100644 --- a/DeskHub-Webseite/includes/fcm.php +++ b/DeskHub-Webseite/includes/fcm.php @@ -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'] ?? '')))); diff --git a/DeskHub-Webseite/includes/functions.php b/DeskHub-Webseite/includes/functions.php index 938d6622..24dd89a1 100644 --- a/DeskHub-Webseite/includes/functions.php +++ b/DeskHub-Webseite/includes/functions.php @@ -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 + */ +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=?'); diff --git a/DeskHub-Webseite/includes/login_guard.php b/DeskHub-Webseite/includes/login_guard.php new file mode 100644 index 00000000..c17dea3d --- /dev/null +++ b/DeskHub-Webseite/includes/login_guard.php @@ -0,0 +1,282 @@ + '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 = '

Neue Anmeldung bei Ihrem Konto

' + . '

Hallo ' . $name . ', Ihr Mitarbeiter-Konto wurde gerade von einem bisher unbekannten ' . ($reason === 'Neuer Ort' ? 'Ort' : 'Gerät') . ' aus genutzt.

' + . '' + . '' + . '' + . '
Zeit' . e(date('d.m.Y H:i')) . '
Gerät' . e($device) . ($channel === 'app' ? ' (App)' : '') . '
Ort' . e($place) . '
IP-Adresse' . e($ip) . '
' + . '

Das waren Sie? Dann müssen Sie nichts tun.

' + . '

Das waren Sie nicht? Dann sichern Sie Ihr Konto sofort: Das Passwort wird ungültig, alle App-Anmeldungen werden beendet und die Administratoren erfahren davon.

' + . '

Das war ich nicht – Konto sichern

' + . '

Der Link gilt 7 Tage und kann einmal verwendet werden.

'; + 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 = '

Anmeldung gesperrt

' + . '

Für das Konto ' . e($email) . ' gab es zu viele falsche Anmeldeversuche. Die Anmeldung ist für ' . login_guard_lock_minutes() . ' Minuten gesperrt.

' + . '

Letzte IP-Adresse: ' . e($ip) . '. Waren Sie das nicht, ändern Sie bitte Ihr Passwort, sobald die Sperre vorbei ist.

'; + 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 = '

Konto gesichert

' + . '

' . 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.

' + . '

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“.

'; + login_guard_notify_admins('Konto gesichert: ' . $staff['email'], $msg); + send_mail((string) $staff['email'], 'Ihr Konto wurde gesichert', email_wrap('

Ihr Konto wurde gesichert

Das Passwort ist ungültig. Setzen Sie über „Passwort vergessen?“ auf der Anmeldeseite ein neues Passwort.

')); + 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', + ]; +} diff --git a/DeskHub-Webseite/includes/mailer.php b/DeskHub-Webseite/includes/mailer.php index 2c914e2b..93d81977 100644 --- a/DeskHub-Webseite/includes/mailer.php +++ b/DeskHub-Webseite/includes/mailer.php @@ -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 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); +} diff --git a/DeskHub-Webseite/includes/notifications.php b/DeskHub-Webseite/includes/notifications.php index 042d9239..6b5efa27 100644 --- a/DeskHub-Webseite/includes/notifications.php +++ b/DeskHub-Webseite/includes/notifications.php @@ -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 = '

Das Ticket #' . e($ticket['ticket_number']) . ' („' . e($ticket['subject']) . '“) wurde dem Team ' . e($team['name']) . ' zugewiesen.

'; - 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++; } diff --git a/DeskHub-Webseite/includes/plugins.php b/DeskHub-Webseite/includes/plugins.php index 949f74d3..f929a8e1 100644 --- a/DeskHub-Webseite/includes/plugins.php +++ b/DeskHub-Webseite/includes/plugins.php @@ -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=&e=. 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' => ] 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 ''; } + +// ── Anmeldung von Kunden über Plugins (Berechtigung auth.customer) ────────────────────────────────────────────────────────────── + +/** + * Registriert eine Anmelde-Adresse für Kunden: GET /plugin_login.php?p=&e= (ö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'); +} diff --git a/DeskHub-Webseite/includes/smtp_mailer.php b/DeskHub-Webseite/includes/smtp_mailer.php index 5934cdc0..981b2d5d 100644 --- a/DeskHub-Webseite/includes/smtp_mailer.php +++ b/DeskHub-Webseite/includes/smtp_mailer.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"; diff --git a/DeskHub-Webseite/includes/vacation.php b/DeskHub-Webseite/includes/vacation.php new file mode 100644 index 00000000..1fabd649 --- /dev/null +++ b/DeskHub-Webseite/includes/vacation.php @@ -0,0 +1,137 @@ +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 .= '
  • #' . e((string) $row['ticket_number']) . ' – ' . e(mb_strimwidth((string) $row['subject'], 0, 70, '…')) . '
  • '; + $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( + '

    Sie vertreten ' . e($name) . '

    ' + . '

    ' . count($tickets) . ' offene Tickets wurden Ihnen zugewiesen. Neue Tickets von ' . e($name) . ' kommen ebenfalls zu Ihnen, solange die Abwesenheit dauert.

    ' + . '
      ' . $list . '
    ')); + } + 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)); +}