diff --git a/app/Enums/ActivityType.php b/app/Enums/ActivityType.php index 381a9be3..8f03d65e 100644 --- a/app/Enums/ActivityType.php +++ b/app/Enums/ActivityType.php @@ -27,6 +27,7 @@ enum ActivityType: string case SyncLock = 'sync_lock'; case Anonymization = 'anonymization'; case Correction = 'correction'; + case ContentRemoval = 'content_removal'; public function label(): string { @@ -49,6 +50,7 @@ enum ActivityType: string self::SyncLock => 'Re-Sync-Sperre', self::Anonymization => 'Anonymisierung', self::Correction => 'Korrektur', + self::ContentRemoval => 'Entfernung (410)', }; } @@ -71,6 +73,7 @@ enum ActivityType: string self::SyncLock => 'red', self::Anonymization => 'red', self::Correction => 'blue', + self::ContentRemoval => 'red', }; } } diff --git a/app/Http/Controllers/Web/NewsroomDetailController.php b/app/Http/Controllers/Web/NewsroomDetailController.php index 195e1169..7ddbaf48 100644 --- a/app/Http/Controllers/Web/NewsroomDetailController.php +++ b/app/Http/Controllers/Web/NewsroomDetailController.php @@ -6,13 +6,22 @@ use App\Http\Controllers\Controller; use App\Services\Web\NewsroomDetailPageData; use App\Support\WebPortalContext; use Illuminate\Contracts\View\View; +use Illuminate\Http\Response; class NewsroomDetailController extends Controller { // $edition stammt aus dem Gruppen-Prefix {edition} und steht positionsbedingt // vor {slug}; es wird hier nicht gebraucht, muss aber in der Signatur stehen. - public function __invoke(string $edition, string $slug, NewsroomDetailPageData $pageData): View + public function __invoke(string $edition, string $slug, NewsroomDetailPageData $pageData): View|Response { - return view('web.newsroom', $pageData->build(WebPortalContext::fromRequest(), $slug)); + $data = $pageData->build(WebPortalContext::fromRequest(), $slug); + + // Bewusst entfernter Newsroom → Tombstone mit 410 Gone (ohne Inhalte). + // Vollständig gelöschte Firmen bleiben bei 404. + if (isset($data['tombstone'])) { + return response()->view('web.newsroom-tombstone', [], 410); + } + + return view('web.newsroom', $data); } } diff --git a/app/Http/Controllers/Web/ReleaseDetailController.php b/app/Http/Controllers/Web/ReleaseDetailController.php index 95c0be78..fc505d44 100644 --- a/app/Http/Controllers/Web/ReleaseDetailController.php +++ b/app/Http/Controllers/Web/ReleaseDetailController.php @@ -16,9 +16,11 @@ class ReleaseDetailController extends Controller { $data = $pageData->build(WebPortalContext::fromRequest(), $slug); - // T12: depublizierte PM → Tombstone mit 410 Gone (ohne Inhalte). + // Bewusst entfernte PM → Tombstone mit 410 Gone (ohne Inhalte). + // 'depublished' = Depublikation auf Wunsch des Absenders (T12), + // 'removed' = redaktionelle/rechtliche Entfernung. if (isset($data['tombstone'])) { - return response()->view('web.release-tombstone', [], 410); + return response()->view('web.release-tombstone', ['reason' => $data['tombstone']], 410); } return view('web.release-detail', $data); diff --git a/app/Models/Company.php b/app/Models/Company.php index d56ccbd0..a9b6b91c 100644 --- a/app/Models/Company.php +++ b/app/Models/Company.php @@ -57,6 +57,8 @@ class Company extends Model 'logo_variants', 'boilerplate', 'is_active', + 'gone_at', + 'is_active_before_gone', 'disable_footer_code', 'legacy_portal', 'legacy_id', @@ -69,6 +71,8 @@ class Company extends Model 'type' => CompanyType::class, 'logo_variants' => 'array', 'is_active' => 'boolean', + 'gone_at' => 'datetime', + 'is_active_before_gone' => 'boolean', 'disable_footer_code' => 'boolean', 'deleted_at' => 'datetime', ]; @@ -117,6 +121,16 @@ class Company extends Model }); } + /** + * Mit 410 entfernter Newsroom: die Newsroom-URL bleibt bestehen und liefert + * einen Tombstone statt eines 404. Gegenstück zur vollständigen Löschung + * (Soft Delete). + */ + public function isGone(): bool + { + return $this->gone_at !== null; + } + public function owner(): BelongsTo { return $this->belongsTo(User::class, 'owner_user_id'); diff --git a/app/Models/PressRelease.php b/app/Models/PressRelease.php index 3df25138..f707900c 100644 --- a/app/Models/PressRelease.php +++ b/app/Models/PressRelease.php @@ -91,6 +91,8 @@ class PressRelease extends Model 'no_export', 'published_at', 'depublished_at', + 'gone_at', + 'no_export_before_gone', 'scheduled_at', 'embargo_at', 'withdrawal_waiver_consented_at', @@ -117,6 +119,8 @@ class PressRelease extends Model 'no_export' => 'boolean', 'published_at' => 'datetime', 'depublished_at' => 'datetime', + 'gone_at' => 'datetime', + 'no_export_before_gone' => 'boolean', 'sync_locked_at' => 'datetime', 'scheduled_at' => 'datetime', 'embargo_at' => 'datetime', @@ -482,7 +486,17 @@ class PressRelease extends Model */ public function scopeVisibleInArchive(Builder $query): Builder { - return $query->published()->where('published_at', '<=', now()); + return $query->published()->where('published_at', '<=', now())->whereNull('gone_at'); + } + + /** + * Mit 410 entfernt: die URL bleibt bestehen und liefert einen Tombstone, + * der Inhalt ist aber nicht mehr öffentlich. Gegenstück zur vollständigen + * Löschung (Soft Delete), die 404 erzeugt. + */ + public function isGone(): bool + { + return $this->gone_at !== null; } /** @@ -494,7 +508,8 @@ class PressRelease extends Model { return $this->status === PressReleaseStatus::Published && $this->published_at !== null - && $this->published_at->lessThanOrEqualTo(now()); + && $this->published_at->lessThanOrEqualTo(now()) + && $this->gone_at === null; } /** diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index fc4c694f..44f24edf 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -32,6 +32,7 @@ use App\Services\Admin\AdminRequestPerformanceMetrics; use App\Services\Newsletter\NullNewsletterSyncClient; use App\Services\PressRelease\PressReleaseService; use App\Support\OperationalAlert; +use App\Support\WebPageCache; use Illuminate\Auth\Events\Login; use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Events\Verified; @@ -86,6 +87,12 @@ class AppServiceProvider extends ServiceProvider { Model::preventLazyLoading(app()->isLocal()); + // Die Generations-Nummer der Seiten-Caches wird prozessintern gemerkt. + // Ein Queue-Worker lebt über beliebig viele Jobs hinweg und würde sonst + // dauerhaft mit dem Stand seines ersten Zugriffs arbeiten — Keys einer + // längst verworfenen Generation lesen und löschen. + Queue::looping(static fn () => WebPageCache::resetMemo()); + $scheme = request()->header('X-Forwarded-Proto') ?? request()->server('HTTP_X_FORWARDED_PROTO') ?? (request()->secure() ? 'https' : 'http'); diff --git a/app/Services/Company/CompanyRemovalService.php b/app/Services/Company/CompanyRemovalService.php new file mode 100644 index 00000000..441b35bb --- /dev/null +++ b/app/Services/Company/CompanyRemovalService.php @@ -0,0 +1,123 @@ +gone_at === null) { + $company->forceFill([ + 'gone_at' => now(), + // Vorzustand sichern, sonst ist die Entfernung nicht wirklich + // umkehrbar: Eine Firma, die bereits vorher deaktiviert war — + // im Bestand der Normalfall bei Karteileichen —, stünde nach + // dem Aufheben öffentlich in Newsroom-Übersicht und Sitemap. + 'is_active_before_gone' => (bool) $company->is_active, + 'is_active' => false, + ])->save(); + + $this->activityLog->record( + ActivityType::ContentRemoval, + sprintf('Newsroom „%s“ mit 410 entfernt: %s', $company->name, $reason), + subject: $company, + ); + } + + $affected = 0; + + if ($includePressReleases) { + // chunkById statt get(): Eine Bestandsfirma kann dreistellig viele + // Mitteilungen haben, die hier sonst allesamt gleichzeitig im + // Speicher lägen. Der Filter auf `gone_at` schrumpft mit jedem + // Durchgang, deshalb chunkById und nicht chunk — ein Offset würde + // Zeilen überspringen. + PressRelease::query() + ->withoutGlobalScope(PortalScope::class) + ->where('company_id', $company->getKey()) + ->whereNull('gone_at') + ->chunkById(200, function ($releases) use ($reason, &$affected): void { + foreach ($releases as $release) { + // Ohne Flush je Mitteilung: der verwirft ALLE + // Seiten-Caches auf einmal und würde die gesamte + // öffentliche Seite pro Zeile in den Neuaufbau zwingen. + // Einmal am Ende genügt. + $this->pressReleases->markGone($release, $reason, flushPublicPages: false); + $affected++; + } + }); + } + + WebCaches::forgetPublicPages(); + + return $affected; + } + + /** + * Hebt die 410-Entfernung des Newsrooms auf. Bereits entfernte + * Pressemitteilungen bleiben entfernt — sie werden einzeln freigegeben, + * damit eine rechtlich veranlasste Einzel-Entfernung nicht versehentlich + * mit zurückkommt. + * + * `is_active` geht auf den Stand VOR der Entfernung zurück, nicht pauschal + * auf „aktiv“. Fehlt der Merker (Entfernung aus der Zeit vor dieser + * Spalte), bleibt es beim bisherigen Verhalten — für diese Altfälle ist + * „aktiv“ die einzige Annahme, die überhaupt zur Verfügung steht. + */ + public function restoreFromGone(Company $company): void + { + if ($company->gone_at === null) { + return; + } + + $company->forceFill([ + 'gone_at' => null, + 'is_active' => $company->is_active_before_gone ?? true, + 'is_active_before_gone' => null, + ])->save(); + + $this->activityLog->record( + ActivityType::ContentRemoval, + sprintf('410-Entfernung des Newsrooms „%s“ aufgehoben', $company->name), + subject: $company, + ); + + WebCaches::forgetPublicPages(); + } +} diff --git a/app/Services/PressRelease/BoostService.php b/app/Services/PressRelease/BoostService.php index c2132b3d..c3c7b088 100644 --- a/app/Services/PressRelease/BoostService.php +++ b/app/Services/PressRelease/BoostService.php @@ -13,6 +13,7 @@ use App\Models\User; use App\Scopes\PortalScope; use App\Services\Billing\CreditPricingService; use App\Services\Billing\CreditWalletService; +use App\Support\WebPageCache; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; @@ -229,12 +230,12 @@ class BoostService foreach ($portals as $portal) { if ($placement === BoostPlacement::Home) { - Cache::forget(sprintf('web.home.%s.%s', $portal, $pressRelease->language)); + Cache::forget(WebPageCache::key('home', [$portal, $pressRelease->language])); } foreach ($translations as $translation) { if (filled($translation->slug)) { - Cache::forget(sprintf('web.category.%s.%s.%s', $portal, $translation->locale, $translation->slug)); + Cache::forget(WebPageCache::key('category', [$portal, $translation->locale, $translation->slug])); } } } diff --git a/app/Services/PressRelease/PressReleaseService.php b/app/Services/PressRelease/PressReleaseService.php index d2e18b7f..0d0be10c 100644 --- a/app/Services/PressRelease/PressReleaseService.php +++ b/app/Services/PressRelease/PressReleaseService.php @@ -411,7 +411,7 @@ class PressReleaseService 'depublished_at' => now(), ]); $pressRelease->lockSync($syncLockReason ?? 'Depublikation auf Wunsch des Absenders'); - WebCaches::forgetSitemaps(); + WebCaches::forgetPublicPages(); $this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Archived, 'Depublikation auf Wunsch des Absenders (kostenpflichtige Serviceleistung)', 'depublication'); } @@ -464,6 +464,89 @@ class PressReleaseService $pressRelease->delete(); } + /** + * Entfernung mit 410 Gone: die Mitteilung verschwindet aus allen + * öffentlichen Listen, ihre Detail-URL liefert aber weiterhin einen + * Tombstone statt eines 404. + * + * Bewusste Abgrenzung zu deleteFromAdmin(): dort wird der Inhalt durch den + * Ersatztext überschrieben bzw. die PM soft-gelöscht. Hier bleibt der + * Datensatz unverändert erhalten, damit die Entfernung über + * restoreFromGone() umkehrbar und im Audit nachvollziehbar bleibt. + * + * Die Re-Sync-Sperre ist Pflicht — ohne sie würde der nächste + * Importer-Lauf die Entfernung wieder überschreiben. + * + * $flushPublicPages steuert den Auslieferungs-Cache. Der Flush verwirft + * ALLE Seiten-Caches auf einmal (siehe WebPageCache::flush) und ist damit + * genau einmal je Vorgang richtig — wer die Methode in einer Schleife + * aufruft, schaltet ihn ab und flusht selbst am Ende. Sonst zwingt jede + * einzelne Mitteilung die gesamte öffentliche Seite in den Neuaufbau. + */ + public function markGone(PressRelease $pressRelease, ?string $reason = null, bool $flushPublicPages = true): void + { + if ($pressRelease->gone_at !== null) { + return; + } + + $previous = $pressRelease->status; + + $pressRelease->update([ + 'status' => PressReleaseStatus::Archived->value, + 'gone_at' => now(), + // Vorzustand sichern: `no_export` kann auch redaktionell oder vom + // Kunden gesetzt sein und ist in beiden Detailansichten sichtbar. + // Ohne diesen Merker stünde die Mitteilung nach dem Aufheben + // dauerhaft als „kein Export“ da, ohne dass das je jemand gewählt + // hätte. + 'no_export_before_gone' => (bool) $pressRelease->no_export, + 'no_export' => true, + ]); + + $pressRelease->lockSync($reason ?? 'Entfernung mit 410 (Tombstone)'); + + if ($flushPublicPages) { + WebCaches::forgetPublicPages(); + } + + $this->logStatusChange( + $pressRelease, + $previous, + PressReleaseStatus::Archived, + $reason ?? 'Aus der öffentlichen Ansicht entfernt (410 Gone)', + 'gone', + ); + } + + /** + * Hebt eine 410-Entfernung auf. Der Status bleibt bewusst „archived“ — + * ob die Mitteilung wieder veröffentlicht wird, entscheidet die Redaktion + * anschließend über den regulären Statuswechsel. + * + * `no_export` geht dagegen auf den Stand VOR der Entfernung zurück: Die + * Sperre gehört zur Entfernung, nicht zur Mitteilung. Fehlt der Merker + * (Entfernung aus der Zeit vor dieser Spalte), bleibt der aktuelle Wert + * stehen — raten wäre schlechter als nichts zu tun. + * + * Zu $flushPublicPages siehe markGone(). + */ + public function restoreFromGone(PressRelease $pressRelease, bool $flushPublicPages = true): void + { + if ($pressRelease->gone_at === null) { + return; + } + + $pressRelease->update([ + 'gone_at' => null, + 'no_export' => $pressRelease->no_export_before_gone ?? $pressRelease->no_export, + 'no_export_before_gone' => null, + ]); + + if ($flushPublicPages) { + WebCaches::forgetPublicPages(); + } + } + /** * @param PressReleaseStatus[] $allowed */ diff --git a/app/Services/Web/NewsroomDetailPageData.php b/app/Services/Web/NewsroomDetailPageData.php index 2a0dbdf5..198202cb 100644 --- a/app/Services/Web/NewsroomDetailPageData.php +++ b/app/Services/Web/NewsroomDetailPageData.php @@ -31,6 +31,14 @@ class NewsroomDetailPageData // (Cache-Poisoning über z. B. ?page=2.5). $page = Paginator::resolveCurrentPage(); + // Bewusst VOR dem Seiten-Cache: eine Entfernung (410) muss sofort + // greifen, auch wenn für den Slug noch ein bis zu eine Stunde alter + // Stale-Eintrag im Cache liegt. Kostet einen indizierten Slug-Lookup + // pro Aufruf — vertretbar, weil Entfernungen rechtlich motiviert sind. + if ($this->isGone($context, $slug)) { + return ['tombstone' => 'removed']; + } + $cacheKey = WebPageCache::key('newsroom', [ implode('-', $context->portalValues), $context->language, @@ -45,6 +53,20 @@ class NewsroomDetailPageData ); } + /** + * Newsroom mit 410 entfernt? Vollständig gelöschte Firmen (Soft Delete) + * bleiben bewusst außen vor — deren URL antwortet weiterhin mit 404. + */ + private function isGone(WebPortalContext $context, string $slug): bool + { + return Company::query() + ->withoutGlobalScope(PortalScope::class) + ->whereIn('portal', $context->portalValues) + ->where('slug', $slug) + ->whereNotNull('gone_at') + ->exists(); + } + /** * @return array */ diff --git a/app/Services/Web/ReleaseDetailPageData.php b/app/Services/Web/ReleaseDetailPageData.php index 950aa7b3..bb109653 100644 --- a/app/Services/Web/ReleaseDetailPageData.php +++ b/app/Services/Web/ReleaseDetailPageData.php @@ -45,19 +45,23 @@ class ReleaseDetailPageData ->first(); if (! $release) { - // T12-Tombstone: depublizierte PMs (kostenpflichtige Entfernung - // auf Wunsch des Absenders) melden 410 statt 404 — die URL gab - // es, der Inhalt wurde bewusst entfernt. - $depublished = PressRelease::query() + // Tombstone statt 404: die URL gab es, der Inhalt wurde bewusst + // entfernt. Zwei Anlässe mit eigenem Seitentext — + // `depublished_at` = kostenpflichtige Depublikation auf Wunsch des + // Absenders (T12), `gone_at` = redaktionelle/rechtliche Entfernung + // aus dem Admin. Vollständig gelöschte PMs bleiben bei 404. + $removed = PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->forPortalValues($portalValues) ->forLanguage($language) - ->whereNotNull('depublished_at') ->where('slug', $slug) - ->exists(); + ->where(fn (Builder $query) => $query + ->whereNotNull('depublished_at') + ->orWhereNotNull('gone_at')) + ->first(['depublished_at', 'gone_at']); - if ($depublished) { - return ['tombstone' => true]; + if ($removed) { + return ['tombstone' => $removed->depublished_at !== null ? 'depublished' : 'removed']; } abort(404); diff --git a/app/Support/WebCaches.php b/app/Support/WebCaches.php index 90a64369..df4060cb 100644 --- a/app/Support/WebCaches.php +++ b/app/Support/WebCaches.php @@ -10,6 +10,18 @@ use Illuminate\Support\Facades\Cache; */ class WebCaches { + /** + * Kompletter Auslieferungs-Cache nach einer Entfernung: Sitemaps UND alle + * Seiten-Caches (Ticker, Startseite, Kategorie-, Newsroom- und Suchlisten). + * Ohne den zweiten Teil bliebe eine entfernte Meldung bis zu einer Stunde + * in gecachten Listen sichtbar. + */ + public static function forgetPublicPages(): void + { + self::forgetSitemaps(); + WebPageCache::flush(); + } + /** * Sitemap-Caches aller Hosts verwerfen (SitemapController cached 1h je * Host) — nach einer Mutation darf kein Cache den alten Stand halten. diff --git a/app/Support/WebPageCache.php b/app/Support/WebPageCache.php index 6b3483db..5c9a3554 100644 --- a/app/Support/WebPageCache.php +++ b/app/Support/WebPageCache.php @@ -33,9 +33,40 @@ class WebPageCache */ public const StaleSeconds = 3600; + /** + * Cache-Key der Generations-Nummer (siehe generation()). + * + * Bewusst OHNE `web.`-Präfix: Der Zähler ist der eine Eintrag, der eine + * Verwerfung des `web.`-Namensraums überleben muss. Läge er darin, würde + * ihn ein pauschales Aufräumen dieses Präfixes mitnehmen — und ein Flush + * wäre danach stillschweigend wirkungslos. + */ + private const GenerationKey = 'page-cache.generation'; + + /** + * Generations-Nummer innerhalb des Requests, damit key() nicht bei jedem + * Aufruf erneut den Cache-Store befragt. + * + * ACHTUNG Lebensdauer: Der Wert gilt für einen Request. In langlebigen + * Prozessen (queue:work, künftig Octane) muss er zwischen zwei Vorgängen + * verworfen werden — sonst arbeitet der Worker beliebig lange mit einer + * veralteten Generation und würde etwa in BoostService::forgetPlacementCaches() + * die Keys einer alten Generation löschen, während der aktuelle Eintrag + * stehen bliebe. Dafür sorgt resetMemo(), registriert im AppServiceProvider + * auf `Queue::looping()`. + */ + private static ?int $generation = null; + /** * Baut einen `web.`-Cache-Key aus Segmenten (leere Segmente entfallen). * + * Der Key enthält eine Generations-Nummer: Der Store kennt keine Tags, und + * die Keys der Listenseiten sind über Portal, Sprache, Seite und Filter + * verstreut. Um eine Entfernung (410/Depublikation) sofort wirksam zu + * machen, wird deshalb die Generation erhöht — dadurch verfallen alle + * `web.`-Seiten-Caches auf einen Schlag. Das ist bewusst grob, aber + * Entfernungen sind selten und dürfen nicht bis zu einer Stunde nachhängen. + * * @param array $segments */ public static function key(string $group, array $segments = []): string @@ -45,7 +76,47 @@ class WebPageCache static fn (string $segment): bool => $segment !== '', ); - return implode('.', array_merge(['web', $group], $parts)); + return implode('.', array_merge(['web', 'g'.self::generation(), $group], $parts)); + } + + /** + * Aktuelle Generation der Seiten-Caches. + */ + public static function generation(): int + { + return self::$generation ??= (int) Cache::get(self::GenerationKey, 0); + } + + /** + * Verwirft die prozessinterne Merkung der Generation, sodass der nächste + * Zugriff wieder den Store fragt. Für langlebige Prozesse (Queue-Worker) + * und für Tests, die den Cache-Store zurücksetzen. + */ + public static function resetMemo(): void + { + self::$generation = null; + } + + /** + * Verwirft sämtliche `web.`-Seiten-Caches, indem die Generation erhöht + * wird. Die alten Einträge laufen über ihre TTL aus und werden nie wieder + * gelesen. + */ + public static function flush(): void + { + $current = self::generation(); + $next = (int) Cache::increment(self::GenerationKey); + + // Der Zähler MUSS echt weiterlaufen. Wurde er im Store verworfen + // (Eviction, `cache:clear`), liefert increment() wieder 1 und die alte + // Generation bliebe gültig — dann würde ein Flush stillschweigend + // wirkungslos und entfernte Inhalte blieben in den Listen stehen. + if ($next <= $current) { + $next = $current + 1; + Cache::forever(self::GenerationKey, $next); + } + + self::$generation = $next; } /** diff --git a/database/migrations/2026_08_25_160000_add_gone_at_to_companies_and_press_releases_tables.php b/database/migrations/2026_08_25_160000_add_gone_at_to_companies_and_press_releases_tables.php new file mode 100644 index 00000000..839c9e5e --- /dev/null +++ b/database/migrations/2026_08_25_160000_add_gone_at_to_companies_and_press_releases_tables.php @@ -0,0 +1,65 @@ +100k Zeilen) kann ein abgebrochener Deploy die Spalte bereits + * angelegt haben, ohne dass der Migrations-Eintrag geschrieben wurde. + */ +return new class extends Migration +{ + public function up(): void + { + if (! Schema::hasColumn('companies', 'gone_at')) { + Schema::table('companies', function (Blueprint $table) { + $table->timestamp('gone_at')->nullable()->after('is_active'); + }); + } + + if (! Schema::hasColumn('press_releases', 'gone_at')) { + Schema::table('press_releases', function (Blueprint $table) { + $table->timestamp('gone_at')->nullable()->after('depublished_at'); + }); + } + } + + public function down(): void + { + if (Schema::hasColumn('companies', 'gone_at')) { + Schema::table('companies', function (Blueprint $table) { + $table->dropColumn('gone_at'); + }); + } + + if (Schema::hasColumn('press_releases', 'gone_at')) { + Schema::table('press_releases', function (Blueprint $table) { + $table->dropColumn('gone_at'); + }); + } + } +}; diff --git a/database/migrations/2026_09_01_090000_add_gone_restore_state_to_companies_and_press_releases_tables.php b/database/migrations/2026_09_01_090000_add_gone_restore_state_to_companies_and_press_releases_tables.php new file mode 100644 index 00000000..f7fa554c --- /dev/null +++ b/database/migrations/2026_09_01_090000_add_gone_restore_state_to_companies_and_press_releases_tables.php @@ -0,0 +1,60 @@ +boolean('is_active_before_gone')->nullable()->after('gone_at'); + }); + } + + if (! Schema::hasColumn('press_releases', 'no_export_before_gone')) { + Schema::table('press_releases', function (Blueprint $table) { + $table->boolean('no_export_before_gone')->nullable()->after('gone_at'); + }); + } + } + + public function down(): void + { + if (Schema::hasColumn('companies', 'is_active_before_gone')) { + Schema::table('companies', function (Blueprint $table) { + $table->dropColumn('is_active_before_gone'); + }); + } + + if (Schema::hasColumn('press_releases', 'no_export_before_gone')) { + Schema::table('press_releases', function (Blueprint $table) { + $table->dropColumn('no_export_before_gone'); + }); + } + } +}; diff --git a/resources/views/livewire/admin/companies/edit.blade.php b/resources/views/livewire/admin/companies/edit.blade.php index 0ebfdaca..eabcb4ed 100644 --- a/resources/views/livewire/admin/companies/edit.blade.php +++ b/resources/views/livewire/admin/companies/edit.blade.php @@ -2,10 +2,12 @@ use App\Enums\Portal; use App\Models\Company; +use App\Services\Company\CompanyRemovalService; use App\Services\Image\ImageService; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; use Livewire\Attributes\Layout; +use Livewire\Attributes\Locked; use Livewire\Attributes\Title; use Livewire\Attributes\Validate; use Livewire\Volt\Component; @@ -15,6 +17,14 @@ new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends { use WithFileUploads; + /** + * Die bearbeitete Firma. #[Locked], weil save(), deleteCompany(), + * markGone() und restoreGone() alle auf diesem Wert arbeiten — ohne das + * Attribut könnte der Client ihn im Livewire-Payload austauschen und die + * Aktion träfe eine andere Firma als die im Formular angezeigte. + * Gegenstück zum #[Locked] $id der PM-Bearbeitung. + */ + #[Locked] public int $companyId; public string $portal = 'both'; @@ -72,6 +82,12 @@ new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends public bool $is_active = true; + /** Zeitpunkt der 410-Entfernung des Newsrooms (formatiert) oder null. */ + public ?string $goneAt = null; + + /** Beim Entfernen mit 410 zusätzlich alle PMs der Firma entfernen. */ + public bool $goneIncludesPressReleases = false; + public function mount(int $id): void { $this->companyId = $id; @@ -98,6 +114,7 @@ new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends $this->country = $company->country_code ?? 'DE'; $this->is_verified = false; $this->is_active = (bool) $company->is_active; + $this->goneAt = $company->gone_at?->format('d.m.Y H:i'); $this->current_logo_url = $company->logoUrl(); } @@ -186,6 +203,50 @@ new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends ]; } + /** + * Entfernung mit 410 statt Löschung: die Newsroom-URL liefert danach einen + * Tombstone (410 Gone) statt eines 404. Umkehrbar über restoreGone(). + */ + public function markGone(CompanyRemovalService $removal): void + { + $this->authorize('companies:write'); + + $company = Company::query()->find($this->companyId); + if (! $company) { + session()->flash('error', __('Die angeforderte Firma wurde nicht gefunden.')); + $this->redirect(route('admin.companies.index'), navigate: true); + + return; + } + + $affected = $removal->markGone($company, $this->goneIncludesPressReleases); + + session()->flash('success', $affected > 0 + ? __('Newsroom mit 410 entfernt — inklusive :count Pressemitteilung(en).', ['count' => $affected]) + : __('Newsroom mit 410 entfernt. Die URL liefert jetzt 410 Gone.')); + + $this->redirect(route('admin.companies.index'), navigate: true); + } + + public function restoreGone(CompanyRemovalService $removal): void + { + $this->authorize('companies:write'); + + $company = Company::query()->find($this->companyId); + if (! $company) { + session()->flash('error', __('Die angeforderte Firma wurde nicht gefunden.')); + $this->redirect(route('admin.companies.index'), navigate: true); + + return; + } + + $removal->restoreFromGone($company); + + session()->flash('success', __('410-Entfernung aufgehoben. Bereits entfernte Pressemitteilungen bleiben entfernt.')); + + $this->redirect(route('admin.companies.edit', $this->companyId), navigate: true); + } + public function deleteCompany(): void { $this->authorize('companies:write'); @@ -411,18 +472,46 @@ new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends
{{ __('Aktionen') }}
+ @if ($goneAt) +
+ {{ __('Dieser Newsroom ist mit 410 entfernt (seit :date). Die öffentliche Newsroom-URL liefert einen Tombstone statt Inhalten.', ['date' => $goneAt]) }} +
+ @endif
- - - {{ __('Löschen') }} - - +
+ @if ($goneAt) + + {{ __('410-Entfernung aufheben') }} + + @else + + {{ __('Entfernen mit 410') }} + + @endif + + + {{ __('Vollständig löschen') }} + + +
{{ __('Abbrechen') }} @@ -435,12 +524,39 @@ new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends + +
+
+ {{ __('Newsroom mit 410 entfernen?') }} + + {{ __('Der Newsroom wird deaktiviert und verschwindet aus Newsroom-Übersicht und Sitemap. Seine bestehende URL antwortet danach mit „410 Gone“ und zeigt einen Hinweis auf die Entfernung — statt eines 404. Die Firma bleibt in der Verwaltung erhalten, die Entfernung ist umkehrbar.') }} + +
+ + + +
+ + {{ __('Abbrechen') }} + + + + {{ __('Mit 410 entfernen') }} + +
+
+
+
{{ __('Firma wirklich löschen?') }} - {{ __('Diese Aktion kann nicht direkt rückgängig gemacht werden. Die Firma wird archiviert (Soft Delete) und aus den Standardlisten entfernt.') }} + {{ __('Diese Aktion kann nicht direkt rückgängig gemacht werden. Die Firma wird archiviert (Soft Delete) und aus den Standardlisten entfernt. Die öffentliche Newsroom-URL antwortet danach mit 404. Soll die Adresse stattdessen einen Entfernungs-Hinweis mit „410 Gone“ ausliefern, brechen Sie hier ab und wählen „Entfernen mit 410“.') }}
diff --git a/resources/views/livewire/admin/press-releases/edit.blade.php b/resources/views/livewire/admin/press-releases/edit.blade.php index 06c9193a..5292998f 100644 --- a/resources/views/livewire/admin/press-releases/edit.blade.php +++ b/resources/views/livewire/admin/press-releases/edit.blade.php @@ -34,6 +34,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl #[Locked] public int $id; + /** Zeitpunkt der 410-Entfernung (formatiert) oder null. */ + #[Locked] + public ?string $goneAt = null; + public string $portal = ''; public string $language = 'de'; @@ -94,6 +98,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl $pr = $this->findAdminPR($id); + $this->goneAt = $pr->gone_at?->format('d.m.Y H:i'); $this->portal = $pr->portal->value; $this->language = $pr->language; $this->companyId = $pr->company_id; @@ -367,6 +372,32 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl Flux::modal('confirm-status-change')->close(); } + /** + * Entfernung mit 410 statt Löschung: die Detail-URL liefert danach einen + * Tombstone (410 Gone) statt eines 404. Umkehrbar über restoreGone(). + */ + public function markGone(): void + { + $this->authorize('press-releases:publish'); + + app(PressReleaseService::class)->markGone($this->findAdminPR($this->id)); + + Flux::toast(text: __('Pressemitteilung entfernt — die URL liefert jetzt 410 Gone.'), variant: 'success'); + + $this->redirect(route('admin.press-releases.index'), navigate: true); + } + + public function restoreGone(): void + { + $this->authorize('press-releases:publish'); + + app(PressReleaseService::class)->restoreFromGone($this->findAdminPR($this->id)); + + Flux::toast(text: __('410-Entfernung aufgehoben. Die Pressemitteilung ist archiviert und kann neu veröffentlicht werden.'), variant: 'success'); + + $this->redirect(route('admin.press-releases.edit', $this->id), navigate: true); + } + public function deletePressRelease(): void { $this->authorize('press-releases:publish'); @@ -1018,9 +1049,25 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl {{ __('Änderungen speichern') }} + @if ($goneAt) +
+ {{ __('Diese Pressemitteilung ist mit 410 entfernt (seit :date). Die öffentliche URL liefert einen Tombstone statt Inhalten.', ['date' => $goneAt]) }} +
+ + {{ __('410-Entfernung aufheben') }} + + @else + + + {{ __('Entfernen mit 410 (Tombstone)') }} + + + @endif - {{ __('Pressemitteilung löschen') }} + {{ __('Vollständig löschen') }}
@@ -1065,16 +1112,34 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
+ +
+
+ {{ __('Pressemitteilung mit 410 entfernen?') }} + + {{ __('Die Mitteilung verschwindet aus allen öffentlichen Listen, Feeds und der Sitemap. Ihre bestehende URL antwortet danach mit „410 Gone“ und zeigt einen Hinweis auf die Entfernung — statt eines 404. Der Datensatz bleibt erhalten, die Entfernung ist umkehrbar.') }} + +
+
+ + {{ __('Abbrechen') }} + + {{ __('Mit 410 entfernen') }} +
+
+
+
{{ __('Pressemitteilung löschen?') }} @if ($currentStatus === 'published') - {{ __('Diese Pressemitteilung ist veröffentlicht. Sie wird nicht entfernt, sondern archiviert und der Inhalt wird durch den voreingestellten Ersatztext ersetzt, damit die URL keinen 404-Fehler erzeugt.') }} + {{ __('Diese Pressemitteilung ist veröffentlicht. Sie wird nicht entfernt, sondern archiviert und der Inhalt wird durch den voreingestellten Ersatztext ersetzt.') }} @else {{ __('Diese Pressemitteilung wird per Soft Delete aus den Standardlisten entfernt.') }} @endif + {{ __('Die öffentliche URL antwortet danach mit 404. Soll die Adresse stattdessen einen Entfernungs-Hinweis mit „410 Gone“ ausliefern, brechen Sie hier ab und wählen „Entfernen mit 410 (Tombstone)“.') }}
diff --git a/resources/views/web/newsroom-tombstone.blade.php b/resources/views/web/newsroom-tombstone.blade.php new file mode 100644 index 00000000..f88d3e96 --- /dev/null +++ b/resources/views/web/newsroom-tombstone.blade.php @@ -0,0 +1,37 @@ +@extends('web.layouts.web-master') + +@section('title', 'Newsroom entfernt') +@section('meta_description', 'Dieser Newsroom wurde aus der öffentlichen Ansicht entfernt.') + +{{-- + Tombstone (410 Gone) für einen Newsroom: Die Unternehmensseite wurde + bewusst aus der öffentlichen Ansicht genommen. Bewusst ohne Firmenname + und Inhalte — der Zweck der Entfernung ist die Entfernung. Gegenstück zur + vollständigen Löschung, die weiterhin 404 liefert. +--}} +@section('content') + +
+

+ Der Newsroom unter dieser Adresse wurde dauerhaft aus der öffentlichen Ansicht + entfernt und steht nicht mehr zur Verfügung. +

+

+ Bitte beachten Sie: Kopien in Suchmaschinen-Caches oder Archiven Dritter können + unabhängig davon zeitweise weiter verfügbar sein — darauf haben wir keinen Einfluss. +

+

+ Eine Übersicht der verfügbaren Newsrooms finden Sie in unserer + Newsroom-Übersicht. +

+

+ Wenn Sie ein rechtliches Anliegen zu Inhalten auf diesem Portal haben, nutzen Sie + bitte unser Meldeverfahren — Meldungen sind kostenlos. +

+
+
+@endsection diff --git a/resources/views/web/release-tombstone.blade.php b/resources/views/web/release-tombstone.blade.php index 8d1924fc..4c3b49e8 100644 --- a/resources/views/web/release-tombstone.blade.php +++ b/resources/views/web/release-tombstone.blade.php @@ -1,13 +1,19 @@ +@php + /** @var string $reason 'depublished' (T12, Wunsch des Absenders) oder 'removed' (redaktionell/rechtlich) */ + $reason = $reason ?? 'depublished'; +@endphp @extends('web.layouts.web-master') @section('title', 'Pressemitteilung entfernt') @section('meta_description', 'Diese Pressemitteilung wurde aus der öffentlichen Ansicht entfernt.') {{-- - T12-Tombstone (410 Gone): Die PM wurde auf Wunsch des Absenders - kostenpflichtig depubliziert. Bewusst ohne Titel/Inhalte — der Zweck - der Depublikation ist die Entfernung; ehrlicher Hinweis auf Grenzen - (Suchmaschinen-Caches, Drittarchive) statt stillem 404. + Tombstone (410 Gone): Die PM wurde bewusst aus der öffentlichen Ansicht + genommen — entweder auf Wunsch des Absenders (kostenpflichtige + Depublikation, T12) oder redaktionell/rechtlich über die Verwaltung. + Bewusst ohne Titel/Inhalte — der Zweck der Entfernung ist die Entfernung; + ehrlicher Hinweis auf Grenzen (Suchmaschinen-Caches, Drittarchive) statt + stillem 404. --}} @section('content')
-

- Die Pressemitteilung unter dieser Adresse wurde auf Wunsch des Absenders dauerhaft - aus der öffentlichen Ansicht entfernt. -

+ @if ($reason === 'depublished') +

+ Die Pressemitteilung unter dieser Adresse wurde auf Wunsch des Absenders dauerhaft + aus der öffentlichen Ansicht entfernt. +

+ @else +

+ Die Pressemitteilung unter dieser Adresse wurde dauerhaft aus der öffentlichen + Ansicht entfernt und steht nicht mehr zur Verfügung. +

+ @endif

Bitte beachten Sie: Kopien in Suchmaschinen-Caches oder Archiven Dritter können unabhängig davon zeitweise weiter verfügbar sein — darauf haben wir keinen Einfluss. diff --git a/tests/Feature/Web/BoostPlacementTest.php b/tests/Feature/Web/BoostPlacementTest.php index 9e74d8e7..158394dd 100644 --- a/tests/Feature/Web/BoostPlacementTest.php +++ b/tests/Feature/Web/BoostPlacementTest.php @@ -195,7 +195,7 @@ test('booking a category boost invalidates the category page but keeps the homep // Branchenseite sofort aktualisiert, Startseiten-Cache bleibt stehen // (der Kategorie-Boost berührt die Startseite nicht). - expect(Cache::has('web.home.presseecho.de'))->toBeTrue(); + expect(Cache::has(webPageCacheKey('web.home.presseecho.de')))->toBeTrue(); $this->get('https://presseecho.test/de/category/energie-klima') ->assertSuccessful() ->assertSeeText('Top-Platzierung'); diff --git a/tests/Feature/Web/CategoryNavigationCacheTest.php b/tests/Feature/Web/CategoryNavigationCacheTest.php index 8df54189..25b104cd 100644 --- a/tests/Feature/Web/CategoryNavigationCacheTest.php +++ b/tests/Feature/Web/CategoryNavigationCacheTest.php @@ -32,9 +32,9 @@ test('category navigation caches per portal, language and limit separately', fun CategoryNavigation::items(5); - expect(Cache::has('web.category-nav.presseecho.de.5'))->toBeTrue() - ->and(Cache::has('web.category-nav.presseecho.de.all'))->toBeFalse(); + expect(Cache::has(webPageCacheKey('web.category-nav.presseecho.de.5')))->toBeTrue() + ->and(Cache::has(webPageCacheKey('web.category-nav.presseecho.de.all')))->toBeFalse(); CategoryNavigation::items(null); - expect(Cache::has('web.category-nav.presseecho.de.all'))->toBeTrue(); + expect(Cache::has(webPageCacheKey('web.category-nav.presseecho.de.all')))->toBeTrue(); }); diff --git a/tests/Feature/Web/GoneTombstoneTest.php b/tests/Feature/Web/GoneTombstoneTest.php new file mode 100644 index 00000000..9cb46de4 --- /dev/null +++ b/tests/Feature/Web/GoneTombstoneTest.php @@ -0,0 +1,359 @@ +seed(RolesAndPermissionsSeeder::class); +}); + +function goneTestCompany(): Company +{ + return Company::factory()->create([ + 'portal' => Portal::Businessportal24, + 'is_active' => true, + ]); +} + +function goneTestRelease(?Company $company = null): PressRelease +{ + return PressRelease::factory() + ->published() + ->forPortal(Portal::Businessportal24) + ->create([ + 'company_id' => $company?->getKey(), + 'published_at' => now()->subDay(), + ]); +} + +function goneTestAdmin(): User +{ + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + + return $admin; +} + +test('a press release marked gone answers 410 with a tombstone instead of 404', function () { + /** @var TestCase $this */ + $release = goneTestRelease(); + $url = 'https://businessportal24.test/de/press-release/'.$release->slug; + + $this->get($url)->assertOk()->assertSeeText($release->title); + + app(PressReleaseService::class)->markGone($release); + + $this->get($url) + ->assertStatus(410) + ->assertSeeText('Diese Pressemitteilung wurde entfernt') + ->assertDontSeeText($release->title) + // Eigener Wortlaut: keine Depublikation "auf Wunsch des Absenders". + ->assertDontSeeText('auf Wunsch des Absenders'); +}); + +test('marking a press release gone archives it, blocks export and locks the re-sync', function () { + /** @var TestCase $this */ + $release = goneTestRelease(); + + app(PressReleaseService::class)->markGone($release); + $release->refresh(); + + expect($release->gone_at)->not->toBeNull() + ->and($release->status)->toBe(PressReleaseStatus::Archived) + ->and($release->no_export)->toBeTrue() + // Ohne Re-Sync-Sperre würde der nächste Importer-Lauf die Entfernung + // wieder überschreiben. + ->and($release->isSyncLocked())->toBeTrue() + ->and($release->deleted_at)->toBeNull() + ->and($release->statusLogs()->where('source', 'gone')->count())->toBe(1); +}); + +test('a fully deleted press release still answers 404', function () { + /** @var TestCase $this */ + $release = goneTestRelease(); + $url = 'https://businessportal24.test/de/press-release/'.$release->slug; + + $release->delete(); + + $this->get($url)->assertNotFound(); +}); + +test('lifting the gone marker makes the press release publishable again', function () { + /** @var TestCase $this */ + $release = goneTestRelease(); + $service = app(PressReleaseService::class); + + $service->markGone($release); + $service->restoreFromGone($release->fresh()); + + $release->refresh(); + + expect($release->gone_at)->toBeNull() + // Der Status bleibt bewusst archiviert — die Freigabe ist ein + // eigener redaktioneller Schritt. + ->and($release->status)->toBe(PressReleaseStatus::Archived); + + $this->get('https://businessportal24.test/de/press-release/'.$release->slug)->assertNotFound(); +}); + +test('a newsroom marked gone answers 410 with a tombstone instead of 404', function () { + /** @var TestCase $this */ + $company = goneTestCompany(); + goneTestRelease($company); + $url = 'https://businessportal24.test/de/newsroom/'.$company->slug; + + $this->get($url)->assertOk(); + + app(CompanyRemovalService::class)->markGone($company); + + $this->get($url) + ->assertStatus(410) + ->assertSeeText('Dieser Newsroom wurde entfernt') + ->assertDontSeeText($company->name); +}); + +test('the newsroom tombstone beats a warm page cache', function () { + /** @var TestCase $this */ + $company = goneTestCompany(); + goneTestRelease($company); + $url = 'https://businessportal24.test/de/newsroom/'.$company->slug; + + // Erst aufwärmen, damit für den Slug ein Seiten-Cache existiert … + $this->get($url)->assertOk(); + + // … dann den Marker OHNE Cache-Invalidierung setzen. Damit prüft der Test + // die Absicherung in NewsroomDetailPageData (Gone-Prüfung vor dem Cache) + // und nicht bloß das Verwerfen der Caches im Service. + $company->forceFill(['gone_at' => now(), 'is_active' => false])->saveQuietly(); + + $this->get($url)->assertStatus(410); +}); + +test('removing content drops the cached public listings immediately', function () { + /** @var TestCase $this */ + $release = goneTestRelease(); + + // Startseiten-/Ticker-Caches aufwärmen … + $this->get('https://businessportal24.test/de')->assertOk(); + + app(PressReleaseService::class)->markGone($release); + + // … die entfernte Meldung darf danach in keiner Liste mehr auftauchen, + // auch nicht aus dem Stale-Fenster (bis zu einer Stunde). + $this->get('https://businessportal24.test/de') + ->assertOk() + ->assertDontSeeText($release->title); +}); + +test('a fully deleted newsroom still answers 404', function () { + /** @var TestCase $this */ + $company = goneTestCompany(); + goneTestRelease($company); + $url = 'https://businessportal24.test/de/newsroom/'.$company->slug; + + $company->delete(); + + $this->get($url)->assertNotFound(); +}); + +test('removing a newsroom leaves its press releases alone unless asked to cascade', function () { + /** @var TestCase $this */ + $company = goneTestCompany(); + $release = goneTestRelease($company); + + app(CompanyRemovalService::class)->markGone($company); + + expect($release->fresh()->gone_at)->toBeNull(); + $this->get('https://businessportal24.test/de/press-release/'.$release->slug)->assertOk(); +}); + +test('removing a newsroom can cascade the 410 to its press releases', function () { + /** @var TestCase $this */ + $company = goneTestCompany(); + $release = goneTestRelease($company); + + $affected = app(CompanyRemovalService::class)->markGone($company, includePressReleases: true); + + expect($affected)->toBe(1) + ->and($release->fresh()->gone_at)->not->toBeNull(); + + $this->get('https://businessportal24.test/de/press-release/'.$release->slug)->assertStatus(410); +}); + +test('cascading a newsroom removal flushes the page cache once, not per release', function () { + /** @var TestCase $this */ + $company = goneTestCompany(); + goneTestRelease($company); + goneTestRelease($company); + goneTestRelease($company); + + $before = WebPageCache::generation(); + + $affected = app(CompanyRemovalService::class)->markGone($company, includePressReleases: true); + + // Ein Flush verwirft ALLE `web.`-Seiten-Caches auf einmal. Einer pro + // Mitteilung wäre ein selbst ausgelöster Cache-Stampede: Eine Bestandsfirma + // mit dreistellig vielen Meldungen zwänge die gesamte öffentliche Seite + // ebenso oft in den Neuaufbau. + expect($affected)->toBe(3) + ->and(WebPageCache::generation())->toBe($before + 1); +}); + +test('lifting a newsroom removal keeps individually removed press releases gone', function () { + /** @var TestCase $this */ + $company = goneTestCompany(); + $release = goneTestRelease($company); + $removal = app(CompanyRemovalService::class); + + $removal->markGone($company, includePressReleases: true); + $removal->restoreFromGone($company->fresh()); + + $company->refresh(); + + expect($company->gone_at)->toBeNull() + ->and($company->is_active)->toBeTrue() + // Eine einmal entfernte PM kommt nicht automatisch zurück — sonst + // könnte eine rechtlich veranlasste Einzel-Entfernung versehentlich + // mit aufgehoben werden. + ->and($release->fresh()->gone_at)->not->toBeNull(); +}); + +test('lifting a newsroom removal leaves an already inactive newsroom inactive', function () { + /** @var TestCase $this */ + $company = Company::factory()->create([ + 'portal' => Portal::Businessportal24, + 'is_active' => false, + ]); + $removal = app(CompanyRemovalService::class); + + $removal->markGone($company); + $removal->restoreFromGone($company->fresh()); + + $company->refresh(); + + // Die Oberfläche verspricht Umkehrbarkeit. Eine Firma, die schon vor der + // Entfernung deaktiviert war, darf durch das Aufheben nicht öffentlich + // werden — sie stünde sonst plötzlich in Newsroom-Übersicht und Sitemap. + expect($company->gone_at)->toBeNull() + ->and($company->is_active)->toBeFalse() + ->and($company->is_active_before_gone)->toBeNull(); +}); + +test('lifting a press release removal restores the export flag it overwrote', function () { + /** @var TestCase $this */ + $release = goneTestRelease(); + $release->update(['no_export' => false]); + $service = app(PressReleaseService::class); + + $service->markGone($release); + expect($release->fresh()->no_export)->toBeTrue(); + + $service->restoreFromGone($release->fresh()); + + // `no_export` gehört zur Entfernung, nicht zur Mitteilung — sonst stünde + // sie in Kunden- und Admin-Ansicht dauerhaft als „kein Export“ da, ohne + // dass das je jemand gewählt hätte. + expect($release->fresh()->no_export)->toBeFalse() + ->and($release->fresh()->no_export_before_gone)->toBeNull(); +}); + +test('lifting a press release removal keeps an export block that predates it', function () { + /** @var TestCase $this */ + $release = goneTestRelease(); + $release->update(['no_export' => true]); + $service = app(PressReleaseService::class); + + $service->markGone($release); + $service->restoreFromGone($release->fresh()); + + // Umgekehrt gilt dasselbe: Eine schon vorher gesetzte Sperre bleibt. + expect($release->fresh()->no_export)->toBeTrue(); +}); + +test('a press release marked gone disappears from listings and the sitemap', function () { + /** @var TestCase $this */ + $company = goneTestCompany(); + $release = goneTestRelease($company); + + $this->get('https://businessportal24.test/sitemap.xml')->assertSee($release->slug, false); + + app(PressReleaseService::class)->markGone($release); + + $this->get('https://businessportal24.test/sitemap.xml')->assertDontSee($release->slug, false); + $this->get('https://businessportal24.test/de/newsrooms')->assertDontSeeText($company->name); +}); + +test('admin can remove a press release with 410 from the edit page', function () { + /** @var TestCase $this */ + $release = goneTestRelease(); + $this->actingAs(goneTestAdmin()); + + LivewireVolt::test('admin.press-releases.edit', ['id' => $release->id]) + ->call('markGone') + ->assertRedirect(route('admin.press-releases.index')); + + expect($release->fresh()->gone_at)->not->toBeNull(); +}); + +test('admin can remove a newsroom with 410 from the edit page', function () { + /** @var TestCase $this */ + $company = goneTestCompany(); + $release = goneTestRelease($company); + $this->actingAs(goneTestAdmin()); + + LivewireVolt::test('admin.companies.edit', ['id' => $company->id]) + ->set('goneIncludesPressReleases', true) + ->call('markGone') + ->assertRedirect(route('admin.companies.index')); + + $company->refresh(); + + expect($company->gone_at)->not->toBeNull() + ->and($company->is_active)->toBeFalse() + ->and($release->fresh()->gone_at)->not->toBeNull(); +}); + +test('admin can lift a press release removal from the edit page', function () { + /** @var TestCase $this */ + $release = goneTestRelease(); + app(PressReleaseService::class)->markGone($release); + $this->actingAs(goneTestAdmin()); + + LivewireVolt::test('admin.press-releases.edit', ['id' => $release->id]) + ->assertSee('410-Entfernung aufheben') + ->call('restoreGone') + ->assertRedirect(route('admin.press-releases.edit', $release->id)); + + expect($release->fresh()->gone_at)->toBeNull(); +}); + +test('admin can lift a newsroom removal from the edit page', function () { + /** @var TestCase $this */ + $company = goneTestCompany(); + app(CompanyRemovalService::class)->markGone($company); + $this->actingAs(goneTestAdmin()); + + LivewireVolt::test('admin.companies.edit', ['id' => $company->id]) + ->assertSee('410-Entfernung aufheben') + ->call('restoreGone') + ->assertRedirect(route('admin.companies.edit', $company->id)); + + $company->refresh(); + + expect($company->gone_at)->toBeNull() + ->and($company->is_active)->toBeTrue(); +}); diff --git a/tests/Feature/Web/PageDataCacheTest.php b/tests/Feature/Web/PageDataCacheTest.php index 53da457e..6f11d67e 100644 --- a/tests/Feature/Web/PageDataCacheTest.php +++ b/tests/Feature/Web/PageDataCacheTest.php @@ -41,7 +41,7 @@ test('homepage data is cached per portal and language', function () { ->assertSuccessful() ->assertSeeText('Erste Meldung im Cache'); - expect(Cache::has('web.home.presseecho.de'))->toBeTrue(); + expect(Cache::has(webPageCacheKey('web.home.presseecho.de')))->toBeTrue(); PressRelease::factory() ->published() @@ -119,23 +119,23 @@ test('the press release feed caches its front pages but not deep pages or junk t ->assertSuccessful() ->assertSeeText('Feed-Meldung eins'); - expect(Cache::has('web.feed.presseecho.de.all.page.1'))->toBeTrue(); + expect(Cache::has(webPageCacheKey('web.feed.presseecho.de.all.page.1')))->toBeTrue(); // Tiefe Seiten bleiben ungecacht (Schutz vor unbegrenztem Key-Raum). $this->get('https://presseecho.test/de?page=25')->assertSuccessful(); - expect(Cache::has('web.feed.presseecho.de.all.page.25'))->toBeFalse(); + expect(Cache::has(webPageCacheKey('web.feed.presseecho.de.all.page.25')))->toBeFalse(); // Unbekannte timeframe-Werte aus der URL fallen auf "all" zurück und // erzeugen keinen eigenen (poisonbaren) Cache-Key. $this->get('https://presseecho.test/de?timeframe=boesewert')->assertSuccessful(); - expect(Cache::has('web.feed.presseecho.de.boesewert.page.1'))->toBeFalse(); + expect(Cache::has(webPageCacheKey('web.feed.presseecho.de.boesewert.page.1')))->toBeFalse(); // Der Cache hält nur items + total — der Paginator (inkl. Host-URLs der // Pagination-Links) wird pro Request neu gebaut, damit der gecachte Feed // nicht den Host des Cache-Erzeugers an andere Besucher ausliefert. - expect(Cache::get('web.feed.presseecho.de.all.page.1')) + expect(Cache::get(webPageCacheKey('web.feed.presseecho.de.all.page.1'))) ->toBeArray() ->toHaveKeys(['items', 'total']); }); @@ -156,18 +156,18 @@ test('category page data is cached per portal, language and slug but 404s are no $this->get('https://businessportal24.test/de/category/mobilitaet')->assertSuccessful(); - expect(Cache::has('web.category.businessportal24.de.mobilitaet'))->toBeTrue(); + expect(Cache::has(webPageCacheKey('web.category.businessportal24.de.mobilitaet')))->toBeTrue(); $this->get('https://businessportal24.test/de/category/gibt-es-nicht')->assertNotFound(); - expect(Cache::has('web.category.businessportal24.de.gibt-es-nicht'))->toBeFalse(); + expect(Cache::has(webPageCacheKey('web.category.businessportal24.de.gibt-es-nicht')))->toBeFalse(); }); test('search sidebar data is cached per portal and language', function () { /** @var TestCase $this */ $this->get('https://businessportal24.test/de/suche')->assertSuccessful(); - expect(Cache::has('web.search.most-read.businessportal24-both.de'))->toBeTrue(); + expect(Cache::has(webPageCacheKey('web.search.most-read.businessportal24-both.de')))->toBeTrue(); }); test('newsroom detail data is cached per portal language slug and page', function () { @@ -188,7 +188,7 @@ test('newsroom detail data is cached per portal language slug and page', functio $this->get('https://businessportal24.test/de/newsroom/cache-newsroom?page=2') ->assertSuccessful(); - expect(Cache::has('web.newsroom.businessportal24-both.de.cache-newsroom.page.2'))->toBeTrue(); + expect(Cache::has(webPageCacheKey('web.newsroom.businessportal24-both.de.cache-newsroom.page.2')))->toBeTrue(); }); test('newsroom detail cache key aligns with the paginator so an invalid page cannot poison another page', function () { @@ -211,8 +211,8 @@ test('newsroom detail cache key aligns with the paginator so an invalid page can $this->get('https://businessportal24.test/de/newsroom/poison-newsroom?page=2.5') ->assertSuccessful(); - expect(Cache::has('web.newsroom.businessportal24-both.de.poison-newsroom.page.1'))->toBeTrue(); - expect(Cache::has('web.newsroom.businessportal24-both.de.poison-newsroom.page.2'))->toBeFalse(); + expect(Cache::has(webPageCacheKey('web.newsroom.businessportal24-both.de.poison-newsroom.page.1')))->toBeTrue(); + expect(Cache::has(webPageCacheKey('web.newsroom.businessportal24-both.de.poison-newsroom.page.2')))->toBeFalse(); }); test('newsrooms unfiltered index is cached but filtered searches are not', function () { @@ -232,7 +232,7 @@ test('newsrooms unfiltered index is cached but filtered searches are not', funct ->assertSuccessful() ->assertSeeText('SolarTec Bestand'); - expect(Cache::has('web.newsrooms.businessportal24,both.de.page.1'))->toBeTrue(); + expect(Cache::has(webPageCacheKey('web.newsrooms.businessportal24,both.de.page.1')))->toBeTrue(); // Gefilterte Suche wird NICHT gecacht (Schutz vor unbegrenztem Key-Raum): // eine nach dem ersten Aufruf angelegte Firma erscheint sofort. @@ -259,7 +259,7 @@ test('deep newsrooms pages are not cached', function () { /** @var TestCase $this */ $this->get('https://businessportal24.test/de/newsrooms?page=25')->assertSuccessful(); - expect(Cache::has('web.newsrooms.businessportal24,both.de.page.25'))->toBeFalse(); + expect(Cache::has(webPageCacheKey('web.newsrooms.businessportal24,both.de.page.25')))->toBeFalse(); }); test('newsrooms cache does not retain unrelated query parameters in pagination links', function () { diff --git a/tests/Pest.php b/tests/Pest.php index c2de277b..14ffefae 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -3,6 +3,7 @@ use App\Models\Plan; use App\Models\User; use App\Services\CurrentPortalContext; +use App\Support\WebPageCache; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -74,3 +75,14 @@ function subscribeUserToPlan(User $user, Plan $plan, string $interval = 'monthly 'quantity' => 1, ]); } + +/** + * Seiten-Cache-Keys tragen seit der Entfernung mit 410 eine Generations-Nummer + * (siehe WebPageCache::key): Ein Flush erhöht sie und verwirft damit alle + * `web.`-Seiten-Caches auf einmal. Die Tests prüfen weiterhin die fachliche + * Key-Struktur, nur eben generationsunabhängig. + */ +function webPageCacheKey(string $key): string +{ + return 'web.g'.WebPageCache::generation().'.'.substr($key, strlen('web.')); +}