Seiten verschieben, Papierkorb, Fassungen, Zeitplan (Arbeitspaket F)

Migration 20260912000900 ergänzt page.publish_at, page.deleted_at und
revision.seq.

Verschieben nimmt den ganzen Teilbaum mit und hinterlässt für jede
alte Adresse ein 301. Kollisionen, Tiefengrenze und das Verschieben
unter eigene Unterseiten werden vor der ersten Änderung abgewiesen;
alles in einer Transaktion, weil ein halb verschobener Teilbaum
Seiten unerreichbar machen würde.

Der Papierkorb ersetzt das sofortige Löschen für die Editorrolle:
alle Fassungen bleiben, öffentlich 404, verschwunden aus Navigation,
Archiven, Feed, Sitemap, llms.txt und Editorliste. Unterseiten gehen
mit, beim Wiederherstellen muss das Elternteil zuerst zurück.

Eine Fassung wiederherzustellen überschreibt nichts, sondern legt
eine neue an. revision.seq behebt dabei einen echten Fehler: die
Reihenfolge des Verlaufs war nicht eindeutig, weil created_at nur
Sekunden kennt und mehrere Speichervorgänge in dieselbe Sekunde
fallen - "vorherige Fassung wiederherstellen" hätte die falsche
treffen können.

Geplante Veröffentlichung über cms:publish-due. Ohne Cron bleibt die
Seite ein Entwurf, was die sichere Richtung ist.

Eine frei wählbare Startseite fehlt bewusst: der Pfad home gilt an
elf Stellen als Wurzel, das ist ein eigener Schritt (ADR 022).

Geprüft: 160 PHP-Tests / 622 Assertions unter PHP 8.2.12 und 8.4.22,
15 JavaScript-Modelltests, vollständiger Editortest mit axe,
Migration zuerst gegen eine Datenbankkopie samt Kontrolle des
Backfills.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 14:45:09 +02:00
1 parent ba56add678
commit 6afad30956
16 files changed
+750 -17

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Command;
use App\Content\PageRepository;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Publishes pages whose scheduled moment has passed. Meant for a cron entry, for example every
* five minutes. Without it a scheduled page simply stays a draft, which is the safe direction.
*/
#[AsCommand(name: 'cms:publish-due', description: 'Publish pages whose scheduled publication time has passed.')]
final class PublishDueCommand extends Command
{
public function __construct(private PageRepository $pages)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$published = $this->pages->publishDue();
foreach ($published as $path) {
$output->writeln('Published: /'.$path);
}
$output->writeln(sprintf('%d Seite(n) veröffentlicht.', count($published)));
return Command::SUCCESS;
}
}
+1 -1
View File
@@ -78,7 +78,7 @@ final class MenuRepository
public function resolve(array $items, string $currentPath, string $locale = 'de'): array
{
$pages = [];
foreach ($this->db->fetchAllAssociative('SELECT id, path, published_revision_id, draft_revision_id FROM page WHERE website_id = ? AND locale = ?', ['default', $locale]) as $page) {
foreach ($this->db->fetchAllAssociative('SELECT id, path, published_revision_id, draft_revision_id FROM page WHERE website_id = ? AND locale = ? AND deleted_at IS NULL', ['default', $locale]) as $page) {
$pages[$page['id']] = $page;
}
+214 -3
View File
@@ -63,12 +63,221 @@ final class PageRepository
'id' => $revisionId, 'page_id' => $pageId,
'document' => json_encode($document, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'created_at' => gmdate('Y-m-d H:i:s'),
'seq' => 1,
]);
return $pageId;
});
}
/**
* Renames or moves a page and carries its whole subtree along.
*
* Every affected address gets a 301 from its old form, so a migrated or long lived site keeps
* every external link working. All of it happens in one transaction: a half moved subtree
* would leave pages unreachable.
*
* @return int the number of pages whose address changed
*/
public function movePage(string $pageId, string $slug, ?string $parentId, int $expectedVersion, RedirectRepository $redirects): int
{
$slug = strtolower(trim($slug));
if (1 !== preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $slug) || strlen($slug) > 80) {
throw new InvalidPage('Die Adresse darf nur Kleinbuchstaben, Ziffern und einzelne Bindestriche enthalten.');
}
return $this->db->transactional(function () use ($pageId, $slug, $parentId, $expectedVersion, $redirects): int {
$page = $this->db->fetchAssociative('SELECT id, path, slug, parent_id, type, locale, version FROM page WHERE id = ? AND website_id = ?', [$pageId, 'default']);
if (false === $page) {
throw new InvalidPage('Diese Seite gibt es nicht mehr.');
}
if ((int) $page['version'] !== $expectedVersion) {
throw new StaleWrite('Die Seite wurde inzwischen geändert. Lade die Übersicht neu.');
}
if ('home' === $page['path']) {
// The path 'home' is assumed as the site root in several places (public routing, canonical,
// sitemap, llms.txt, menu resolution). Making it configurable is its own step; until
// then the start page keeps its address rather than half of the system following it.
throw new InvalidPage('Die Startseite behält ihre Adresse. Eine andere Seite als Startseite zu wählen ist noch nicht möglich.');
}
$newPath = $slug;
if (null !== $parentId) {
if ($parentId === $pageId) {
throw new InvalidPage('Eine Seite kann nicht ihre eigene übergeordnete Seite sein.');
}
$parent = $this->db->fetchAssociative('SELECT path, type FROM page WHERE id = ? AND website_id = ? AND locale = ? AND deleted_at IS NULL', [$parentId, 'default', $page['locale']]);
if (false === $parent) {
throw new InvalidPage('Die übergeordnete Seite gibt es nicht mehr. Lade die Übersicht neu.');
}
if ('page' !== $parent['type']) {
throw new InvalidPage('Beiträge können keine Unterseiten enthalten.');
}
// Moving a page under one of its own descendants would detach the whole subtree.
if ($parent['path'] === $page['path'] || str_starts_with($parent['path'].'/', $page['path'].'/')) {
throw new InvalidPage('Eine Seite kann nicht unter eine ihrer eigenen Unterseiten verschoben werden.');
}
$newPath = $parent['path'].'/'.$slug;
}
if ($newPath === $page['path']) {
return 0;
}
// Collect the subtree before anything changes, so the old addresses are still known.
$affected = $this->db->fetchAllAssociative(
'SELECT id, path FROM page WHERE website_id = ? AND locale = ? AND (path = ? OR path LIKE ?) ORDER BY path',
['default', $page['locale'], $page['path'], $page['path'].'/%'],
);
$depth = substr_count($newPath, '/') + 1;
foreach ($affected as $row) {
$moved = $newPath.substr($row['path'], strlen($page['path']));
if (substr_count($moved, '/') + 1 > self::MAX_DEPTH) {
throw new InvalidPage(sprintf('Mehr als %d Ebenen sind nicht vorgesehen; die Unterseiten würden zu tief liegen.', self::MAX_DEPTH));
}
$taken = $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND locale = ? AND path = ? AND id <> ?', ['default', $page['locale'], $moved, $row['id']]);
if (false !== $taken) {
throw new InvalidPage(sprintf('Die Adresse /%s ist bereits vergeben.', $moved));
}
}
unset($depth);
foreach ($affected as $row) {
$moved = $newPath.substr($row['path'], strlen($page['path']));
$this->db->executeStatement('UPDATE page SET path = ? WHERE id = ?', [$moved, $row['id']]);
$redirects->remember($row['path'], $moved);
}
$this->db->executeStatement('UPDATE page SET slug = ?, parent_id = ?, version = version + 1 WHERE id = ?', [$slug, $parentId, $pageId]);
return count($affected);
});
}
/**
* Moves a page into the bin. Its revisions stay; publicly it answers 404 and it disappears
* from menus, archives and the sitemap. Subpages go along, because leaving them behind would
* make them unreachable without their parent in the address.
*
* @return int the number of pages moved into the bin
*/
public function trashPage(string $pageId, int $expectedVersion): int
{
return $this->db->transactional(function () use ($pageId, $expectedVersion): int {
$page = $this->db->fetchAssociative('SELECT path, locale, version, deleted_at FROM page WHERE id = ? AND website_id = ?', [$pageId, 'default']);
if (false === $page || null !== $page['deleted_at']) {
throw new InvalidPage('Diese Seite ist nicht mehr vorhanden.');
}
if ((int) $page['version'] !== $expectedVersion) {
throw new StaleWrite('Die Seite wurde inzwischen geändert. Lade die Übersicht neu.');
}
if ('home' === $page['path']) {
throw new InvalidPage('Die Startseite kann nicht in den Papierkorb.');
}
return $this->db->executeStatement(
'UPDATE page SET deleted_at = ?, version = version + 1 WHERE website_id = ? AND locale = ? AND (path = ? OR path LIKE ?) AND deleted_at IS NULL',
[gmdate('Y-m-d H:i:s'), 'default', $page['locale'], $page['path'], $page['path'].'/%'],
);
});
}
/** Takes a page out of the bin. Its ancestors have to be present, or the address would break. */
public function restorePage(string $pageId): void
{
$this->db->transactional(function () use ($pageId): void {
$page = $this->db->fetchAssociative('SELECT path, parent_id, locale FROM page WHERE id = ? AND website_id = ? AND deleted_at IS NOT NULL', [$pageId, 'default']);
if (false === $page) {
throw new InvalidPage('Diese Seite liegt nicht im Papierkorb.');
}
if (null !== $page['parent_id']) {
$parent = $this->db->fetchOne('SELECT id FROM page WHERE id = ? AND deleted_at IS NULL', [$page['parent_id']]);
if (false === $parent) {
throw new InvalidPage('Stelle zuerst die übergeordnete Seite wieder her.');
}
}
$this->db->executeStatement('UPDATE page SET deleted_at = NULL, version = version + 1 WHERE id = ?', [$pageId]);
});
}
/** @return list<array{id: string, path: string, deletedAt: string}> */
public function trash(string $locale = 'de'): array
{
return array_map(static fn (array $row): array => ['id' => $row['id'], 'path' => $row['path'], 'deletedAt' => $row['deleted_at']], $this->db->fetchAllAssociative(
'SELECT id, path, deleted_at FROM page WHERE website_id = ? AND locale = ? AND deleted_at IS NOT NULL ORDER BY deleted_at DESC',
['default', $locale],
));
}
/** @return list<array{id: string, createdAt: string, title: string, published: bool, draft: bool}> */
public function revisions(string $pageId, int $limit = 30): array
{
$page = $this->db->fetchAssociative('SELECT published_revision_id, draft_revision_id FROM page WHERE id = ?', [$pageId]);
return array_map(static function (array $row) use ($page): array {
$document = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR);
return [
'id' => $row['id'],
'createdAt' => $row['created_at'],
'title' => $document['title'] ?? '',
'published' => $row['id'] === ($page['published_revision_id'] ?? null),
'draft' => $row['id'] === ($page['draft_revision_id'] ?? null),
];
}, $this->db->fetchAllAssociative(
'SELECT id, created_at, document FROM revision WHERE page_id = ? ORDER BY seq DESC LIMIT '.max(1, min($limit, 100)),
[$pageId],
));
}
/**
* Makes an earlier revision the current draft by storing its document again. Nothing is
* overwritten in place: the restored state becomes a new revision, so the history stays whole.
*/
public function restoreRevision(string $pageId, string $revisionId, int $expectedVersion): string
{
$raw = $this->db->fetchOne('SELECT document FROM revision WHERE id = ? AND page_id = ?', [$revisionId, $pageId]);
if (false === $raw) {
throw new InvalidPage('Diese Fassung gehört nicht zu dieser Seite.');
}
return $this->saveDraft($pageId, json_decode($raw, true, 512, JSON_THROW_ON_ERROR), $expectedVersion);
}
/**
* Publishes every page whose scheduled moment has passed. Run from cron; without it a
* scheduled page simply stays a draft, which is the safe direction.
*
* @return list<string> the paths that went public
*/
public function publishDue(): array
{
$published = [];
foreach ($this->db->fetchAllAssociative(
'SELECT id, path, draft_revision_id, version FROM page WHERE website_id = ? AND deleted_at IS NULL AND publish_at IS NOT NULL AND publish_at <= ? AND draft_revision_id IS NOT NULL AND (published_revision_id IS NULL OR published_revision_id <> draft_revision_id)',
['default', gmdate('Y-m-d H:i:s')],
) as $row) {
$this->publish($row['id'], $row['draft_revision_id'], (int) $row['version']);
$this->db->executeStatement('UPDATE page SET publish_at = NULL WHERE id = ?', [$row['id']]);
$published[] = $row['path'];
}
return $published;
}
public function schedule(string $pageId, string $when, int $expectedVersion): void
{
$when = trim($when);
if ('' !== $when && 1 !== preg_match('/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}$/D', $when)) {
throw new InvalidPage('Der Zeitpunkt braucht die Form JJJJ-MM-TT HH:MM.');
}
$changed = $this->db->executeStatement(
'UPDATE page SET publish_at = ?, version = version + 1 WHERE id = ? AND version = ?',
['' === $when ? null : str_replace('T', ' ', $when).':00', $pageId, $expectedVersion],
);
if (1 !== $changed) {
throw new StaleWrite('Die Seite wurde inzwischen geändert. Lade die Übersicht neu.');
}
}
/**
* Removes a page and its revisions. Pages with children stay untouched so no content
* disappears silently; the editor has to deal with the children first.
@@ -111,6 +320,8 @@ final class PageRepository
'id' => $id, 'page_id' => $pageId,
'document' => json_encode($document, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'created_at' => gmdate('Y-m-d H:i:s'),
// Strictly increasing per page, because several saves share one second.
'seq' => 1 + (int) $this->db->fetchOne('SELECT COALESCE(MAX(seq), 0) FROM revision WHERE page_id = ?', [$pageId]),
]);
return $id;
@@ -143,7 +354,7 @@ final class PageRepository
public function findPublished(string $path, string $locale = 'de'): ?array
{
$page = $this->db->fetchAssociative(
'SELECT p.id, p.slug, p.path, p.type, p.locale, p.author, p.excerpt, p.published_at, p.robots, p.og_media_id, r.document, r.id AS revision_id, w.name AS website_name FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id JOIN website w ON w.id = p.website_id WHERE p.path = ? AND p.locale = ? AND p.website_id = ?',
'SELECT p.id, p.slug, p.path, p.type, p.locale, p.author, p.excerpt, p.published_at, p.robots, p.og_media_id, r.document, r.id AS revision_id, w.name AS website_name FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id JOIN website w ON w.id = p.website_id WHERE p.path = ? AND p.locale = ? AND p.website_id = ? AND p.deleted_at IS NULL',
[$path, $locale, 'default'],
);
if (false === $page) {
@@ -157,12 +368,12 @@ final class PageRepository
public function listForEditor(): array
{
return $this->db->fetchAllAssociative('SELECT p.id, p.slug, p.path, p.parent_id, p.type, p.locale, p.version, p.published_revision_id, p.draft_revision_id, p.excerpt, p.author, p.published_at, p.featured_media_id, p.robots, p.og_media_id, r.document FROM page p LEFT JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.website_id = ? ORDER BY p.path', ['default']);
return $this->db->fetchAllAssociative('SELECT p.id, p.slug, p.path, p.parent_id, p.type, p.locale, p.version, p.published_revision_id, p.draft_revision_id, p.excerpt, p.author, p.published_at, p.featured_media_id, p.robots, p.og_media_id, p.publish_at, r.document FROM page p LEFT JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.deleted_at IS NULL ORDER BY p.path', ['default']);
}
public function findForEditor(string $id): ?array
{
$row = $this->db->fetchAssociative('SELECT p.*, r.document FROM page p JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.id = ? AND p.website_id = ?', [$id, 'default']);
$row = $this->db->fetchAssociative('SELECT p.*, r.document FROM page p JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.id = ? AND p.website_id = ? AND p.deleted_at IS NULL', [$id, 'default']);
if (false === $row) {
return null;
}
+1 -1
View File
@@ -109,7 +109,7 @@ final class PostRepository
*/
public function published(int $page = 1, ?string $termId = null, int $perPage = self::PER_PAGE, string $locale = 'de'): array
{
$where = "p.website_id = ? AND p.locale = ? AND p.type = 'post' AND p.published_revision_id IS NOT NULL";
$where = "p.website_id = ? AND p.locale = ? AND p.type = 'post' AND p.published_revision_id IS NOT NULL AND p.deleted_at IS NULL";
$params = ['default', $locale];
$join = '';
if (null !== $termId) {
+107 -2
View File
@@ -7,6 +7,7 @@ use App\Content\DocumentValidator;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PostRepository;
use App\Content\RedirectRepository;
use App\Content\StyleCompiler;
use App\Content\StyleSchema;
use App\Content\Theme;
@@ -23,7 +24,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
#[Route('/admin')]
final class EditorController extends AbstractController
{
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private CsrfTokenManagerInterface $csrf)
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private CsrfTokenManagerInterface $csrf)
{
}
@@ -40,6 +41,7 @@ final class EditorController extends AbstractController
return $this->render('admin/pages.html.twig', [
'pages' => $pages,
'trash' => $this->pages->trash(),
'parents' => array_values(array_filter($pages, static fn (array $p): bool => 'page' === $p['type'] && $p['depth'] + 2 <= PageRepository::MAX_DEPTH)),
]);
}
@@ -144,10 +146,113 @@ final class EditorController extends AbstractController
return $this->back('Die Beitragsdaten wurden gespeichert.', 'success');
}
#[Route('/pages/{id}/move', name: 'admin_move', methods: ['POST'])]
public function move(Request $request, string $id): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
try {
$moved = $this->pages->movePage(
$id,
$request->request->getString('slug'),
$request->request->getString('parent_id') ?: null,
$request->request->getInt('expectedVersion', -1),
$this->redirects,
);
} catch (InvalidPage|StaleWrite $e) {
return $this->back($e->getMessage());
}
return $this->back(0 === $moved
? 'Die Adresse ist unverändert.'
: sprintf('%d Adresse(n) geändert. Von den alten Adressen wird dauerhaft weitergeleitet.', $moved), 'success');
}
#[Route('/pages/{id}/schedule', name: 'admin_schedule', methods: ['POST'])]
public function schedule(Request $request, string $id): Response
{
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
try {
$this->pages->schedule($id, $request->request->getString('publishAt'), $request->request->getInt('expectedVersion', -1));
} catch (InvalidPage|StaleWrite $e) {
return $this->back($e->getMessage());
}
return $this->back('' === trim($request->request->getString('publishAt'))
? 'Die geplante Veröffentlichung wurde entfernt.'
: 'Die Veröffentlichung ist geplant. Sie erfolgt, sobald cms:publish-due läuft.', 'success');
}
#[Route('/pages/{id}/restore', name: 'admin_restore_page', methods: ['POST'])]
public function restorePage(Request $request, string $id): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
try {
$this->pages->restorePage($id);
} catch (InvalidPage $e) {
return $this->back($e->getMessage());
}
return $this->back('Die Seite ist zurück. Sie ist erst nach dem Veröffentlichen wieder öffentlich.', 'success');
}
#[Route('/pages/{id}/revisions/{revision}', name: 'admin_restore_revision', requirements: ['revision' => '[a-f0-9]{32}'], methods: ['POST'])]
public function restoreRevision(Request $request, string $id, string $revision): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
try {
$this->pages->restoreRevision($id, $revision, $request->request->getInt('expectedVersion', -1));
} catch (InvalidPage|StaleWrite $e) {
return $this->back($e->getMessage());
}
return $this->back('Die Fassung ist als Entwurf zurück. Der bisherige Stand bleibt als eigene Fassung erhalten.', 'success');
}
#[Route('/pages/{id}/revisions', name: 'admin_revisions', methods: ['GET'])]
public function revisions(string $id): Response
{
$page = $this->page($id);
return $this->render('admin/revisions.html.twig', [
'page' => $page,
'title' => $page['document']['title'] ?? $page['path'],
'revisions' => $this->pages->revisions($id),
]);
}
#[Route('/pages/{id}/trash', name: 'admin_trash', methods: ['POST'])]
public function trash(Request $request, string $id): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
try {
$count = $this->pages->trashPage($id, $request->request->getInt('expectedVersion', -1));
} catch (InvalidPage|StaleWrite $e) {
return $this->back($e->getMessage());
}
return $this->back(sprintf('%d Seite(n) in den Papierkorb gelegt. Öffentlich nicht mehr erreichbar, aber wiederherstellbar.', $count), 'success');
}
#[Route('/pages/{id}/delete', name: 'admin_delete', methods: ['POST'])]
public function delete(Request $request, string $id): Response
{
// Deleting removes every revision of a page, so it stays with the administrator role.
// The final removal deletes every revision, so it stays with the administrator role.
// Everyone with the editor role uses the bin instead.
$this->denyAccessUnlessGranted('ROLE_ADMIN');
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
+3 -3
View File
@@ -32,7 +32,7 @@ final class SeoController extends AbstractController
// Only published pages that are allowed to be indexed. A draft or a noindex page in the
// sitemap would ask a search engine to fetch something it must not list.
foreach ($this->db->fetchAllAssociative(
"SELECT p.path, p.type, p.published_at, r.created_at FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.robots = 'index' ORDER BY p.path",
"SELECT p.path, p.type, p.published_at, r.created_at FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.robots = 'index' AND p.deleted_at IS NULL ORDER BY p.path",
['default'],
) as $row) {
$entries[] = [
@@ -45,7 +45,7 @@ final class SeoController extends AbstractController
$entries[] = ['loc' => $origin.'/beitraege', 'lastmod' => null];
}
foreach ($this->db->fetchAllAssociative(
'SELECT DISTINCT t.kind, t.slug FROM term t JOIN page_term pt ON pt.term_id = t.id JOIN page p ON p.id = pt.page_id AND p.published_revision_id IS NOT NULL ORDER BY t.kind, t.slug',
'SELECT DISTINCT t.kind, t.slug FROM term t JOIN page_term pt ON pt.term_id = t.id JOIN page p ON p.id = pt.page_id AND p.published_revision_id IS NOT NULL AND p.deleted_at IS NULL ORDER BY t.kind, t.slug',
) as $row) {
$entries[] = ['loc' => $origin.'/'.PostRepository::ARCHIVE_PREFIX[$row['kind']].'/'.$row['slug'], 'lastmod' => null];
}
@@ -88,7 +88,7 @@ final class SeoController extends AbstractController
}
foreach (['page' => '## Seiten', 'post' => '## Beiträge'] as $type => $heading) {
$rows = $this->db->fetchAllAssociative(
"SELECT p.path, p.excerpt, r.document FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.robots = 'index' AND p.type = ? ORDER BY p.path",
"SELECT p.path, p.excerpt, r.document FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.robots = 'index' AND p.deleted_at IS NULL AND p.type = ? ORDER BY p.path",
['default', $type],
);
if ([] === $rows) {