diff --git a/DeskHub-Webseite/includes/admin_models.php b/DeskHub-Webseite/includes/admin_models.php index 04233640..1e8ee436 100644 --- a/DeskHub-Webseite/includes/admin_models.php +++ b/DeskHub-Webseite/includes/admin_models.php @@ -86,11 +86,38 @@ function department_save(PDO $pdo, int $id, array $in): int $pdo->prepare('UPDATE departments SET name=?, email=?, is_default=?, is_active=?, manager_id=?, sla_plan_id=?, notes=?, signature=?, updated_at=? WHERE id=?') ->execute([$name, $email, $isDefault, $isActive, $managerId, $slaPlanId, $notes ?: null, $signature ?: null, $now, $id]); } + // Automatische Zuweisung neuer Tickets (nur wenn mitgeschickt: ältere Apps lassen die Einstellung unverändert) + if (array_key_exists('auto_assign_mode', $in)) { + try { + department_save_auto_assign($pdo, $id, $in); + } catch (PDOException $e) { + // Spalten fehlen: die Datenbank ist noch nicht auf dem neuesten Stand + throw new AdminModelException('Die Datenbank ist noch nicht auf dem neuesten Stand. Bitte unter System-Update auf "Datenbank aktualisieren" klicken und es dann erneut versuchen.', 'DB_OUTDATED', 503); + } + } // Eingehende Mails an die Abteilungs-E-Mail landen ohne weiteren Schritt in dieser Abteilung if ($email !== '') sync_department_email_route($pdo, $id, $email); return $id; } +/** Speichert die automatische Zuweisung einer Abteilung (Art und Mitarbeiter). */ +function department_save_auto_assign(PDO $pdo, int $id, array $in): void +{ + $mode = (string) $in['auto_assign_mode']; + if (!array_key_exists($mode, department_auto_assign_modes())) throw new AdminModelException('Die Art der automatischen Zuweisung ist ungültig.', 'INVALID_AUTO_ASSIGN'); + $assignStaff = am_int_or_null($in, 'auto_assign_staff_id'); + if ($mode === 'staff') { + $chk = $pdo->prepare('SELECT COUNT(*) FROM staff WHERE id = ? AND is_active = 1'); + $chk->execute([(int) $assignStaff]); + if (!$assignStaff || !(int) $chk->fetchColumn()) throw new AdminModelException('Bitte einen aktiven Mitarbeiter für die automatische Zuweisung wählen.', 'INVALID_AUTO_ASSIGN_STAFF'); + } else { + $assignStaff = null; + } + $prevMode = (string) am_current($pdo, 'departments', $id, 'auto_assign_mode', 'none'); + $pdo->prepare('UPDATE departments SET auto_assign_mode = ?, auto_assign_staff_id = ? WHERE id = ?')->execute([$mode, $assignStaff, $id]); + if ($prevMode !== $mode) $pdo->prepare('UPDATE departments SET auto_assign_last_id = NULL WHERE id = ?')->execute([$id]); +} + /** Löscht eine Abteilung (nur ohne zugeordnete Tickets). Gibt den Namen zurück. */ /** * Anzahl der Datensätze in $table, die über $column auf $id verweisen (0, wenn Tabelle oder Spalte in dieser Datenbank fehlen). diff --git a/DeskHub-Webseite/includes/auth.php b/DeskHub-Webseite/includes/auth.php index 089fd5e3..dd251094 100644 --- a/DeskHub-Webseite/includes/auth.php +++ b/DeskHub-Webseite/includes/auth.php @@ -90,15 +90,27 @@ function require_staff_login(): array // Inaktivitäts-Timeout $timeout = (int)(get_setting('staff_session_timeout', '30') ?: 30); // Minuten $last = $_SESSION['staff_last_activity'] ?? 0; + $userNavigation = staff_is_user_navigation(); if ($last && (time() - $last) > ($timeout * 60)) { staff_logout(); - session_start(); + if (session_status() !== PHP_SESSION_ACTIVE) session_start(); + if (!$userNavigation) { + // Hintergrund-Abruf (Aktualisierung der Liste, Chat-Abfrage …): kurze Antwort statt Weiterleitung auf die Anmeldeseite + http_response_code(401); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['error' => 'Session abgelaufen', 'sessionExpired' => true]); + exit; + } $_SESSION['flash'] = [['type'=>'error','message'=>'Sie wurden wegen Inaktivität abgemeldet.']]; redirect('/staff/login.php'); } - $_SESSION['staff_last_activity'] = time(); - // Echte Seitenaufrufe zählen als Aktivität (Hintergrund-Abrufe per fetch nicht) - if (staff_is_user_navigation()) staff_touch_activity(get_db(), (int) $staff['id']); + // Nur echte Bedienung hält die Sitzung am Leben: ein vom Nutzer ausgelöster Seitenaufruf oder der Ping der Seite bei Klicks und + // Tastatureingaben (staff/session_ping.php). Hintergrund-Abfragen der offenen Seiten (Listen, Chat, Anwesenheit alle paar + // Sekunden) zählen nicht, sonst würde eine offene Seite die Sitzung ewig verlängern und die Abmeldung nie greifen. + if ($userNavigation) { + $_SESSION['staff_last_activity'] = time(); + staff_touch_activity(get_db(), (int) $staff['id']); + } // "Eingebauter" Ersatz für echte Server-Cronjobs (siehe includes/pseudo_cron.php): // bei jeder authentifizierten Backend-Anfrage werden fällige Jobs im Hintergrund geprüft. diff --git a/DeskHub-Webseite/includes/bootstrap.php b/DeskHub-Webseite/includes/bootstrap.php index fcf33ceb..32c4640f 100644 --- a/DeskHub-Webseite/includes/bootstrap.php +++ b/DeskHub-Webseite/includes/bootstrap.php @@ -32,6 +32,9 @@ date_default_timezone_set(get_setting('timezone', config()['site']['timezone'] ? // Sichere Session-Cookie-Einstellungen (vor session_start) if (session_status() === PHP_SESSION_NONE) { + // Die Sitzungsdaten müssen mindestens so lange liegen bleiben wie die Inaktivitätszeit (PHP-Standard: 24 Minuten, sonst würde man + // vor Ablauf der eingestellten Zeit abgemeldet) + @ini_set('session.gc_maxlifetime', (string) max(1440, ((int) (get_setting('staff_session_timeout', '30') ?: 30)) * 60 + 600)); session_set_cookie_params([ 'lifetime' => 0, 'path' => '/', diff --git a/DeskHub-Webseite/includes/functions.php b/DeskHub-Webseite/includes/functions.php index fbadd819..9724ea28 100644 --- a/DeskHub-Webseite/includes/functions.php +++ b/DeskHub-Webseite/includes/functions.php @@ -54,8 +54,8 @@ function verify_csrf(): void http_response_code(403); die('Ungültige Anfrage (CSRF-Token fehlt oder ist ungültig). Bitte gehen Sie zurück und versuchen Sie es erneut.'); } - // Token nach Verbrauch rotieren - $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + // Der Token gilt für die ganze Sitzung und wird NICHT nach jeder Benutzung erneuert: Sonst würden alle anderen offenen Tabs desselben + // Browsers beim nächsten Absenden "CSRF-Token ungültig" melden (typisch bei mehreren Tickets in Tabs). Er wird beim Anmelden neu erzeugt. } function flash(string $type, string $message): void @@ -423,6 +423,90 @@ function staff_department_ids(array $staff): ?array return array_values(array_unique($ids)); } +/** + * 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, + * wenn der Mitarbeiter in dieser Abteilung nicht antworten darf. Aktualisiert $ticket (assigned_staff_id, staff_name) und gibt zurück, + * ob zugewiesen wurde. + */ +function ticket_assign_on_open(PDO $pdo, array &$ticket, array $staff): bool +{ + if (get_setting('auto_assign_on_open', '1') !== '1') return false; + if (!empty($ticket['assigned_staff_id']) || !empty($ticket['merged_into']) || !empty($ticket['is_spam'])) return false; + if (in_array((string) ($ticket['status'] ?? ''), ['closed', 'resolved'], true)) return false; + if (!has_permission($staff, 'reply_ticket', (int) ($ticket['department_id'] ?? 0))) return false; + $stmt = $pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ? AND assigned_staff_id IS NULL'); + $stmt->execute([(int) $staff['id'], (int) $ticket['id']]); + if ($stmt->rowCount() !== 1) return false; + $ticket['assigned_staff_id'] = (int) $staff['id']; + $ticket['staff_name'] = $staff['name'] ?? null; + log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Beim Öffnen automatisch zugewiesen'); + return true; +} + +/** Arten der automatischen Zuweisung neuer Tickets einer Abteilung. */ +function department_auto_assign_modes(): array +{ + return ['none' => 'Nicht automatisch zuweisen', 'staff' => 'Immer an einen bestimmten Mitarbeiter', 'round_robin' => 'Reihum an die Mitarbeiter der Abteilung']; +} + +/** + * Wem ein neues Ticket dieser Abteilung laut Einstellung der Abteilung automatisch zugewiesen wird (Mitarbeiter-ID) oder null. + * - "staff": immer derselbe Mitarbeiter; ist er im Urlaub, geht es an seine Vertretung, ohne Vertretung bleibt das Ticket unzugewiesen + * - "round_robin": der Reihe nach an die Mitarbeiter der Abteilung (Hauptabteilung oder zusätzlich zugewiesen), die nicht im Urlaub und + * gerade erreichbar sind (nicht offline, in der Schicht); ist niemand erreichbar, bekommt der nächste aus der Reihe es trotzdem + * Fehlende Spalten (Datenbank noch nicht aktualisiert) und unbekannte Abteilungen ergeben null. + */ +function department_auto_assignee(PDO $pdo, int $departmentId): ?int +{ + try { + $s = $pdo->prepare('SELECT auto_assign_mode, auto_assign_staff_id, auto_assign_last_id FROM departments WHERE id = ?'); + $s->execute([$departmentId]); + $d = $s->fetch(); + } catch (Throwable $e) { + return null; + } + if (!$d) return null; + $mode = (string) ($d['auto_assign_mode'] ?? 'none'); + + if ($mode === 'staff') { + $id = (int) ($d['auto_assign_staff_id'] ?? 0); + if ($id <= 0) return null; + $st = $pdo->prepare('SELECT id, is_active, is_on_vacation, vacation_substitute_id FROM staff WHERE id = ?'); + $st->execute([$id]); + $row = $st->fetch(); + if (!$row || !(int) $row['is_active']) return null; + if (!(int) $row['is_on_vacation']) return $id; + $sub = (int) ($row['vacation_substitute_id'] ?? 0); + if ($sub > 0) { + $st->execute([$sub]); + $subRow = $st->fetch(); + if ($subRow && (int) $subRow['is_active'] && !(int) $subRow['is_on_vacation']) return $sub; + } + return null; + } + + if ($mode === 'round_robin') { + $c = $pdo->prepare( + 'SELECT id FROM staff WHERE is_active = 1 AND is_on_vacation = 0 + AND (department_id = ? OR id IN (SELECT staff_id FROM staff_department WHERE department_id = ?)) ORDER BY id' + ); + $c->execute([$departmentId, $departmentId]); + $candidates = array_map('intval', $c->fetchAll(PDO::FETCH_COLUMN)); + if (!$candidates) return null; + $available = array_values(array_filter($candidates, fn(int $id) => is_staff_available($pdo, $id))); + $pool = $available ?: $candidates; + $last = (int) ($d['auto_assign_last_id'] ?? 0); + $next = null; + foreach ($pool as $id) { if ($id > $last) { $next = $id; break; } } + $next ??= $pool[0]; + $pdo->prepare('UPDATE departments SET auto_assign_last_id = ? WHERE id = ?')->execute([$next, $departmentId]); + return $next; + } + + return null; +} + /** Löscht ein Ticket dauerhaft, protokolliert es vorher für Berichte */ function delete_ticket(PDO $pdo, array $ticket, ?int $deletedByStaffId): void { diff --git a/DeskHub-Webseite/includes/integrations.php b/DeskHub-Webseite/includes/integrations.php index 4fe8d2ff..d27f359b 100644 --- a/DeskHub-Webseite/includes/integrations.php +++ b/DeskHub-Webseite/includes/integrations.php @@ -112,8 +112,14 @@ function public_ticket_create(PDO $pdo, array $in, string $clientIp): array ->execute([$ticketId, 'customer', $message, $now]); log_activity($pdo, $ticketId, null, 'Ticket über die API erstellt'); - if ($ruleResult['assign_staff'] !== null) { - $pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$ruleResult['assign_staff'], $ticketId]); + $assignTo = $ruleResult['assign_staff']; + if ($assignTo === null) { + // Einstellung der Abteilung ("Neue Tickets automatisch zuweisen") + $assignTo = department_auto_assignee($pdo, (int) $deptId); + if ($assignTo !== null) log_activity($pdo, $ticketId, null, 'Automatisch zugewiesen (Einstellung der Abteilung)'); + } + if ($assignTo !== null) { + $pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$assignTo, $ticketId]); } foreach ($ruleResult['add_tags'] as $tagName) apply_tag_to_ticket($pdo, $ticketId, $tagName); apply_auto_reply($pdo, $ticketId, $ruleResult['auto_reply']); diff --git a/DeskHub-Webseite/includes/staff_activity.php b/DeskHub-Webseite/includes/staff_activity.php index 52571b41..aadd274a 100644 --- a/DeskHub-Webseite/includes/staff_activity.php +++ b/DeskHub-Webseite/includes/staff_activity.php @@ -89,7 +89,9 @@ function staff_is_user_navigation(): bool { if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET' && ($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') return false; $mode = $_SERVER['HTTP_SEC_FETCH_MODE'] ?? ''; - if ($mode !== '') return $mode === 'navigate'; + // Sec-Fetch-User (?1) sendet der Browser nur bei Aufrufen, die der Nutzer selbst ausgelöst hat (Klick, Adresszeile, F5, Formular), + // nicht bei Neuladen oder Weiterleitung per Skript (z. B. "neue Antwort, Seite aktualisieren") + if ($mode !== '') return $mode === 'navigate' && ($_SERVER['HTTP_SEC_FETCH_USER'] ?? '') === '?1'; // Ältere Browser ohne Sec-Fetch-Header: Seitenaufrufe erkennt man daran, dass kein X-Requested-With gesetzt ist und HTML erwartet wird return empty($_SERVER['HTTP_X_REQUESTED_WITH']) && str_contains((string) ($_SERVER['HTTP_ACCEPT'] ?? ''), 'text/html'); }