categoryRepo = $categoryRepo; $this->changeLogRepo = $changeLogRepo; } public function listForAdmin(string $search = '', string $state = 'active'): array { return $this->categoryRepo->listForAdmin($search, $state); } public function getById(int $categoryId): ?array { return $this->categoryRepo->findById($categoryId); } public function upsert(int $categoryId, string $name, int $actorId, ?int $activeOverride = null): array { $name = $this->normalizeName($name); if ($categoryId > 0) { return $this->updateCategory($categoryId, $name, $actorId, $activeOverride); } return $this->createCategory($name, $actorId); } public function setActive(int $categoryId, int $active, int $actorId): array { $active = $active === 1 ? 1 : 0; $category = $this->categoryRepo->findById($categoryId); if ($category === null) { throw new RuntimeException('Kategorie nicht gefunden.'); } if ($active === 0 && $this->isProtectedCategory($category)) { throw new RuntimeException('Die Standardkategorie "Allgemein" kann nicht deaktiviert werden.'); } if ((int) ($category['active'] ?? 0) === $active) { return [ 'category' => $this->snapshot($category), 'changed' => false, 'action' => $active === 1 ? 'category_activate' : 'category_deactivate', ]; } TaskCertDb::beginTransaction(); try { $before = $this->snapshot($category); $this->categoryRepo->setActive($categoryId, $active); $afterCategory = $this->categoryRepo->findById($categoryId); if ($afterCategory === null) { throw new RuntimeException('Kategorie konnte nicht geladen werden.'); } $after = $this->snapshot($afterCategory); $action = $active === 1 ? 'category_activate' : 'category_deactivate'; $this->changeLogRepo->create( $categoryId, $action, $actorId, $before, $after, [ 'field_changes' => $this->buildFieldDiff($before, $after), ] ); TaskCertDb::commit(); return [ 'category' => $after, 'changed' => true, 'action' => $action, ]; } catch (Throwable $throwable) { TaskCertDb::rollback(); throw $throwable; } } public function deleteCategory(int $categoryId, int $actorId): array { $category = $this->categoryRepo->findById($categoryId); if ($category === null) { throw new RuntimeException('Kategorie nicht gefunden.'); } if ($this->isProtectedCategory($category)) { throw new RuntimeException('Die Standardkategorie "Allgemein" kann nicht gelöscht werden.'); } $taskCount = $this->categoryRepo->countTasks($categoryId); if ($taskCount > 0) { throw new RuntimeException('Kategorie kann nicht gelöscht werden, solange Aufgaben zugeordnet sind.'); } TaskCertDb::beginTransaction(); try { $before = $this->snapshot($category); $deleted = $this->categoryRepo->delete($categoryId); if ($deleted <= 0) { throw new RuntimeException('Kategorie konnte nicht gelöscht werden.'); } $this->changeLogRepo->create( $categoryId, 'category_delete', $actorId, $before, null, [ 'deleted' => true, 'task_count' => $taskCount, ] ); TaskCertDb::commit(); return [ 'deleted' => true, 'category_id' => $categoryId, 'task_count' => $taskCount, 'before' => $before, ]; } catch (Throwable $throwable) { TaskCertDb::rollback(); throw $throwable; } } public function ensureCategoryUsableForTask(int $categoryId, ?int $currentTaskCategoryId = null): array { if ($categoryId <= 0) { throw new InvalidArgumentException('Bitte Kategorie auswählen.'); } $category = $this->categoryRepo->findById($categoryId); if ($category === null) { throw new InvalidArgumentException('Ausgewählte Kategorie existiert nicht.'); } $isActive = (int) ($category['active'] ?? 0) === 1; if ($isActive) { return $category; } if ($currentTaskCategoryId !== null && $currentTaskCategoryId > 0 && $currentTaskCategoryId === $categoryId) { return $category; } throw new InvalidArgumentException('Ausgewählte Kategorie ist inaktiv und kann nicht neu zugewiesen werden.'); } private function createCategory(string $name, int $actorId): array { $this->assertUniqueName($name, null); $slug = $this->createUniqueSlug($name); $sortOrder = $this->categoryRepo->nextSortOrder(10); TaskCertDb::beginTransaction(); try { $categoryId = $this->categoryRepo->create([ 'name' => $name, 'slug' => $slug, 'sort_order' => $sortOrder, 'active' => 1, ]); $created = $this->categoryRepo->findById($categoryId); if ($created === null) { throw new RuntimeException('Kategorie konnte nicht geladen werden.'); } $after = $this->snapshot($created); $this->changeLogRepo->create( $categoryId, 'category_create', $actorId, null, $after, [ 'created' => true, ] ); TaskCertDb::commit(); return [ 'category' => $after, 'created' => true, 'action' => 'category_create', ]; } catch (Throwable $throwable) { TaskCertDb::rollback(); throw $throwable; } } private function updateCategory(int $categoryId, string $name, int $actorId, ?int $activeOverride = null): array { $current = $this->categoryRepo->findById($categoryId); if ($current === null) { throw new RuntimeException('Kategorie nicht gefunden.'); } $this->assertUniqueName($name, $categoryId); if ($activeOverride !== null && !in_array($activeOverride, [0, 1], true)) { throw new InvalidArgumentException('Ungültiger Statuswert.'); } if ($activeOverride === 0 && $this->isProtectedCategory($current)) { throw new RuntimeException('Die Standardkategorie "Allgemein" kann nicht deaktiviert werden.'); } $before = $this->snapshot($current); $target = [ 'id' => (int) ($current['id'] ?? 0), 'name' => $name, 'slug' => (string) ($current['slug'] ?? ''), 'sort_order' => (int) ($current['sort_order'] ?? 0), 'active' => $activeOverride !== null ? $activeOverride : (int) ($current['active'] ?? 1), ]; if ( $before['name'] === $target['name'] && $before['sort_order'] === $target['sort_order'] && $before['active'] === $target['active'] ) { return [ 'category' => $before, 'created' => false, 'changed' => false, 'action' => 'category_update', ]; } TaskCertDb::beginTransaction(); try { $this->categoryRepo->update($categoryId, [ 'name' => $target['name'], 'sort_order' => $target['sort_order'], 'active' => $target['active'], ]); $updated = $this->categoryRepo->findById($categoryId); if ($updated === null) { throw new RuntimeException('Kategorie konnte nicht geladen werden.'); } $after = $this->snapshot($updated); $fieldChanges = $this->buildFieldDiff($before, $after); $this->changeLogRepo->create( $categoryId, 'category_update', $actorId, $before, $after, [ 'field_changes' => $fieldChanges, ] ); TaskCertDb::commit(); return [ 'category' => $after, 'created' => false, 'changed' => true, 'action' => 'category_update', ]; } catch (Throwable $throwable) { TaskCertDb::rollback(); throw $throwable; } } private function normalizeName(string $name): string { $name = trim(preg_replace('/\s+/', ' ', $name) ?? ''); if ($name === '') { throw new InvalidArgumentException('Name ist erforderlich.'); } if (function_exists('mb_strlen')) { if (mb_strlen($name) > 120) { throw new InvalidArgumentException('Name darf maximal 120 Zeichen lang sein.'); } } elseif (strlen($name) > 120) { throw new InvalidArgumentException('Name darf maximal 120 Zeichen lang sein.'); } return $name; } private function assertUniqueName(string $name, ?int $ignoreCategoryId): void { $normalized = $this->normalizeNameForCompare($name); $existing = $this->categoryRepo->findByNormalizedName($normalized); if ($existing === null) { return; } $existingId = (int) ($existing['id'] ?? 0); if ($ignoreCategoryId !== null && $existingId === $ignoreCategoryId) { return; } throw new InvalidArgumentException('Eine Kategorie mit diesem Namen existiert bereits.'); } private function normalizeNameForCompare(string $name): string { $name = trim(preg_replace('/\s+/', ' ', $name) ?? ''); if (function_exists('mb_strtolower')) { return mb_strtolower($name, 'UTF-8'); } return strtolower($name); } private function createUniqueSlug(string $name): string { $baseSlug = $this->slugify($name); if ($baseSlug === '') { $baseSlug = 'kategorie'; } $slug = $baseSlug; $suffix = 2; while ($this->categoryRepo->findBySlug($slug) !== null) { $slug = $baseSlug . '-' . $suffix; $suffix++; } return $slug; } private function slugify(string $value): string { $value = trim($value); if ($value === '') { return ''; } $replaceMap = [ 'Ä' => 'ae', 'Ö' => 'oe', 'Ü' => 'ue', 'ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss', ]; $value = strtr($value, $replaceMap); if (function_exists('iconv')) { $converted = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value); if (is_string($converted) && $converted !== '') { $value = $converted; } } $value = strtolower($value); $value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? ''; $value = trim($value, '-'); if (function_exists('mb_strlen') && function_exists('mb_substr') && mb_strlen($value) > 140) { $value = mb_substr($value, 0, 140); $value = rtrim($value, '-'); } elseif (strlen($value) > 140) { $value = substr($value, 0, 140); $value = rtrim($value, '-'); } return $value; } private function isProtectedCategory(array $category): bool { return strtolower(trim((string) ($category['slug'] ?? ''))) === 'allgemein'; } private function snapshot(array $category): array { return [ 'id' => (int) ($category['id'] ?? 0), 'name' => trim((string) ($category['name'] ?? '')), 'slug' => trim((string) ($category['slug'] ?? '')), 'sort_order' => (int) ($category['sort_order'] ?? 0), 'active' => (int) ($category['active'] ?? 0), ]; } private function buildFieldDiff(array $before, array $after): array { $diff = []; foreach (['name', 'slug', 'sort_order', 'active'] as $field) { $beforeValue = $before[$field] ?? null; $afterValue = $after[$field] ?? null; if ($beforeValue === $afterValue) { continue; } $diff[$field] = [ 'before' => $beforeValue, 'after' => $afterValue, ]; } return $diff; } }