Upload via GUI (275 Dateien)
This commit is contained in:
@@ -303,10 +303,28 @@ Nach der Installation müsst ihr nichts tun, Option A läuft von selbst.
|
||||
|
||||
Jeder Mitarbeiter stellt seinen Status ein (Verfügbar, Beschäftigt, Pause, Offline), im Browser unter **Verfügbarkeit & Schichten** und in der App unter Verwaltung → Mein Status. **Wer 30 Minuten lang nichts tut, geht automatisch auf Offline** und bekommt seinen vorherigen Status zurück, sobald er wieder aktiv ist. Als aktiv zählen echte Bedienung (Seitenaufrufe, Klicks und Tipps, Aktionen und Berührungen in der App); Abfragen im Hintergrund halten niemanden online. Ein von Hand gesetzter Status wird nicht von selbst zurückgesetzt. Die Zeit stellt ein Admin unter Verfügbarkeit & Schichten ein (0 = aus). Admins können dort (und in der App durch Antippen eines Teammitglieds) den Status jedes Mitarbeiters ändern; das steht im Aktivitätsprotokoll. Der Live-Chat auf der Kundenseite zeigt automatisch Offline-Mitarbeiter nicht mehr als erreichbar.
|
||||
|
||||
## Automatische Abmeldung
|
||||
|
||||
Im Browser wird ein Mitarbeiter nach der eingestellten Zeit ohne Bedienung abgemeldet (Einstellungen → Allgemein → Sitzungs-Timeout, Standard 30 Minuten; oben rechts zeigt ein Zähler die Restzeit). **Als Bedienung zählen** Klicks, Tastatureingaben und Mausbewegungen auf der Seite sowie jeder vom Nutzer selbst ausgelöste Seitenaufruf. **Nicht als Bedienung zählen** die Abfragen, die eine offene Seite im Hintergrund stellt (Ticketliste, Chat, Anwesenheit, „Seite aktualisieren“ bei neuen Antworten): Eine unbeachtet offene Seite hält die Sitzung also nicht am Leben. Mehrere Tabs teilen sich die Zeit; der Zähler gleicht sich alle 30 Sekunden mit dem Server ab. Windows-Programm und Android-App melden sich über ihr eigenes Anmelde-Token an und sind davon nicht betroffen.
|
||||
|
||||
## Ticket-Sperre
|
||||
|
||||
Wer ein Ticket öffnet, sperrt es für andere (5 Minuten, bei Inaktivität läuft die Sperre ab), damit nicht zwei Personen gleichzeitig daran arbeiten. Normale Mitarbeiter bekommen bei einem gesperrten Ticket einen Hinweis mit dem Namen des Bearbeiters. **Administratoren** können ein gesperrtes Ticket trotzdem **lesen und bearbeiten** (Antworten, Notizen, Status) und sehen oben den Hinweis „wird gerade von … bearbeitet“ mit dem Knopf **„Sperre aufheben und übernehmen“**. Das gibt es im Browser, im Windows-Programm und in der Android-App; das Aufheben steht im Ticket-Verlauf. Jeder darf seine eigene Sperre freigeben. Ein Live-Chat sperrt Administratoren nie aus.
|
||||
|
||||
## Beim Öffnen automatisch zuweisen
|
||||
|
||||
Öffnet ein Mitarbeiter ein Ticket, das noch niemandem zugewiesen ist, wird es ihm zugewiesen (Browser, Windows-Programm und Android-App). Das gilt nicht für geschlossene, gelöste, zusammengeführte oder als Spam markierte Tickets, nicht für Mitarbeiter ohne Recht zum Antworten und nicht, wenn ein Admin ein von jemand anderem bearbeitetes Ticket nur ansieht. Ein bereits zugewiesenes Ticket bleibt beim Bearbeiter. Ausschalten: Einstellungen → Allgemein → „Beim Öffnen automatisch zuweisen“.
|
||||
|
||||
## Neue Tickets automatisch zuweisen
|
||||
|
||||
Im Formular einer Abteilung (Adminportal → Abteilungen → Bearbeiten, auch in der Android-App und im Windows-Programm) steht **„Neue Tickets automatisch zuweisen“**:
|
||||
|
||||
- **Nicht automatisch zuweisen:** wie bisher.
|
||||
- **Immer an einen bestimmten Mitarbeiter:** z. B. alle Tickets von „First Support“ an Lukas. Ist er im Urlaub, bekommt seine Vertretung das Ticket, ohne Vertretung bleibt es unzugewiesen.
|
||||
- **Reihum:** der Reihe nach an die Mitarbeiter der Abteilung (Hauptabteilung oder zusätzlich zugewiesen). Mitarbeiter im Urlaub, offline oder außerhalb ihrer Schicht werden übersprungen; ist niemand erreichbar, bekommt trotzdem der Nächste in der Reihe das Ticket.
|
||||
|
||||
Das gilt für Tickets aus Formular, E-Mail, API (z. B. das Minecraft-Plugin) und Live-Chat. Reihenfolge: eine **Ticket-Regel** mit „Mitarbeiter zuweisen“ hat Vorrang, dann die Einstellung der Abteilung, dann der letzte Bearbeiter des Kunden. Von Mitarbeitern von Hand angelegte Tickets bleiben so, wie der Mitarbeiter sie zugewiesen hat. Im Ticket-Verlauf steht „Automatisch zugewiesen (Einstellung der Abteilung)“.
|
||||
|
||||
## SLA-Pläne
|
||||
|
||||
Ein SLA-Plan legt fest, wie schnell reagiert und gelöst werden soll. Unter Adminportal → **SLA-Pläne** legt ihr Pläne mit Reaktions- und Lösungszeit an und weist sie Abteilungen oder Hilfethemen zu.
|
||||
|
||||
@@ -384,7 +384,12 @@ function chat_handle_start(PDO $pdo, ?array $user): never
|
||||
log_activity($pdo, $ticketId, null, 'Live-Chat gestartet');
|
||||
|
||||
$assignTo = $ruleResult['assign_staff'];
|
||||
if ($assignTo === null) {
|
||||
// Einstellung der Abteilung ("Neue Tickets automatisch zuweisen") vor dem letzten Bearbeiter
|
||||
$deptAssignee = $assignTo === null ? department_auto_assignee($pdo, (int) $deptId) : null;
|
||||
if ($deptAssignee !== null) {
|
||||
$assignTo = $deptAssignee;
|
||||
log_activity($pdo, $ticketId, null, 'Automatisch zugewiesen (Einstellung der Abteilung)');
|
||||
} elseif ($assignTo === null) {
|
||||
$prevStmt = $pdo->prepare(
|
||||
"SELECT assigned_staff_id FROM tickets
|
||||
WHERE LOWER(requester_email) = LOWER(?) AND assigned_staff_id IS NOT NULL
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft die automatische Abmeldung nach Inaktivität: Hintergrund-Abfragen der offenen Seiten verlängern die Sitzung nicht, nur echte Bedienung
|
||||
* (vom Nutzer ausgelöste Seitenaufrufe, Ping bei Klicks und Tastatur); abgelaufene Sitzungen führen zur Anmeldung bzw. antworten mit 401.
|
||||
*/
|
||||
|
||||
if (!extension_loaded('pdo_sqlite') || !function_exists('curl_init')) { echo "Aktivitäts-Test übersprungen: pdo_sqlite oder curl fehlt.\n"; exit(0); }
|
||||
|
||||
date_default_timezone_set('Europe/Berlin'); // wie in der Test-Installation
|
||||
$root = dirname(__DIR__);
|
||||
$work = sys_get_temp_dir() . '/deskhub-session-' . bin2hex(random_bytes(4));
|
||||
$tmp = $work . '/inst';
|
||||
mkdir($tmp, 0755, true);
|
||||
$webPort = random_int(34500, 34999);
|
||||
$skip = '#^(config/config\.php|database/[^/]+\.sqlite[^/]*|backups|uploads|tests|plugins/[^/]+|\.git)(/|$)#';
|
||||
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST) as $f) {
|
||||
$rel = str_replace(chr(92), '/', substr($f->getPathname(), strlen($root) + 1));
|
||||
if (preg_match($skip, $rel) && !preg_match('#^(backups|uploads|plugins)/(\.htaccess|README\.md)$#', $rel)) continue;
|
||||
if ($f->isDir()) { @mkdir($tmp . '/' . $rel, 0755, true); continue; }
|
||||
@mkdir(dirname($tmp . '/' . $rel), 0755, true);
|
||||
copy($f->getPathname(), $tmp . '/' . $rel);
|
||||
}
|
||||
@mkdir($tmp . '/uploads');
|
||||
@mkdir($tmp . '/config');
|
||||
file_put_contents($tmp . '/config/config.php', '<?php return ' . var_export([
|
||||
'app_key' => bin2hex(random_bytes(32)),
|
||||
'db' => ['driver' => 'sqlite', 'sqlite_path' => $tmp . '/database/deskhub.sqlite'],
|
||||
'site' => ['name' => 'Test', 'url' => 'http://127.0.0.1', 'admin_email' => 'a@test.de', 'timezone' => 'Europe/Berlin'],
|
||||
'uploads' => ['max_size_bytes' => 10485760, 'allowed_extensions' => ['png'], 'path' => $tmp . '/uploads'],
|
||||
], true) . ';');
|
||||
|
||||
putenv('DESKHUB_CONFIG_JSON');
|
||||
putenv('DESKHUB_SQLITE_PATH');
|
||||
putenv('DESKHUB_TESTING');
|
||||
putenv('DESKHUB_API_ALLOW_INSECURE=1');
|
||||
require_once $root . '/database/sqlite_schema.php';
|
||||
require_once $root . '/database/migrate.php';
|
||||
$db = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
|
||||
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
$db->exec('PRAGMA foreign_keys = ON');
|
||||
$db->exec(convert_schema_to_sqlite((string) file_get_contents($root . '/database/schema.sql')));
|
||||
$mig = run_migrations($db, true);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$dept = (int) $db->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
|
||||
$db->exec('DELETE FROM staff');
|
||||
$ins = $db->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)');
|
||||
$ids = [];
|
||||
foreach ([['Chef', 'chef@test.de', 'admin'], ['Agent', 'agent@test.de', 'agent'], ['Andere', 'andere@test.de', 'agent']] as [$n, $e, $r]) {
|
||||
$ins->execute([$n, $e, password_hash('Sehr-Sicher-1234', PASSWORD_DEFAULT), $r, $dept, $now]);
|
||||
$ids[$n] = (int) $db->lastInsertId();
|
||||
}
|
||||
$db = null;
|
||||
|
||||
$failures = [];
|
||||
$total = 0;
|
||||
$check = function (bool $ok, string $what) use (&$failures, &$total): void { $total++; if (!$ok) $failures[] = $what; };
|
||||
$webProc = proc_open([PHP_BINARY, '-S', "127.0.0.1:$webPort", '-t', $tmp], [['pipe', 'r'], ['file', $work . '/web.out', 'w'], ['file', $work . '/web.out', 'a']], $wp, $tmp);
|
||||
|
||||
try {
|
||||
for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $webPort)) break; usleep(100000); }
|
||||
$savePath = rtrim((string) (ini_get('session.save_path') ?: sys_get_temp_dir()), '/\\');
|
||||
if (str_contains($savePath, ';')) $savePath = substr($savePath, strrpos($savePath, ';') + 1);
|
||||
$call = function (string $method, string $path, string $jar, array $headers = [], array $form = []) use ($webPort): array {
|
||||
$ch = curl_init("http://127.0.0.1:$webPort$path");
|
||||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 60, CURLOPT_HTTPHEADER => $headers, CURLOPT_COOKIEJAR => $jar, CURLOPT_COOKIEFILE => $jar]);
|
||||
if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($form)); }
|
||||
$b = (string) curl_exec($ch);
|
||||
$s = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$loc = (string) curl_getinfo($ch, CURLINFO_REDIRECT_URL);
|
||||
curl_close($ch);
|
||||
return [$s, $b, $loc];
|
||||
};
|
||||
$USER_NAV = ['Sec-Fetch-Mode: navigate', 'Sec-Fetch-Dest: document', 'Sec-Fetch-User: ?1']; // Klick, Adresszeile, F5
|
||||
$SCRIPT_NAV = ['Sec-Fetch-Mode: navigate', 'Sec-Fetch-Dest: document']; // location.reload() ohne Nutzeraktion
|
||||
$POLL = ['Sec-Fetch-Mode: cors', 'Sec-Fetch-Dest: empty', 'Accept: */*']; // fetch() im Hintergrund
|
||||
$login = function () use ($call, $USER_NAV): string {
|
||||
$jar = tempnam(sys_get_temp_dir(), 'ses');
|
||||
[, $lp] = $call('GET', '/staff/login.php', $jar, $USER_NAV);
|
||||
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $lp, $m);
|
||||
$call('POST', '/staff/login.php', $jar, $USER_NAV, ['csrf_token' => $m[1] ?? '', 'email' => 'chef@test.de', 'password' => 'Sehr-Sicher-1234']);
|
||||
return $jar;
|
||||
};
|
||||
$sessionFile = function (string $jar) use ($savePath): string {
|
||||
preg_match('/PHPSESSID\s+([A-Za-z0-9,-]+)/', (string) file_get_contents($jar), $m);
|
||||
return $savePath . '/sess_' . ($m[1] ?? 'unbekannt');
|
||||
};
|
||||
$lastActivity = function (string $jar) use ($sessionFile): ?int {
|
||||
$f = $sessionFile($jar);
|
||||
return is_file($f) && preg_match('/staff_last_activity\|i:(\d+);/', (string) file_get_contents($f), $m) ? (int) $m[1] : null;
|
||||
};
|
||||
$setAge = function (string $jar, int $seconds) use ($sessionFile): bool {
|
||||
$f = $sessionFile($jar);
|
||||
if (!is_file($f)) return false;
|
||||
$c = (string) file_get_contents($f);
|
||||
$c = preg_replace('/staff_last_activity\|i:\d+;/', 'staff_last_activity|i:' . (time() - $seconds) . ';', $c, 1, $n);
|
||||
file_put_contents($f, $c);
|
||||
return $n === 1;
|
||||
};
|
||||
|
||||
$jar = $login();
|
||||
$check($lastActivity($jar) !== null && abs(time() - $lastActivity($jar)) < 5, 'Anmeldung setzt die Aktivitätszeit (Sitzungsdatei gefunden: ' . $sessionFile($jar) . ')');
|
||||
|
||||
// A: Hintergrund-Abrufe verlängern die Sitzung nicht
|
||||
$setAge($jar, 29 * 60);
|
||||
[$s] = $call('GET', '/staff/availability_status.php', $jar, $POLL);
|
||||
$age = time() - (int) $lastActivity($jar);
|
||||
$check($s === 200 && $age >= 29 * 60 - 3, 'Hintergrund-Abruf (Liste/Chat/Anwesenheit) verlängert die Sitzung nicht (Alter ' . $age . ' s, HTTP ' . $s . ')');
|
||||
// B: nach Ablauf bekommen Hintergrund-Abrufe 401 statt einer Weiterleitung
|
||||
$setAge($jar, 31 * 60);
|
||||
[$s, $body] = $call('GET', '/staff/availability_status.php', $jar, $POLL);
|
||||
$check($s === 401 && str_contains($body, 'sessionExpired'), 'Nach 31 Minuten ohne Bedienung antwortet ein Hintergrund-Abruf mit 401 (HTTP ' . $s . ')');
|
||||
[$s, , $loc] = $call('GET', '/staff/tickets.php', $jar, $USER_NAV);
|
||||
$check($s === 302 && str_contains($loc, 'login'), 'und der Mitarbeiter ist abgemeldet (Seitenaufruf führt zur Anmeldung)');
|
||||
|
||||
// C: abgelaufene Sitzung, Seitenaufruf
|
||||
$jar = $login();
|
||||
$setAge($jar, 31 * 60);
|
||||
[$s, , $loc] = $call('GET', '/staff/tickets.php', $jar, $USER_NAV);
|
||||
$check($s === 302 && str_contains($loc, 'login'), 'Seitenaufruf nach 31 Minuten führt zur Anmeldung (HTTP ' . $s . ')');
|
||||
[$s, $body] = $call('GET', '/staff/session_ping.php?check=1', $jar, $POLL);
|
||||
$check($s === 401, 'Die Abfrage der Restzeit meldet ebenfalls „abgelaufen“ (HTTP ' . $s . ')');
|
||||
|
||||
// D: echter Seitenaufruf (Klick/F5) verlängert, per Skript ausgelöstes Neuladen nicht
|
||||
$jar = $login();
|
||||
$setAge($jar, 29 * 60);
|
||||
[$s] = $call('GET', '/staff/tickets.php', $jar, $SCRIPT_NAV);
|
||||
$age = time() - (int) $lastActivity($jar);
|
||||
$check($s === 200 && $age >= 29 * 60 - 3, 'Vom Skript ausgelöstes Neuladen der Seite verlängert die Sitzung nicht (Alter ' . $age . ' s)');
|
||||
[$s] = $call('GET', '/staff/tickets.php', $jar, $USER_NAV);
|
||||
$age = time() - (int) $lastActivity($jar);
|
||||
$check($s === 200 && $age < 5, 'Ein vom Nutzer ausgelöster Seitenaufruf verlängert die Sitzung (Alter ' . $age . ' s)');
|
||||
|
||||
// E: Ping bei Klicks/Tastatur verlängert; die Abfrage der Restzeit nicht
|
||||
$setAge($jar, 29 * 60);
|
||||
[$s, $body] = $call('GET', '/staff/session_ping.php?check=1', $jar, $POLL);
|
||||
$d = json_decode($body, true);
|
||||
$age = time() - (int) $lastActivity($jar);
|
||||
$check($s === 200 && ($d['remaining'] ?? -1) > 0 && ($d['remaining'] ?? 9999) <= 62 && $age >= 29 * 60 - 3, 'Restzeit-Abfrage liefert ca. 60 s und verlängert nichts (Rest ' . json_encode($d['remaining'] ?? null) . ' s)');
|
||||
[$s, $body] = $call('POST', '/staff/session_ping.php', $jar, $POLL);
|
||||
$age = time() - (int) $lastActivity($jar);
|
||||
$check($s === 200 && $age < 5, 'Der Ping der Seite bei Klicks und Tastatur verlängert die Sitzung (Alter ' . $age . ' s)');
|
||||
[$s] = $call('GET', '/staff/session_ping.php', $jar, $POLL);
|
||||
$check($s === 405, 'Ohne ?check=1 ist GET weiterhin nicht erlaubt');
|
||||
@unlink($jar);
|
||||
|
||||
// F: Sitzungsdaten bleiben länger liegen als die Inaktivitätszeit
|
||||
file_put_contents($tmp . '/gc_probe.php', "<?php require __DIR__ . '/includes/bootstrap.php'; echo ini_get('session.gc_maxlifetime');");
|
||||
$pj = tempnam(sys_get_temp_dir(), 'ses');
|
||||
[, $gcBody] = $call('GET', '/gc_probe.php', $pj);
|
||||
$gc = (int) $gcBody;
|
||||
@unlink($pj);
|
||||
$check($gc >= 30 * 60 + 600, 'PHP räumt Sitzungsdaten erst nach der Inaktivitätszeit plus Reserve auf (gc_maxlifetime ' . $gc . ' s)');
|
||||
} finally {
|
||||
if (isset($webProc) && is_resource($webProc)) { proc_terminate($webProc); proc_close($webProc); }
|
||||
}
|
||||
|
||||
usleep(300000);
|
||||
$rm = function (string $d) use (&$rm) {
|
||||
foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") && !is_link("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); }
|
||||
@rmdir($d);
|
||||
};
|
||||
if (str_starts_with($work, sys_get_temp_dir() . '/deskhub-session-')) $rm($work);
|
||||
|
||||
if ($failures) {
|
||||
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
echo "Session timeout checks passed ({$total} Prüfungen).\n";
|
||||
@@ -163,6 +163,64 @@ try {
|
||||
$check($s === 302 && $lockedBy('T-LOCK-WEB') === null, 'Web: „Sperre aufheben und übernehmen“ funktioniert (HTTP ' . $s . ')');
|
||||
[$s, $page] = $call('GET', '/staff/ticket_view.php?n=T-LOCK-WEB', null, null, [], $jarChef, ['Sec-Fetch-Mode: navigate']);
|
||||
$check($s === 200 && (int) $lockedBy('T-LOCK-WEB') === $ids['Chef'], 'Web: danach besitzt der Admin die Sperre');
|
||||
// ── Mehrere Tabs: der CSRF-Token bleibt für die Sitzung gleich (kein "Token ungültig" nach dem Absenden in einem anderen Tab) ──
|
||||
$tokenOf = function (string $jar, string $path) use ($call): string {
|
||||
[, $p] = $call('GET', $path, null, null, [], $jar, ['Sec-Fetch-Mode: navigate']);
|
||||
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', is_string($p) ? $p : '', $mm);
|
||||
return $mm[1] ?? '';
|
||||
};
|
||||
$mkTicket('T-TAB-1');
|
||||
$mkTicket('T-TAB-2');
|
||||
$tab1 = $tokenOf($jarChef, '/staff/ticket_view.php?n=T-TAB-1');
|
||||
$tab2 = $tokenOf($jarChef, '/staff/ticket_view.php?n=T-TAB-2');
|
||||
$check($tab1 !== '' && $tab1 === $tab2, 'Zwei Tabs bekommen denselben CSRF-Token');
|
||||
[$s] = $call('POST', '/staff/ticket_view.php?n=T-TAB-1', null, null, ['csrf_token' => $tab1, 'action' => 'release_lock'], $jarChef);
|
||||
$check($s === 302, 'Absenden im ersten Tab klappt (HTTP ' . $s . ')');
|
||||
[$s] = $call('POST', '/staff/ticket_view.php?n=T-TAB-2', null, null, ['csrf_token' => $tab2, 'action' => 'release_lock'], $jarChef);
|
||||
$check($s === 302, 'Danach klappt auch das Absenden im zweiten Tab mit dem älteren Token (HTTP ' . $s . ')');
|
||||
[$s, $body] = $call('POST', '/staff/ticket_view.php?n=T-TAB-2', null, null, ['csrf_token' => 'falsch', 'action' => 'release_lock'], $jarChef);
|
||||
$check($s === 403, 'Ein falscher Token wird weiterhin abgelehnt (HTTP ' . $s . ')');
|
||||
[$s] = $call('POST', '/staff/ticket_view.php?n=T-TAB-2', null, null, ['action' => 'release_lock'], $jarChef);
|
||||
$check($s === 403, 'Ohne Token wird abgelehnt (HTTP ' . $s . ')');
|
||||
$jarNew = $webLogin('chef@test.de');
|
||||
$check($tokenOf($jarNew, '/staff/availability.php') !== $tab1, 'Nach einer neuen Anmeldung gibt es einen neuen Token');
|
||||
@unlink($jarNew);
|
||||
|
||||
// ── Beim Öffnen zuweisen ──
|
||||
$assignee = fn(string $number) => $pdo()->query("SELECT assigned_staff_id FROM tickets WHERE ticket_number = '$number'")->fetchColumn();
|
||||
$mkTicket('T-OPEN-1');
|
||||
[$s, $r] = $call('GET', '/api/staff/v1/ticket.php?number=T-OPEN-1', null, $agent);
|
||||
$check($s === 200 && (int) $assignee('T-OPEN-1') === $ids['Agent'] && ($r['ticket']['assignedStaff']['name'] ?? '') === 'Agent', 'Öffnen eines unzugewiesenen Tickets weist es dem Öffnenden zu (App/API) (HTTP ' . $s . ')');
|
||||
$logged = $pdo()->query("SELECT COUNT(*) FROM activity_log a JOIN tickets t ON t.id = a.ticket_id WHERE t.ticket_number = 'T-OPEN-1' AND a.action = 'Beim Öffnen automatisch zugewiesen'")->fetchColumn();
|
||||
$check((int) $logged === 1, 'Das steht im Ticket-Verlauf');
|
||||
$call('POST', '/api/staff/v1/ticket_unlock.php', ['number' => 'T-OPEN-1'], $agent);
|
||||
[$s] = $call('GET', '/api/staff/v1/ticket.php?number=T-OPEN-1', null, $other);
|
||||
$check($s === 200 && (int) $assignee('T-OPEN-1') === $ids['Agent'], 'Ein bereits zugewiesenes Ticket bleibt beim Bearbeiter, auch wenn ein anderer es öffnet');
|
||||
$call('POST', '/api/staff/v1/ticket_unlock.php', ['number' => 'T-OPEN-1'], $other);
|
||||
$mkTicket('T-OPEN-2');
|
||||
$pdo()->exec("UPDATE tickets SET status = 'closed' WHERE ticket_number = 'T-OPEN-2'");
|
||||
$call('GET', '/api/staff/v1/ticket.php?number=T-OPEN-2', null, $agent);
|
||||
$check($assignee('T-OPEN-2') === null, 'Geschlossene Tickets werden beim Öffnen nicht zugewiesen');
|
||||
$mkTicket('T-OPEN-3');
|
||||
$call('GET', '/api/staff/v1/ticket.php?number=T-OPEN-3', null, $agent); // Agent hält die Sperre und ist zugewiesen
|
||||
$pdo()->exec("UPDATE tickets SET assigned_staff_id = NULL WHERE ticket_number = 'T-OPEN-3'");
|
||||
$call('GET', '/api/staff/v1/ticket.php?number=T-OPEN-3', null, $chef); // Admin schaut nur, die Sperre des Agenten besteht
|
||||
$check($assignee('T-OPEN-3') === null, 'Ein Admin, der ein fremd gesperrtes Ticket nur ansieht, bekommt es nicht zugewiesen');
|
||||
$pdo()->exec("INSERT OR REPLACE INTO settings (setting_key, setting_value) VALUES ('auto_assign_on_open', '0')");
|
||||
$mkTicket('T-OPEN-4');
|
||||
$call('GET', '/api/staff/v1/ticket.php?number=T-OPEN-4', null, $other);
|
||||
$check($assignee('T-OPEN-4') === null, 'Ausgeschaltet in den Einstellungen: keine Zuweisung beim Öffnen');
|
||||
$pdo()->exec("DELETE FROM settings WHERE setting_key = 'auto_assign_on_open'");
|
||||
// Weboberfläche
|
||||
$mkTicket('T-OPEN-5');
|
||||
[$s] = $call('GET', '/staff/ticket_view.php?n=T-OPEN-5', null, null, [], $jarChef, ['Sec-Fetch-Mode: navigate']);
|
||||
$check($s === 200 && (int) $assignee('T-OPEN-5') === $ids['Chef'], 'Auch im Browser: Öffnen weist das Ticket zu (HTTP ' . $s . ')');
|
||||
|
||||
// Ticketliste: Spalte mit dem Eingangsdatum
|
||||
[$s, $list] = $call('GET', '/staff/tickets.php', null, null, [], $jarChef, ['Sec-Fetch-Mode: navigate']);
|
||||
$list = is_string($list) ? $list : '';
|
||||
$check($s === 200 && str_contains($list, '<th>Eingang</th>') && str_contains($list, date('d.m.Y ')), 'Ticketliste zeigt die Spalte „Eingang“ mit Datum und Uhrzeit (HTTP ' . $s . ', ' . strlen($list) . ' Zeichen, Tabelle: ' . (str_contains($list, '<table') ? 'ja' : 'nein') . ', leer: ' . (str_contains($list, 'Keine Tickets') ? 'ja' : 'nein') . ')');
|
||||
|
||||
@unlink($jarChef);
|
||||
@unlink($jarOther);
|
||||
} finally {
|
||||
|
||||
@@ -188,7 +188,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
|
||||
// Verbleibende Regel-Aktionen, die eine vorhandene Ticket-ID benötigen
|
||||
$assignTo = $ruleResult['assign_staff'];
|
||||
if ($assignTo === null) {
|
||||
// Einstellung der Abteilung ("Neue Tickets automatisch zuweisen"): Ticket-Regel hat Vorrang, dann die Abteilung, dann der letzte Bearbeiter
|
||||
$deptAssignee = $assignTo === null ? department_auto_assignee($pdo, (int) $deptId) : null;
|
||||
if ($deptAssignee !== null) {
|
||||
$assignTo = $deptAssignee;
|
||||
log_activity($pdo, $ticketId, null, 'Automatisch zugewiesen (Einstellung der Abteilung)');
|
||||
} elseif ($assignTo === null) {
|
||||
// Auto-Zuweisung: war der Kunde schon mal einem Mitarbeiter zugewiesen?
|
||||
$prevStmt = $pdo->prepare(
|
||||
"SELECT assigned_staff_id FROM tickets
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
if (defined('DESKHUB_VERSION')) return;
|
||||
|
||||
define('DESKHUB_VERSION', '1.0.12');
|
||||
define('DESKHUB_VERSION', '1.0.17');
|
||||
define('DESKHUB_RELEASE_URL', 'https://git.viper.ipv64.net/M_Viper/deskhub/releases');
|
||||
|
||||
// Kein eingebautes Zugangstoken: Das Update liest die öffentlichen Releases. Ist das Repository privat, kann ein Token
|
||||
|
||||
Reference in New Issue
Block a user