321 lines
18 KiB
PHP
321 lines
18 KiB
PHP
<?php
|
|
/**
|
|
* Gemeinsame Verwaltungslogik für Abteilungen, Hilfethemen, Teams und SLA-Pläne.
|
|
* Wird von den Weboberflächen (staff/*.php) und der API (api/staff/v1/*.php) genutzt,
|
|
* damit beide exakt dieselben Regeln anwenden.
|
|
*/
|
|
|
|
class AdminModelException extends RuntimeException
|
|
{
|
|
public function __construct(string $message, private string $errorCode = 'INVALID', private int $httpStatus = 422)
|
|
{
|
|
parent::__construct($message);
|
|
}
|
|
public function errorCode(): string { return $this->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]);
|
|
}
|
|
// 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;
|
|
}
|
|
|
|
/** 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;
|
|
}
|