errorCode; } public function httpStatus(): int { return $this->httpStatus; } } /** Wert aus $in als int oder null (leer/nicht gesetzt = null). */ function am_int_or_null(array $in, string $key): ?int { return isset($in[$key]) && $in[$key] !== '' && $in[$key] !== null ? (int) $in[$key] : null; } /** Wirft, wenn in $table keine Zeile mit der id existiert. */ function am_require_row(PDO $pdo, string $table, ?int $id, string $message, string $code): void { if ($id === null) return; $stmt = $pdo->prepare("SELECT COUNT(*) FROM {$table} WHERE id = ?"); $stmt->execute([$id]); if (!(int) $stmt->fetchColumn()) throw new AdminModelException($message, $code); } /** Bisheriger Wert einer Spalte (für „nicht angegeben = unverändert“ bei älteren App-Versionen). */ function am_current(PDO $pdo, string $table, int $id, string $column, $default) { if (!$id) return $default; $stmt = $pdo->prepare("SELECT {$column} FROM {$table} WHERE id = ?"); $stmt->execute([$id]); $v = $stmt->fetchColumn(); return $v === false || $v === null ? $default : $v; } // ── Abteilungen ───────────────────────────────────────────────────────────── /** * Legt eine Abteilung an oder aktualisiert sie. * Schlüssel in $in: name, email, is_default, is_active, manager_id, sla_plan_id, notes, signature. * Fehlt is_active, bleibt der bisherige Status erhalten. */ function department_save(PDO $pdo, int $id, array $in): int { $name = trim((string) ($in['name'] ?? '')); $email = trim((string) ($in['email'] ?? '')); $isDefault = !empty($in['is_default']) ? 1 : 0; $isActive = array_key_exists('is_active', $in) ? (!empty($in['is_active']) ? 1 : 0) : (int) am_current($pdo, 'departments', $id, 'is_active', 1); $managerId = am_int_or_null($in, 'manager_id'); $slaPlanId = array_key_exists('sla_plan_id', $in) ? am_int_or_null($in, 'sla_plan_id') : ((int) am_current($pdo, 'departments', $id, 'sla_plan_id', 0) ?: null); $notes = trim((string) ($in['notes'] ?? '')); $signature = sanitize_staff_html((string) ($in['signature'] ?? '')); if ($name === '') throw new AdminModelException('Bitte einen Namen angeben.', 'NAME_REQUIRED'); if ($managerId !== null) { $stmt = $pdo->prepare('SELECT COUNT(*) FROM staff WHERE id = ? AND is_active = 1'); $stmt->execute([$managerId]); if (!(int) $stmt->fetchColumn()) throw new AdminModelException('Der gewählte Abteilungsleiter existiert nicht oder ist deaktiviert.', 'INVALID_MANAGER'); } am_require_row($pdo, 'sla_plans', $slaPlanId, 'Der gewählte SLA-Plan existiert nicht.', 'INVALID_SLA_PLAN'); if ($isDefault && !$isActive) throw new AdminModelException('Die Standardabteilung muss aktiv sein.', 'DEFAULT_INACTIVE'); // E-Mail ist optional: ohne Angabe die Standardadresse der Abteilung (bzw. erste zugeordnete Adresse) if ($email === '') { $fb = $pdo->prepare('SELECT email FROM email_addresses WHERE department_id = ? ORDER BY is_default DESC, id LIMIT 1'); $fb->execute([$id]); $email = (string) ($fb->fetchColumn() ?: ''); } elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) { throw new AdminModelException('Bitte eine gültige E-Mail-Adresse wählen.', 'INVALID_EMAIL'); } if ($isDefault) $pdo->exec('UPDATE departments SET is_default = 0'); $now = db_now(); if ($id === 0) { $pdo->prepare('INSERT INTO departments (name, email, is_default, is_active, manager_id, sla_plan_id, notes, signature, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)') ->execute([$name, $email, $isDefault, $isActive, $managerId, $slaPlanId, $notes ?: null, $signature ?: null, $now, $now]); $id = (int) $pdo->lastInsertId(); } else { $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). */ function am_reference_count(PDO $pdo, string $table, string $column, int $id): int { try { $stmt = $pdo->prepare("SELECT COUNT(*) FROM {$table} WHERE {$column} = ?"); $stmt->execute([$id]); return (int) $stmt->fetchColumn(); } catch (Throwable $e) { return 0; } } /** * Löscht eine Abteilung. Wird sie noch gebraucht, gibt es eine verständliche Meldung statt eines Absturzes: * Standardabteilung, letzte Abteilung und Abteilungen mit Tickets, Hilfethemen, E-Mail-Adressen, Postfächern, wiederkehrenden * Tickets, Hauptabteilungs-Mitarbeitern oder Eskalationsregeln lassen sich nicht löschen. Reine Zuordnungen (zusätzliche * Mitgliedschaften, Textbausteine) werden aufgeräumt. */ function department_delete(PDO $pdo, int $id): string { $stmt = $pdo->prepare('SELECT * FROM departments WHERE id = ?'); $stmt->execute([$id]); $dept = $stmt->fetch(); if (!$dept) throw new AdminModelException('Die Abteilung existiert nicht (mehr).', 'NOT_FOUND', 404); $name = (string) $dept['name']; if (!empty($dept['is_default'])) throw new AdminModelException('Die Standardabteilung kann nicht gelöscht werden. Bitte zuerst eine andere Abteilung als Standard festlegen.', 'DEPARTMENT_IS_DEFAULT'); if ((int) $pdo->query('SELECT COUNT(*) FROM departments')->fetchColumn() <= 1) throw new AdminModelException('Die letzte Abteilung kann nicht gelöscht werden.', 'LAST_DEPARTMENT'); $inUse = []; $labels = [ ['tickets', 'department_id', 'Tickets'], ['help_topics', 'department_id', 'Hilfethemen'], ['email_addresses', 'department_id', 'E-Mail-Adressen'], ['imap_accounts', 'department_id', 'Postfächer (Abruf)'], ['recurring_tickets', 'department_id', 'wiederkehrende Tickets'], ['staff', 'department_id', 'Mitarbeiter mit dieser Hauptabteilung'], ['escalation_rules', 'condition_dept_id', 'Eskalationsregeln'], ]; foreach ($labels as [$table, $column, $label]) { $n = am_reference_count($pdo, $table, $column, $id); if ($n > 0) $inUse[] = "{$n} {$label}"; } if ($inUse) { throw new AdminModelException('Diese Abteilung kann nicht gelöscht werden, sie wird noch verwendet von: ' . implode(', ', $inUse) . '. Bitte zuerst anderen Abteilungen zuordnen oder entfernen.', 'DEPARTMENT_IN_USE'); } try { $pdo->prepare('DELETE FROM staff_department WHERE department_id = ?')->execute([$id]); $pdo->prepare('UPDATE canned_responses SET department_id = NULL WHERE department_id = ?')->execute([$id]); $pdo->prepare('DELETE FROM departments WHERE id = ?')->execute([$id]); } catch (PDOException $e) { error_log('department_delete: ' . $e->getMessage()); throw new AdminModelException('Die Abteilung wird noch verwendet und kann nicht gelöscht werden.', 'DEPARTMENT_IN_USE'); } return $name; } // ── Hilfethemen ───────────────────────────────────────────────────────────── /** Alle Nachfahren eines Hilfethemas (um Zyklen beim Elternthema zu verhindern). */ function help_topic_descendants(PDO $pdo, int $id): array { $found = []; $queue = [$id]; $stmt = $pdo->prepare('SELECT id FROM help_topics WHERE parent_id = ?'); while ($queue) { $stmt->execute([array_shift($queue)]); foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $child) { if (!in_array((int) $child, $found, true)) { $found[] = (int) $child; $queue[] = (int) $child; } } } return $found; } /** * Legt ein Hilfethema an oder aktualisiert es. * Schlüssel in $in: name, department_id, priority_id, is_active, is_public, parent_id, sla_plan_id, notes, sort_order. */ function help_topic_save(PDO $pdo, int $id, array $in): int { $name = trim((string) ($in['name'] ?? '')); $deptId = (int) ($in['department_id'] ?? 0); $prioId = (int) ($in['priority_id'] ?? 0); $isActive = array_key_exists('is_active', $in) ? (!empty($in['is_active']) ? 1 : 0) : (int) am_current($pdo, 'help_topics', $id, 'is_active', 1); $isPublic = array_key_exists('is_public', $in) ? (!empty($in['is_public']) ? 1 : 0) : (int) am_current($pdo, 'help_topics', $id, 'is_public', 1); $parentId = array_key_exists('parent_id', $in) ? am_int_or_null($in, 'parent_id') : ((int) am_current($pdo, 'help_topics', $id, 'parent_id', 0) ?: null); $slaPlanId = array_key_exists('sla_plan_id', $in) ? am_int_or_null($in, 'sla_plan_id') : ((int) am_current($pdo, 'help_topics', $id, 'sla_plan_id', 0) ?: null); $notes = array_key_exists('notes', $in) ? trim((string) $in['notes']) : (string) am_current($pdo, 'help_topics', $id, 'notes', ''); $sort = (int) ($in['sort_order'] ?? 0); if ($name === '') throw new AdminModelException('Bitte ein Thema angeben.', 'NAME_REQUIRED'); am_require_row($pdo, 'departments', $deptId ?: null, 'Bitte eine gültige Abteilung wählen.', 'INVALID_DEPARTMENT'); if (!$deptId) throw new AdminModelException('Bitte eine gültige Abteilung wählen.', 'INVALID_DEPARTMENT'); am_require_row($pdo, 'priorities', $prioId ?: null, 'Bitte eine gültige Priorität wählen.', 'INVALID_PRIORITY'); if (!$prioId) throw new AdminModelException('Bitte eine gültige Priorität wählen.', 'INVALID_PRIORITY'); am_require_row($pdo, 'sla_plans', $slaPlanId, 'Der gewählte SLA-Plan existiert nicht.', 'INVALID_SLA_PLAN'); if ($parentId !== null) { am_require_row($pdo, 'help_topics', $parentId, 'Das übergeordnete Thema existiert nicht.', 'INVALID_PARENT'); if ($id && ($parentId === $id || in_array($parentId, help_topic_descendants($pdo, $id), true))) { throw new AdminModelException('Ein Thema kann nicht sich selbst oder einem eigenen Unterthema untergeordnet werden.', 'INVALID_PARENT'); } } $now = db_now(); if ($id === 0) { $pdo->prepare('INSERT INTO help_topics (name, department_id, priority_id, is_active, is_public, parent_id, sla_plan_id, notes, sort_order, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)') ->execute([$name, $deptId, $prioId, $isActive, $isPublic, $parentId, $slaPlanId, $notes ?: null, $sort, $now, $now]); return (int) $pdo->lastInsertId(); } $pdo->prepare('UPDATE help_topics SET name=?, department_id=?, priority_id=?, is_active=?, is_public=?, parent_id=?, sla_plan_id=?, notes=?, sort_order=?, updated_at=? WHERE id=?') ->execute([$name, $deptId, $prioId, $isActive, $isPublic, $parentId, $slaPlanId, $notes ?: null, $sort, $now, $id]); return $id; } /** Löscht ein Hilfethema; Tickets verlieren die Zuordnung, Unterthemen rücken eine Ebene nach oben. Gibt den Namen zurück. */ function help_topic_delete(PDO $pdo, int $id): string { $name = (string) am_current($pdo, 'help_topics', $id, 'name', (string) $id); $pdo->prepare('UPDATE tickets SET help_topic_id = NULL WHERE help_topic_id = ?')->execute([$id]); $parent = am_current($pdo, 'help_topics', $id, 'parent_id', null); $pdo->prepare('UPDATE help_topics SET parent_id = ? WHERE parent_id = ?')->execute([$parent ?: null, $id]); try { $pdo->prepare('DELETE FROM help_topics WHERE id = ?')->execute([$id]); } catch (PDOException $e) { error_log('help_topic_delete: ' . $e->getMessage()); throw new AdminModelException('Das Hilfethema wird noch verwendet und kann nicht gelöscht werden.', 'TOPIC_IN_USE'); } return $name; } // ── Teams ─────────────────────────────────────────────────────────────────── /** * Legt ein Team an oder aktualisiert es. * Schlüssel in $in: name, lead_id, notes, is_active, no_alerts, members (Array von Mitarbeiter-IDs). * Fehlen is_active/no_alerts, bleiben die bisherigen Werte erhalten. */ function team_save(PDO $pdo, int $id, array $in): int { $name = trim((string) ($in['name'] ?? '')); $leadId = am_int_or_null($in, 'lead_id'); $notes = trim((string) ($in['notes'] ?? '')); $isActive = array_key_exists('is_active', $in) ? (!empty($in['is_active']) ? 1 : 0) : (int) am_current($pdo, 'teams', $id, 'is_active', 1); $noAlerts = array_key_exists('no_alerts', $in) ? (!empty($in['no_alerts']) ? 1 : 0) : (int) am_current($pdo, 'teams', $id, 'no_alerts', 0); $members = array_values(array_unique(array_filter(array_map('intval', (array) ($in['members'] ?? []))))); if ($name === '') throw new AdminModelException('Bitte einen Teamnamen angeben.', 'NAME_REQUIRED'); if ($leadId !== null) { $stmt = $pdo->prepare('SELECT COUNT(*) FROM staff WHERE id = ? AND is_active = 1'); $stmt->execute([$leadId]); if (!(int) $stmt->fetchColumn()) throw new AdminModelException('Der gewählte Teamleiter existiert nicht oder ist deaktiviert.', 'INVALID_LEAD'); } $dup = $pdo->prepare('SELECT COUNT(*) FROM teams WHERE LOWER(name) = LOWER(?) AND id <> ?'); $dup->execute([$name, $id]); if ((int) $dup->fetchColumn() > 0) throw new AdminModelException('Ein Team mit diesem Namen existiert bereits.', 'NAME_TAKEN'); $now = db_now(); if ($id === 0) { $pdo->prepare('INSERT INTO teams (name, lead_id, notes, is_active, no_alerts, created_at, updated_at) VALUES (?,?,?,?,?,?,?)') ->execute([$name, $leadId, $notes ?: null, $isActive, $noAlerts, $now, $now]); $id = (int) $pdo->lastInsertId(); } else { $pdo->prepare('UPDATE teams SET name=?, lead_id=?, notes=?, is_active=?, no_alerts=?, updated_at=? WHERE id=?') ->execute([$name, $leadId, $notes ?: null, $isActive, $noAlerts, $now, $id]); } $pdo->prepare('DELETE FROM team_members WHERE team_id = ?')->execute([$id]); $ins = $pdo->prepare('INSERT INTO team_members (team_id, staff_id) VALUES (?,?)'); foreach ($members as $sid) $ins->execute([$id, $sid]); return $id; } /** Löscht ein Team; zugewiesene Tickets verlieren die Team-Zuordnung. Gibt den Namen zurück. */ function team_delete(PDO $pdo, int $id): string { $name = (string) am_current($pdo, 'teams', $id, 'name', (string) $id); $pdo->prepare('UPDATE tickets SET team_id = NULL WHERE team_id = ?')->execute([$id]); try { $pdo->prepare('DELETE FROM teams WHERE id = ?')->execute([$id]); } catch (PDOException $e) { error_log('team_delete: ' . $e->getMessage()); throw new AdminModelException('Das Team wird noch verwendet und kann nicht gelöscht werden.', 'TEAM_IN_USE'); } return $name; } // ── SLA-Pläne ─────────────────────────────────────────────────────────────── /** Legt einen SLA-Plan an oder aktualisiert ihn. Schlüssel: name, response_hours, resolution_hours, is_active, notes. */ function sla_plan_save(PDO $pdo, int $id, array $in): int { $name = trim((string) ($in['name'] ?? '')); $response = (int) ($in['response_hours'] ?? 0); $resolution = (int) ($in['resolution_hours'] ?? 0); $isActive = array_key_exists('is_active', $in) ? (!empty($in['is_active']) ? 1 : 0) : (int) am_current($pdo, 'sla_plans', $id, 'is_active', 1); $notes = trim((string) ($in['notes'] ?? '')); if ($name === '') throw new AdminModelException('Bitte einen Namen angeben.', 'NAME_REQUIRED'); if ($response <= 0 || $resolution <= 0) throw new AdminModelException('Bitte gültige Stunden angeben (größer als 0).', 'INVALID_HOURS'); if ($response > $resolution) throw new AdminModelException('Die Reaktionszeit darf nicht länger als die Lösungszeit sein.', 'INVALID_HOURS'); $dup = $pdo->prepare('SELECT COUNT(*) FROM sla_plans WHERE LOWER(name) = LOWER(?) AND id <> ?'); $dup->execute([$name, $id]); if ((int) $dup->fetchColumn() > 0) throw new AdminModelException('Ein SLA-Plan mit diesem Namen existiert bereits.', 'NAME_TAKEN'); $now = db_now(); if ($id === 0) { $pdo->prepare('INSERT INTO sla_plans (name, response_hours, resolution_hours, is_active, notes, created_at, updated_at) VALUES (?,?,?,?,?,?,?)') ->execute([$name, $response, $resolution, $isActive, $notes ?: null, $now, $now]); return (int) $pdo->lastInsertId(); } $pdo->prepare('UPDATE sla_plans SET name=?, response_hours=?, resolution_hours=?, is_active=?, notes=?, updated_at=? WHERE id=?') ->execute([$name, $response, $resolution, $isActive, $notes ?: null, $now, $id]); return $id; } /** Löscht einen SLA-Plan; Abteilungen und Themen fallen auf die Zeiten der Priorität zurück. Gibt den Namen zurück. */ function sla_plan_delete(PDO $pdo, int $id): string { $name = (string) am_current($pdo, 'sla_plans', $id, 'name', (string) $id); $pdo->prepare('UPDATE departments SET sla_plan_id = NULL WHERE sla_plan_id = ?')->execute([$id]); $pdo->prepare('UPDATE help_topics SET sla_plan_id = NULL WHERE sla_plan_id = ?')->execute([$id]); try { $pdo->prepare('DELETE FROM sla_plans WHERE id = ?')->execute([$id]); } catch (PDOException $e) { error_log('sla_plan_delete: ' . $e->getMessage()); throw new AdminModelException('Der SLA-Plan wird noch verwendet und kann nicht gelöscht werden.', 'SLA_PLAN_IN_USE'); } return $name; }