diff --git a/Internal/de/PROJECT_CONTEXT.md b/Internal/de/PROJECT_CONTEXT.md index 224af1e..98a9f52 100644 --- a/Internal/de/PROJECT_CONTEXT.md +++ b/Internal/de/PROJECT_CONTEXT.md @@ -55,6 +55,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind l - Neue Kategorien bleiben dort sichtbar, bis sie von einem Berechtigten bestätigt oder mit einer bestehenden Kategorie zusammengeführt werden. - Im Bereich `Kategorien` gibt es zusätzlich eine Suche über bestehende Kategorien, damit sie gezielt gefunden und zusammengeführt werden können. - Beim Zusammenführen wird die nicht zu behaltende Kategorie anschließend vollständig entfernt und die Ansicht danach frisch neu geladen. +- Zusammengeführte Kategorien werden zusätzlich intern als Redirect gesperrt, damit Standard-Seed oder spätere Eingaben sie nicht erneut auswählbar machen. - Beim Zusammenführen kann explizit festgelegt werden, welche der beiden Kategorien erhalten bleibt. - Community-Level können zusätzlich die Rechte für Kategorien sowie für die Freigabe von Orten und Veranstaltungen tragen. diff --git a/Internal/en/PROJECT_CONTEXT.md b/Internal/en/PROJECT_CONTEXT.md index 4d28a3c..13e2bc3 100644 --- a/Internal/en/PROJECT_CONTEXT.md +++ b/Internal/en/PROJECT_CONTEXT.md @@ -55,6 +55,7 @@ Papa-Kind-Treff is a PHP-based platform for fathers. Core areas are local events - New categories remain visible there until an authorized user confirms them or merges them into an existing category. - The `Categories` area also includes a search across existing categories so they can be found and merged directly. - During merging, the category that should not be kept is removed completely and the view is reloaded fresh afterwards. +- Merged categories are additionally blocked internally through redirects so default seeding or later inputs cannot make them selectable again. - During merging, it is now possible to explicitly choose which of the two categories should be kept. - Community levels can now additionally carry the rights for category handling as well as place and event approvals. diff --git a/README.md b/README.md index 7ff7f7b..8079a2f 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf: - Neue Kategorien bleiben im Bereich `Kategorien` sichtbar, bis sie von einem Berechtigten bestätigt oder zusammengeführt werden - Im Bereich `Kategorien` gibt es zusätzlich eine Suche über bestehende Kategorien, damit sie gezielt gefunden und zusammengeführt werden können - Beim Zusammenführen wird die nicht zu behaltende Kategorie anschließend vollständig entfernt und die Ansicht danach frisch neu geladen +- Zusammengeführte Kategorien werden zusätzlich intern als Redirect gesperrt, damit Standard-Seed oder spätere Eingaben sie nicht erneut auswählbar machen - Beim Zusammenführen von Kategorien kann jetzt explizit festgelegt werden, welche der beiden Kategorien erhalten bleibt - Community-Level können jetzt zusätzlich die Rechte für Kategorien sowie für die Freigabe von Orten und Veranstaltungen tragen - neue interne Grundstruktur: `listing_places`, `listings`, `listing_occurrences`, `listing_prices`, `listing_benefits` diff --git a/src/App/ListingCatalog.php b/src/App/ListingCatalog.php index 99602be..76a7f7c 100644 --- a/src/App/ListingCatalog.php +++ b/src/App/ListingCatalog.php @@ -24,6 +24,13 @@ final class ListingCatalog created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci', + 'CREATE TABLE IF NOT EXISTS listing_category_redirects ( + old_slug VARCHAR(120) NOT NULL PRIMARY KEY, + old_title VARCHAR(160) NOT NULL, + target_slug VARCHAR(120) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_listing_category_redirects_target (target_slug) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci', 'CREATE TABLE IF NOT EXISTS listing_places ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, created_by BIGINT UNSIGNED NULL, @@ -186,7 +193,15 @@ final class ListingCatalog VALUES (:slug, :title, :groupName, :sortOrder) ON DUPLICATE KEY UPDATE title = VALUES(title), category_group = VALUES(category_group), sort_order = VALUES(sort_order), updated_at = CURRENT_TIMESTAMP' ); + $redirectedSlugs = []; + $redirectStmt = $this->pdo->query('SELECT old_slug FROM listing_category_redirects'); + foreach (($redirectStmt ? $redirectStmt->fetchAll(\PDO::FETCH_COLUMN) : []) as $redirectedSlug) { + $redirectedSlugs[(string)$redirectedSlug] = true; + } foreach ($this->defaultCategories() as $index => $category) { + if (isset($redirectedSlugs[(string)$category['slug']])) { + continue; + } $seed->execute([ 'slug' => $category['slug'], 'title' => $category['title'], @@ -247,6 +262,15 @@ final class ListingCatalog } $slug = $this->slugify($input); + $redirect = $this->findCategoryRedirect($slug, $input); + if ($redirect !== null) { + $resolved = $this->findCategoryBySlug($redirect['target_slug']); + if ($resolved !== null) { + $resolved['is_new'] = false; + return $resolved; + } + } + $stmt = $this->pdo->prepare( 'SELECT id, slug, title, category_group, sort_order FROM listing_categories @@ -292,6 +316,8 @@ final class ListingCatalog (SELECT COUNT(*) FROM listing_category_map m WHERE m.category_id = c.id) AS listing_count, (SELECT COUNT(*) FROM listing_places p WHERE p.place_kind = c.slug) AS place_count FROM listing_categories c + LEFT JOIN listing_category_redirects r ON r.old_slug = c.slug + WHERE r.old_slug IS NULL ORDER BY CASE WHEN c.sort_order >= 900 THEN 0 ELSE 1 END, c.sort_order ASC, @@ -343,7 +369,9 @@ final class ListingCatalog (SELECT COUNT(*) FROM listing_category_map m WHERE m.category_id = c.id) AS listing_count, (SELECT COUNT(*) FROM listing_places p WHERE p.place_kind = c.slug) AS place_count FROM listing_categories c - WHERE ' . implode(' AND ', $conditions) . ' + LEFT JOIN listing_category_redirects r ON r.old_slug = c.slug + WHERE r.old_slug IS NULL + AND ' . implode(' AND ', $conditions) . ' ORDER BY CASE WHEN LOWER(c.title) = :exactTitle THEN 0 @@ -400,6 +428,17 @@ final class ListingCatalog $this->pdo->beginTransaction(); try { + $redirectUpsert = $this->pdo->prepare( + 'INSERT INTO listing_category_redirects (old_slug, old_title, target_slug) + VALUES (:oldSlug, :oldTitle, :targetSlug) + ON DUPLICATE KEY UPDATE old_title = VALUES(old_title), target_slug = VALUES(target_slug)' + ); + $redirectUpsert->execute([ + 'oldSlug' => $sourceSlug, + 'oldTitle' => $sourceTitle, + 'targetSlug' => $targetSlug, + ]); + $mapRows = $this->pdo->prepare('SELECT listing_id FROM listing_category_map WHERE category_id = :categoryId'); $mapRows->execute(['categoryId' => $sourceId]); $listingIds = $mapRows->fetchAll(\PDO::FETCH_COLUMN) ?: []; @@ -484,6 +523,12 @@ final class ListingCatalog return null; } + $slug = $this->slugify($input); + $redirect = $this->findCategoryRedirect($slug, $input); + if ($redirect !== null) { + return $redirect['target_slug']; + } + $stmt = $this->pdo->prepare( 'SELECT slug FROM listing_categories @@ -492,13 +537,42 @@ final class ListingCatalog LIMIT 1' ); $stmt->execute([ - 'slug' => $input, + 'slug' => $slug, 'title' => $input, ]); $slug = $stmt->fetchColumn(); return $slug !== false ? (string)$slug : null; } + private function findCategoryRedirect(string $slug, string $title): ?array + { + $stmt = $this->pdo->prepare( + 'SELECT old_slug, old_title, target_slug + FROM listing_category_redirects + WHERE old_slug = :slug OR LOWER(old_title) = LOWER(:title) + LIMIT 1' + ); + $stmt->execute([ + 'slug' => $slug, + 'title' => $title, + ]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + return $row ?: null; + } + + private function findCategoryBySlug(string $slug): ?array + { + $stmt = $this->pdo->prepare( + 'SELECT id, slug, title, category_group, sort_order + FROM listing_categories + WHERE slug = :slug + LIMIT 1' + ); + $stmt->execute(['slug' => $slug]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + return $row ?: null; + } + public function listPublishedDirectoryEntries(): array { $this->ensureSchema();