From d6e49c11c3e145bf6be2800ca15b37f0acd6f4b4 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Fri, 2 Oct 2026 13:09:00 +0000 Subject: [PATCH] =?UTF-8?q?web/seo:=20=C3=84nderungsdatum=20unabh=C3=A4ngi?= =?UTF-8?q?g=20von=20Aufrufen,=20EN-URLs=20in=20Sitemap=20und=20Legacy-API?= =?UTF-8?q?,=20signierte=20Bounce-Referenz,=20Merge/Canonical=20konsistent?= =?UTF-8?q?=20(Umsetzungsaudit=2029.09.:=20R-02=20bis=20R-11,=20N17,=20N18?= =?UTF-8?q?,=20OPT-1,=20OPT-2;=2002.10.:=20O01)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - R-02/K-03: Aufrufaggregation und Merge ändern updated_at nicht mehr; neue Spalte press_releases.content_changed_at (INSTANT), contentModifiedAt() für Sitemap-lastmod, dateModified und article:modified_time - R-03: Sitemap und Legacy-view_url mit /en/ für englische Meldungen - R-04/O01: X-Pk-Ref je Empfänger per HMAC signiert; auf eine Sperre zählt — hart wie weich — nur ein gültig signierter Rückläufer, unsignierte standardmäßig nur zur Sichtung (BOUNCE_UNSIGNED_KIND=policy) - R-05/R-06: Unmerge setzt nur unveränderte Füllwerte zurück (company_merge_moves.merged_value), Canonical-Neubewertung nach Merge und Unmerge, assignAll setzt veraltete Verweise zurück - R-07 bis R-11: Crawler zählen nicht als Aufruf, Mail-Klick gedrosselt und verzögert geschrieben, Claim-Zähler erst nach Versand, email_bounces nach 395 Tagen gelöscht, Canonical-Portal über Kategorie-ID, llms-Cache geleert, Feed-Teaser ohne Doppel-Escaping, Merge-Ketten aufgelöst - N17/N18: kein falsches „gelistet seit", Rollen im Indexbericht wie Sitemap - OPT-1/OPT-2: topFeeds in SQL, purge-events löscht nach Grenz-ID Enthält aus PressRelease.php auch die Card-Bild-Helfer (B-01) und wasEverPublished()/neverPublished() (A-05), weil die Datei ungeteilt committet ist. Co-Authored-By: Claude Opus 5.5 --- .../Commands/AggregatePressReleaseViews.php | 4 + .../Commands/AssignCanonicalPressReleases.php | 11 +- .../Commands/PurgeConversionEvents.php | 36 +++- .../Commands/PurgePressReleaseEvents.php | 35 +++- .../Commands/ReportDuplicatePressReleases.php | 11 +- .../Legacy/LegacyPressReleaseController.php | 4 +- app/Http/Controllers/Web/FeedController.php | 4 +- .../Controllers/Web/LlmsTxtController.php | 2 +- .../Web/NewsroomClaimController.php | 11 +- .../Controllers/Web/SitemapController.php | 13 +- app/Http/Middleware/RecordMailLinkClick.php | 64 +++++-- app/Listeners/GateOutgoingMail.php | 27 ++- app/Models/CompanyMergeMove.php | 1 + app/Models/PressRelease.php | 91 ++++++++-- app/Services/Company/CompanyMergeService.php | 97 +++++++++- app/Services/Company/MergedNewsroomLookup.php | 41 ++++- app/Services/Mail/BounceMailProcessor.php | 49 ++++- app/Services/Mail/BounceReference.php | 124 +++++++++++++ .../PressRelease/CanonicalAssigner.php | 169 ++++++++++++++++-- app/Services/PressRelease/FeedFetchReport.php | 58 +++--- app/Services/PressRelease/VisibilityStats.php | 29 ++- .../SearchConsole/IndexStatusReport.php | 41 ++++- app/Services/Web/ReleaseDetailPageData.php | 15 +- app/Support/WebCaches.php | 6 +- config/bounces.php | 21 ++- config/canonical.php | 28 ++- config/conversion.php | 21 +++ ...ent_changed_at_to_press_releases_table.php | 35 ++++ ...ged_value_to_company_merge_moves_table.php | 29 +++ .../admin/categories/create.blade.php | 8 + .../admin/reports/index-status.blade.php | 2 +- resources/views/web/release-detail.blade.php | 15 +- .../Admin/AdminCategoryManagementTest.php | 20 +++ tests/Feature/Api/LegacyServiceParityTest.php | 18 ++ tests/Feature/BounceMailProcessingTest.php | 148 ++++++++++++++- tests/Feature/CanonicalAssignmentTest.php | 73 ++++++++ tests/Feature/CompanyMergeTest.php | 117 ++++++++++++ tests/Feature/ConversionFunnelTest.php | 30 ++++ tests/Feature/EmailSuppressionTest.php | 18 +- tests/Feature/IndexStatusCheckTest.php | 19 ++ tests/Feature/PurgePressReleaseEventsTest.php | 23 +++ tests/Feature/VisibilityStatsTest.php | 23 +++ tests/Feature/Web/ContentChangedAtTest.php | 112 ++++++++++++ tests/Feature/Web/CrawlerHitLogTest.php | 20 +++ tests/Feature/Web/FeedFetchLogTest.php | 40 +++++ tests/Feature/Web/FeedTest.php | 12 ++ tests/Feature/Web/LlmsTxtTest.php | 13 ++ tests/Feature/Web/NewsroomClaimTest.php | 22 +++ 48 files changed, 1676 insertions(+), 134 deletions(-) create mode 100644 app/Services/Mail/BounceReference.php create mode 100644 database/migrations/2026_09_29_170000_add_content_changed_at_to_press_releases_table.php create mode 100644 database/migrations/2026_09_29_170100_add_merged_value_to_company_merge_moves_table.php create mode 100644 tests/Feature/Web/ContentChangedAtTest.php diff --git a/app/Console/Commands/AggregatePressReleaseViews.php b/app/Console/Commands/AggregatePressReleaseViews.php index 64f17d4a..cf528287 100644 --- a/app/Console/Commands/AggregatePressReleaseViews.php +++ b/app/Console/Commands/AggregatePressReleaseViews.php @@ -189,9 +189,13 @@ class AggregatePressReleaseViews extends Command } foreach ($counts as $pressReleaseId => $views) { + // toBase(): Eloquents increment() setzt updated_at mit — das + // hat Sitemap-lastmod und dateModified jeder aufgerufenen + // Meldung verfälscht (Umsetzungsaudit 29.09.2026, R-02). PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->whereKey((int) $pressReleaseId) + ->toBase() ->increment('hits', (int) $views); $totalReleases++; diff --git a/app/Console/Commands/AssignCanonicalPressReleases.php b/app/Console/Commands/AssignCanonicalPressReleases.php index 9adfd1bd..2e32da46 100644 --- a/app/Console/Commands/AssignCanonicalPressReleases.php +++ b/app/Console/Commands/AssignCanonicalPressReleases.php @@ -45,10 +45,19 @@ class AssignCanonicalPressReleases extends Command $dryRun ? 'würden geändert' : 'geändert', )); + if ($result['reset'] > 0) { + $this->info(sprintf( + '%s%d veraltete Verweise %s.', + $dryRun ? '[Dry-Run] ' : '', + $result['reset'], + $dryRun ? 'würden zurückgesetzt' : 'zurückgesetzt', + )); + } + // rel=canonical steckt in den gecachten Meldungsseiten, die Sitemaps // (1 h) nennen nur die Erstquelle — nach einer Zuordnung über den // Bestand sollen beide sofort den neuen Stand zeigen. - if (! $dryRun && $result['changed'] > 0) { + if (! $dryRun && ($result['changed'] > 0 || $result['reset'] > 0)) { WebCaches::forgetPublicPages(); $this->line('Seiten- und Sitemap-Caches geleert.'); } diff --git a/app/Console/Commands/PurgeConversionEvents.php b/app/Console/Commands/PurgeConversionEvents.php index 346b35b2..264f45c3 100644 --- a/app/Console/Commands/PurgeConversionEvents.php +++ b/app/Console/Commands/PurgeConversionEvents.php @@ -3,6 +3,7 @@ namespace App\Console\Commands; use App\Models\ConversionEvent; +use App\Models\EmailBounce; use Illuminate\Console\Command; /** @@ -14,6 +15,11 @@ use Illuminate\Console\Command; * hier keine Wasserstandsmarke zu beachten — die Ereignisse werden nirgends * aufsummiert, sie SIND die Auswertung. Wer ältere Zeiträume vergleichen will, * muss den Report vorher laufen lassen und das Ergebnis festhalten. + * + * Mit aufgeräumt: die Rückläufer-Ereignisse (`email_bounces`, Adresse und + * Diagnosetext) nach `bounces.retention_days` (Umsetzungsaudit 29.09.2026, + * R-10). Die Sperrliste `email_suppressions` bleibt — sie ist der Zustand, + * die Ereignisse sind nur seine Herleitung. */ class PurgeConversionEvents extends Command { @@ -22,7 +28,7 @@ class PurgeConversionEvents extends Command {--chunk=5000 : Zeilen je Durchgang} {--dry-run : Nur zählen, nichts löschen}'; - protected $description = 'Begrenzt die Ereignisse der Konversionsleiter auf den Aufbewahrungszeitraum.'; + protected $description = 'Begrenzt die Ereignisse der Konversionsleiter und die Rückläufer-Ereignisse (email_bounces) auf den Aufbewahrungszeitraum.'; public function handle(): int { @@ -39,12 +45,21 @@ class PurgeConversionEvents extends Command $expired = ConversionEvent::query()->where('created_at', '<', $cutoff); + $bounceDays = max(1, (int) config('bounces.retention_days', 395)); + $bounceCutoff = now()->subDays($bounceDays); + $expiredBounces = EmailBounce::query()->where('created_at', '<', $bounceCutoff); + if ($this->option('dry-run')) { $this->info(sprintf( '[Dry-Run] %d Ereignisse älter als %s.', (clone $expired)->count(), $cutoff->format('d.m.Y'), )); + $this->info(sprintf( + '[Dry-Run] %d Rückläufer-Ereignisse älter als %s.', + (clone $expiredBounces)->count(), + $bounceCutoff->format('d.m.Y'), + )); return self::SUCCESS; } @@ -68,6 +83,25 @@ class PurgeConversionEvents extends Command $days, )); + $bouncesDeleted = 0; + + while (true) { + $removed = (clone $expiredBounces)->limit($chunk)->delete(); + + if ($removed === 0) { + break; + } + + $bouncesDeleted += $removed; + } + + $this->info(sprintf( + 'Rückläufer-Ereignisse bereinigt: %d Einträge vor dem %s gelöscht (Aufbewahrung %d Tage, Sperrliste bleibt).', + $bouncesDeleted, + $bounceCutoff->format('d.m.Y'), + $bounceDays, + )); + return self::SUCCESS; } } diff --git a/app/Console/Commands/PurgePressReleaseEvents.php b/app/Console/Commands/PurgePressReleaseEvents.php index 72ec29ec..9619935c 100644 --- a/app/Console/Commands/PurgePressReleaseEvents.php +++ b/app/Console/Commands/PurgePressReleaseEvents.php @@ -7,6 +7,9 @@ use App\Models\FeedFetch; use App\Models\PressReleaseCrawlerHit; use App\Models\PressReleaseEvent; use Illuminate\Console\Command; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Carbon; /** * Begrenzt das Ereignis-Log der Pressemitteilungen auf einen @@ -64,9 +67,13 @@ class PurgePressReleaseEvents extends Command $cutoff = now()->subDays($days); $chunk = max(100, (int) $this->option('chunk')); + // Grenz-ID statt Filter auf created_at (Umsetzungsaudit 29.09.2026, + // OPT-2): press_release_events hat keinen Index auf created_at, jeder + // Block-DELETE mit Datumsfilter liefe über die ganze Tabelle. Alles + // unterhalb der ersten noch frischen ID ist abgelaufen — exakt, auch + // wenn created_at und ID nicht streng gleich laufen. $expired = PressReleaseEvent::query() - ->where('id', '<=', $watermark) - ->where('created_at', '<', $cutoff); + ->where('id', '<=', min($watermark, $this->lastExpiredId(PressReleaseEvent::query(), $cutoff))); if ($this->option('dry-run')) { $this->info(sprintf( @@ -79,7 +86,7 @@ class PurgePressReleaseEvents extends Command // Probelauf muss denselben Umfang nennen. $this->info(sprintf( '[Dry-Run] %d Einträge im Crawler-Abruf-Protokoll und %d im Feed-Abruf-Protokoll älter als %s.', - PressReleaseCrawlerHit::query()->where('created_at', '<', $cutoff)->count(), + PressReleaseCrawlerHit::query()->where('id', '<=', $this->lastExpiredId(PressReleaseCrawlerHit::query(), $cutoff))->count(), FeedFetch::query()->where('created_at', '<', $cutoff)->count(), $cutoff->format('d.m.Y'), )); @@ -109,9 +116,10 @@ class PurgePressReleaseEvents extends Command // Crawler-Abruf-Protokoll (1c.0): gleiche Aufbewahrung, keine // Wasserstandsmarke nötig — es wird nirgends aufsummiert. $crawlerDeleted = 0; + $crawlerUpperId = $this->lastExpiredId(PressReleaseCrawlerHit::query(), $cutoff); while (true) { - $removed = PressReleaseCrawlerHit::query()->where('created_at', '<', $cutoff)->limit($chunk)->delete(); + $removed = PressReleaseCrawlerHit::query()->where('id', '<=', $crawlerUpperId)->limit($chunk)->delete(); if ($removed === 0) { break; @@ -140,4 +148,23 @@ class PurgePressReleaseEvents extends Command return self::SUCCESS; } + + /** + * Höchste ID, unterhalb derer alles vor `$cutoff` liegt: eins unter der + * ersten ID mit einem Zeitpunkt ab `$cutoff`. Die Suche läuft den + * Primärschlüssel aufsteigend ab und liest damit nur die abgelaufenen + * Zeilen (im Tagesbetrieb ein Tag), nicht die ganze Tabelle. + * + * @param Builder $query + */ + private function lastExpiredId(Builder $query, Carbon $cutoff): int + { + $firstFresh = (clone $query)->where('created_at', '>=', $cutoff)->orderBy('id')->value('id'); + + if ($firstFresh === null) { + return (int) ((clone $query)->max('id') ?? 0); + } + + return (int) $firstFresh - 1; + } } diff --git a/app/Console/Commands/ReportDuplicatePressReleases.php b/app/Console/Commands/ReportDuplicatePressReleases.php index 21e087e6..6e616ae9 100644 --- a/app/Console/Commands/ReportDuplicatePressReleases.php +++ b/app/Console/Commands/ReportDuplicatePressReleases.php @@ -2,7 +2,6 @@ namespace App\Console\Commands; -use App\Enums\Portal; use App\Models\PressRelease; use App\Scopes\PortalScope; use App\Services\PressRelease\CanonicalAssigner; @@ -50,19 +49,19 @@ class ReportDuplicatePressReleases extends Command ->leftJoin('categories as c', 'c.id', '=', 'a.category_id') ->leftJoin('categories as root', 'root.id', '=', DB::raw('coalesce(c.parent_id, c.id)')) ->leftJoin('category_translations as t', fn ($join) => $join->on('t.category_id', '=', 'root.id')->where('t.locale', '=', 'de')) - ->select(DB::raw('t.slug as slug, count(*) as pairs')) - ->groupBy('t.slug') + ->select(DB::raw('root.id as root_id, t.slug as slug, count(*) as pairs')) + ->groupBy('root.id', 't.slug') ->orderByDesc('pairs') ->get(); - $rows = $byCategory->map(function (object $row): array { + $rows = $byCategory->map(function (object $row) use ($assigner): array { $slug = (string) ($row->slug ?? '—'); - $portal = config('canonical.portal_by_category.'.$slug); + $portal = $row->root_id !== null ? $assigner->preferredPortalForCategory((int) $row->root_id) : null; return [ $slug, number_format((int) $row->pairs, 0, ',', '.'), - is_string($portal) ? (Portal::tryFrom($portal)?->label() ?? $portal) : 'Fallback ('.config('canonical.fallback', 'first').')', + $portal !== null ? $portal->label() : 'Fallback ('.config('canonical.fallback', 'first').')', ]; })->all(); diff --git a/app/Http/Controllers/Api/Legacy/LegacyPressReleaseController.php b/app/Http/Controllers/Api/Legacy/LegacyPressReleaseController.php index 45299818..7c534eb8 100644 --- a/app/Http/Controllers/Api/Legacy/LegacyPressReleaseController.php +++ b/app/Http/Controllers/Api/Legacy/LegacyPressReleaseController.php @@ -187,7 +187,9 @@ class LegacyPressReleaseController extends Controller 'teaser_end' => $pressRelease->teaser_end, 'language' => $pressRelease->language, 'category_id' => $pressRelease->category_id, - 'view_url' => "{$spokeUrl}/de/press-release/{$pressRelease->slug}", + // Englische Meldungen liegen unter /en/ (die Detailseite filtert + // nach Sprache); /de/ würde für sie 404 liefern. + 'view_url' => $spokeUrl.'/'.($pressRelease->language === 'en' ? 'en' : 'de').'/press-release/'.$pressRelease->slug, ]; if ($pressRelease->company) { diff --git a/app/Http/Controllers/Web/FeedController.php b/app/Http/Controllers/Web/FeedController.php index c9b76cb7..09921d57 100644 --- a/app/Http/Controllers/Web/FeedController.php +++ b/app/Http/Controllers/Web/FeedController.php @@ -221,7 +221,9 @@ class FeedController extends Controller 'title' => (string) $release->title, 'link' => $base.'/'.$language.'/press-release/'.$release->slug, 'published_at' => $release->published_at?->toRfc2822String() ?? '', - 'description' => $release->teaserText(self::TEASER_LENGTH), + // Der gespeicherte Text ist HTML: Entities erst auflösen, sonst + // escaped Blade sie ein zweites Mal (&amp;) — R-11. + 'description' => html_entity_decode($release->teaserText(self::TEASER_LENGTH), ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'category' => $release->localizedCategoryName($language), 'creator' => $release->company?->name, ])->all(); diff --git a/app/Http/Controllers/Web/LlmsTxtController.php b/app/Http/Controllers/Web/LlmsTxtController.php index 5ca093f6..bb82f38e 100644 --- a/app/Http/Controllers/Web/LlmsTxtController.php +++ b/app/Http/Controllers/Web/LlmsTxtController.php @@ -102,7 +102,7 @@ class LlmsTxtController extends Controller '', '## Aufbau', '', - '- Meldung: '.$base.'/de/press-release/{slug}', + '- Meldung: '.$base.'/de/press-release/{slug} — englischsprachige Meldungen unter '.$base.'/en/press-release/{slug}', '- Newsroom eines Unternehmens: '.$base.'/de/newsroom/{slug} — Übersicht unter '.$base.'/de/newsrooms', '- Kategorie: '.$base.'/de/category/{slug}', '- Sitemap: '.$base.'/sitemap.xml — News-Sitemap (Meldungen der letzten 48 Stunden): '.$base.'/sitemap-news.xml', diff --git a/app/Http/Controllers/Web/NewsroomClaimController.php b/app/Http/Controllers/Web/NewsroomClaimController.php index 61b8f755..86a1c1cb 100644 --- a/app/Http/Controllers/Web/NewsroomClaimController.php +++ b/app/Http/Controllers/Web/NewsroomClaimController.php @@ -63,7 +63,6 @@ class NewsroomClaimController extends Controller $this->ensureNotRateLimited($request, $company->getKey()); RateLimiter::hit($this->ipKey($request), 3600); - RateLimiter::hit($this->companyKey($company->getKey()), 86400); if ($this->claims->recipients($company)->isEmpty()) { return back()->with($this->claims->isUnreachable($company) ? 'newsroom-claim-unreachable' : 'newsroom-claim-none', true); @@ -71,7 +70,15 @@ class NewsroomClaimController extends Controller // Enumeration-sicher: dieselbe Meldung, ob ein Konto deaktiviert ist // oder nicht — die Adresse ist ohnehin nur maskiert sichtbar. - $this->claims->request($company, $request->ip(), $this->sourceOf($request), $this->landingPressReleaseId($request, $company)); + $sent = $this->claims->request($company, $request->ip(), $this->sourceOf($request), $this->landingPressReleaseId($request, $company)); + + // Der Firmenzähler zählt nur tatsächlich versendete Links — sonst + // sperrten Dritte den Inhaber mit Fehlversuchen 24 h aus + // (Umsetzungsaudit 29.09.2026, R-09). Gegen Fluten bleibt die IP- + // Drossel oben. + if ($sent > 0) { + RateLimiter::hit($this->companyKey($company->getKey()), 86400); + } return back()->with('newsroom-claim-status', __('static.newsroom_claim.flash_sent')); } diff --git a/app/Http/Controllers/Web/SitemapController.php b/app/Http/Controllers/Web/SitemapController.php index 69e993a9..88a8bbb1 100644 --- a/app/Http/Controllers/Web/SitemapController.php +++ b/app/Http/Controllers/Web/SitemapController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Web; use App\Http\Controllers\Controller; +use App\Http\Middleware\SetEdition; use App\Models\Category; use App\Models\Company; use App\Models\PressRelease; @@ -152,16 +153,22 @@ class SitemapController extends Controller ->forPortalValues($context->portalValues) ->when($assigner->enabled(), fn ($query) => $query->whereNull('canonical_press_release_id')) ->orderByDesc('published_at') - ->get(['id', 'slug', 'portal', 'language', 'category_id', 'canonical_press_release_id', 'published_at', 'updated_at']); + ->get(['id', 'slug', 'portal', 'language', 'category_id', 'canonical_press_release_id', 'published_at', 'content_changed_at']); foreach ($releases as $release) { if (! $assigner->isCanonicalOn($release, $context->portal)) { continue; } + // Edition aus der Sprache (wie News-Sitemap und Canonical): die + // Detailseite filtert nach Sprache, eine EN-Meldung unter /de/ + // wäre ein 404 (Umsetzungsaudit 29.09.2026, R-03). lastmod ist + // die letzte inhaltliche Änderung, nicht updated_at (R-02). + $edition = in_array($release->language, SetEdition::EDITIONS, true) ? $release->language : SetEdition::DEFAULT_EDITION; + $urls[] = [ - 'loc' => $base.'/de/press-release/'.$release->slug, - 'lastmod' => ($release->updated_at ?? $release->published_at)?->toAtomString(), + 'loc' => $base.'/'.$edition.'/press-release/'.$release->slug, + 'lastmod' => $release->contentModifiedAt()?->toAtomString(), ]; } diff --git a/app/Http/Middleware/RecordMailLinkClick.php b/app/Http/Middleware/RecordMailLinkClick.php index a673e948..681ddb5a 100644 --- a/app/Http/Middleware/RecordMailLinkClick.php +++ b/app/Http/Middleware/RecordMailLinkClick.php @@ -7,10 +7,14 @@ use App\Enums\ConversionSource; use App\Models\PressRelease; use App\Scopes\PortalScope; use App\Services\Conversion\ConversionRecorder; +use App\Support\CrawlerIdentifier; use Closure; use Illuminate\Http\Request; +use Illuminate\Support\Facades\RateLimiter; use Symfony\Component\HttpFoundation\Response; +use function Illuminate\Support\defer; + /** * Stufe 1 der Konversionsleiter: Mail-Link geklickt (Roadmap 0.5). * @@ -26,7 +30,9 @@ use Symfony\Component\HttpFoundation\Response; * fälschungssicher mitführt; der `pr`-Parameter entfällt dann. * * Nur Herkünfte aus Mails lösen aus. `organic` würde sonst bei jedem - * getippten Parameter ein Ereignis erzeugen. + * getippten Parameter ein Ereignis erzeugen. Gedrosselt je IP und Meldung, + * Link-Scanner ausgenommen, geschrieben nach der Antwort (R-08, + * config/conversion.php `mail_clicks`). */ class RecordMailLinkClick { @@ -41,32 +47,64 @@ class RecordMailLinkClick } // Nur echte Aufrufe. Mailclients und Sicherheitsscanner holen Links - // vorab per HEAD; das wäre kein Klick, sondern eine Zustellung. - if (! $request->isMethod('GET')) { + // vorab per HEAD oder mit eigener Kennung; das wäre kein Klick, + // sondern eine Zustellung (Umsetzungsaudit 29.09.2026, R-08). + if (! $request->isMethod('GET') || $this->isScanner($request->userAgent())) { return $next($request); } - $pressRelease = $this->pressRelease($request); + $pressReleaseId = max(0, (int) $request->query('pr')); - $this->recorder->record( - ConversionEventType::MailLinkClicked, - $source, - $this->recorder->companyOf($pressRelease), - $pressRelease, - ); + // Derselbe Klick (Doppelklick, erneutes Öffnen, Scanner mit Browser- + // Kennung) zählt je IP und Meldung höchstens einmal im Zeitfenster. + $key = 'mail-click:'.hash('sha256', (string) $request->ip()).':'.$source->value.':'.$pressReleaseId; + $decay = max(1, (int) config('conversion.mail_clicks.throttle_minutes', 30)) * 60; + + if (! RateLimiter::attempt($key, 1, static fn (): bool => true, $decay)) { + return $next($request); + } + + // Nach der Antwort: Der Eintritt über den Mail-Link wartet nicht auf + // die Statistik. + $recorder = $this->recorder; + defer(function () use ($recorder, $source, $pressReleaseId): void { + $pressRelease = $this->pressRelease($pressReleaseId); + + $recorder->record( + ConversionEventType::MailLinkClicked, + $source, + $recorder->companyOf($pressRelease), + $pressRelease, + ); + }); return $next($request); } + private function isScanner(?string $userAgent): bool + { + $userAgent = mb_strtolower(trim((string) $userAgent)); + + if ($userAgent === '' || CrawlerIdentifier::identify($userAgent) !== null) { + return true; + } + + foreach ((array) config('conversion.mail_clicks.scanner_agents', []) as $pattern) { + if (str_contains($userAgent, mb_strtolower((string) $pattern))) { + return true; + } + } + + return false; + } + /** * Scope-frei aufgelöst: Der Klick landet je nach Link auf dem Hub oder auf * einem der beiden Spokes, der Portal-Scope würde die Meldung dort * wegfiltern. */ - private function pressRelease(Request $request): ?PressRelease + private function pressRelease(int $id): ?PressRelease { - $id = (int) $request->query('pr'); - if ($id <= 0) { return null; } diff --git a/app/Listeners/GateOutgoingMail.php b/app/Listeners/GateOutgoingMail.php index 9b4a8ee5..77518e54 100644 --- a/app/Listeners/GateOutgoingMail.php +++ b/app/Listeners/GateOutgoingMail.php @@ -7,13 +7,15 @@ use App\Exceptions\MailDeliveryPausedException; use App\Mail\MagicLoginLink; use App\Mail\OperationalAlertMail; use App\Services\ActivityLog; +use App\Services\Mail\BounceReference; use App\Services\Mail\EmailSuppressionService; use App\Services\Mail\MailCircuitBreaker; use Illuminate\Mail\Events\MessageSending; use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Email; /** - * Das eine Gate vor jeder ausgehenden Mail (Roadmap 1b.6). Drei Aufgaben: + * Das eine Gate vor jeder ausgehenden Mail (Roadmap 1b.6). Vier Aufgaben: * * 1. Gesperrte Empfänger streichen. Bleibt kein Empfänger, geht die Mail * nicht raus — mit Eintrag im Aktivitätsverlauf. Damit sind Magic Links, @@ -23,6 +25,9 @@ use Symfony\Component\Mime\Address; * im Posteingang (Konzept `bounce-mails-2026-09-15.md`). * 3. Circuit Breaker: Ist der Versand angehalten, schlägt die Mail im * Worker fehl (nachholbar) und wird im Web-Kontext still verworfen. + * 4. Bounce-Referenz signieren: `X-Pk-Ref` bekommt je verbliebenem + * Empfänger eine HMAC (BounceReference). Nur ein Rückläufer mit passender + * Signatur darf hart sperren (Umsetzungsaudit 29.09.2026, R-04). * * Betriebs-Alerts gehen immer durch — sonst erführe niemand vom Stopp. * @@ -38,6 +43,7 @@ class GateOutgoingMail private readonly EmailSuppressionService $suppressions, private readonly MailCircuitBreaker $breaker, private readonly ActivityLog $activityLog, + private readonly BounceReference $references, ) {} public function gate(MessageSending $event): bool @@ -76,6 +82,8 @@ class GateOutgoingMail $suppressed = $this->suppressions->suppressedAmong([...$to, ...$cc, ...$bcc], includeUnsubscribed: ! $requested); if ($suppressed === []) { + $this->signReference($message); + return true; } @@ -86,6 +94,8 @@ class GateOutgoingMail $message->bcc(...array_values(array_filter($message->getBcc(), $keep))); if ($message->getTo() !== []) { + $this->signReference($message); + return true; } @@ -99,6 +109,21 @@ class GateOutgoingMail return false; } + /** + * Nach dem Streichen gesperrter Empfänger — signiert wird für genau die + * Adressen, an die die Mail tatsächlich geht. + */ + private function signReference(Email $message): void + { + $name = (string) config('bounces.reference_header', 'X-Pk-Ref'); + $headers = $message->getHeaders(); + $reference = $headers->get($name)?->getBodyAsString(); + $recipients = $this->addresses([...$message->getTo(), ...$message->getCc(), ...$message->getBcc()]); + + $headers->remove($name); + $headers->addTextHeader($name, $this->references->sign($reference, $recipients)); + } + /** * @param Address[] $addresses * @return list diff --git a/app/Models/CompanyMergeMove.php b/app/Models/CompanyMergeMove.php index 8f157002..8560db1d 100644 --- a/app/Models/CompanyMergeMove.php +++ b/app/Models/CompanyMergeMove.php @@ -22,6 +22,7 @@ class CompanyMergeMove extends Model 'from_company_id', 'field', 'previous_value', + 'merged_value', 'dropped', ]; diff --git a/app/Models/PressRelease.php b/app/Models/PressRelease.php index 81d07699..34504a5c 100644 --- a/app/Models/PressRelease.php +++ b/app/Models/PressRelease.php @@ -18,6 +18,7 @@ use App\Services\ActivityLog; use App\Services\PressRelease\PressReleaseHtmlSanitizer; use App\Support\ContentFingerprint; use App\Support\ImageCredit; +use Carbon\CarbonInterface; use Closure; use Database\Factories\PressReleaseFactory; use Illuminate\Database\Eloquent\Builder; @@ -64,10 +65,19 @@ class PressRelease extends Model }); // Prüfsumme über den Inhalt (Anhang 2, 1c.7) — bei jeder Änderung an - // Titel oder Text, und nachträglich für Zeilen ohne Summe. + // Titel oder Text, und nachträglich für Zeilen ohne Summe. Ändert + // sich die Summe an einer bestehenden Zeile, ist das die letzte + // inhaltliche Änderung (content_changed_at, Umsetzungsaudit + // 29.09.2026 R-02) — Grundlage für lastmod/dateModified. static::saving(function (self $pressRelease): void { if ($pressRelease->content_hash === null || $pressRelease->isDirty(['title', 'text'])) { - $pressRelease->content_hash = ContentFingerprint::hash($pressRelease->title, $pressRelease->text); + $hash = ContentFingerprint::hash($pressRelease->title, $pressRelease->text); + + if ($pressRelease->exists && $pressRelease->isDirty(['title', 'text']) && $hash !== $pressRelease->getOriginal('content_hash')) { + $pressRelease->content_changed_at = now(); + } + + $pressRelease->content_hash = $hash; } }); } @@ -84,6 +94,7 @@ class PressRelease extends Model 'slug', 'text', 'content_hash', + 'content_changed_at', 'canonical_press_release_id', 'boilerplate_override', 'placeholder_variant', @@ -131,6 +142,7 @@ class PressRelease extends Model 'content_score' => 'integer', 'content_tier' => PressReleaseContentTier::class, 'scored_at' => 'datetime', + 'content_changed_at' => 'datetime', 'hits' => 'integer', 'teaser_begin' => 'integer', 'teaser_end' => 'integer', @@ -149,6 +161,25 @@ class PressRelease extends Model ]; } + /** + * Letzte inhaltliche Änderung für lastmod, dateModified und + * article:modified_time (Umsetzungsaudit 29.09.2026, R-02): bewusst nicht + * `updated_at`, das auch Aufrufzähler und Firmen-Merge bewegt haben. + * Ohne Änderung nach der Veröffentlichung gilt `published_at` — eine + * Änderung am Entwurf vor der Veröffentlichung liegt davor und zählt nicht. + */ + public function contentModifiedAt(): ?CarbonInterface + { + $changedAt = $this->content_changed_at; + $publishedAt = $this->published_at; + + if ($changedAt === null || ($publishedAt !== null && $changedAt->lessThanOrEqualTo($publishedAt))) { + return $publishedAt; + } + + return $changedAt; + } + /** * Geplanter Veröffentlichungstermin. Die Anwendung läuft durchgängig in * Europe/Berlin (config app.timezone), daher ist keine Umrechnung nötig. @@ -262,36 +293,52 @@ class PressRelease extends Model } /** - * Bild-URL für Kartenansichten: card-Variante des ersten Bildes, - * Fallback Original. Null bei bildlosen PMs (dann greift - * chosenPlaceholderUrl() bzw. der generische Fallback der Komponente). + * Titelbild für Teaser und Listen: das erste öffentlich auslieferbare + * Bild (Umsetzungsaudit 29.09.2026, B-01). Vorgeladene Bilder werden + * gefiltert, auch wenn sie ungefiltert geladen wurden; ohne Vorladen + * genau eine gefilterte Abfrage statt Lazy Loading aller Bilder. */ + private function cardImage(): ?PressReleaseImage + { + $images = $this->relationLoaded('images') + ? $this->images + : $this->images()->publiclyVisible()->orderByDesc('is_preview')->orderBy('sort_order')->limit(1)->get(); + + return $images->first(fn (PressReleaseImage $image): bool => $image->isPubliclyDeliverable()); + } + /** * Nachweiszeile des Titelbilds für Teaser (Bildkennzeichnung H.2): * „Foto: …" bzw. „Quelle: …" plus KI-Zusatz, null ohne Bild. */ public function cardImageCredit(): ?string { - $image = $this->relationLoaded('images') ? $this->images->first() : null; + $image = $this->cardImage(); return $image ? ImageCredit::for($image, $this->company)->line() : null; } + /** + * Bild-URL für Kartenansichten: card-Variante des Titelbildes, + * Fallback Original. Null ohne öffentlich auslieferbares Bild (dann + * greift chosenPlaceholderUrl() bzw. der generische Fallback). + */ public function cardImageUrl(): ?string { - $image = $this->images?->first(); + $image = $this->cardImage(); return $image?->variantUrl('card') ?? $image?->url(); } /** * Bild-URL für Listen-Thumbnails: thumb-Variante des Titelbildes, - * Fallback card-Variante bzw. Original. Null bei bildlosen PMs - * (dann greift chosenPlaceholderUrl() bzw. der generische Fallback). + * Fallback card-Variante bzw. Original. Null ohne öffentlich + * auslieferbares Bild (dann greift chosenPlaceholderUrl() bzw. der + * generische Fallback). */ public function thumbImageUrl(): ?string { - $image = $this->images?->first(); + $image = $this->cardImage(); return $image?->variantUrl('thumb') ?? $image?->variantUrl('card') ?? $image?->url(); } @@ -616,6 +663,30 @@ class PressRelease extends Model return $this->gone_at !== null; } + /** + * War die Meldung (möglicherweise) je öffentlich? Maßgeblich für alles, + * was „nie veröffentlicht" sofort löscht (Umsetzungsaudit 29.09.2026, + * A-05): `published_ever_at` — und im Altbestand `rejected`, weil das + * Altsystem damit auch „offline genommen" abbildete; der Backfill kann + * das nicht unterscheiden (Dev-Kopie: 126.383 solche Meldungen ohne + * `published_ever_at`). + */ + public function wasEverPublished(): bool + { + return $this->published_ever_at !== null + || ($this->legacy_id !== null && $this->status === PressReleaseStatus::Rejected); + } + + /** + * Gegenstück zu wasEverPublished() für Abfragen. + */ + public function scopeNeverPublished(Builder $query): Builder + { + return $query->whereNull($query->qualifyColumn('published_ever_at')) + ->where(fn (Builder $q) => $q->whereNull($q->qualifyColumn('legacy_id')) + ->orWhere($q->qualifyColumn('status'), '!=', PressReleaseStatus::Rejected->value)); + } + /** * Automatik erschöpft (E3): Für diese Meldung entscheidet nur noch ein * Mensch — jede Freigabe geht ohne KI-Lauf in die Admin-Queue. diff --git a/app/Services/Company/CompanyMergeService.php b/app/Services/Company/CompanyMergeService.php index 7041e81a..7ec76b4e 100644 --- a/app/Services/Company/CompanyMergeService.php +++ b/app/Services/Company/CompanyMergeService.php @@ -16,6 +16,7 @@ use App\Models\PressReleaseAiDeclaration; use App\Scopes\PortalScope; use App\Services\ActivityLog; use App\Services\Admin\AdminPerformanceCache; +use App\Services\PressRelease\CanonicalAssigner; use App\Support\WebCaches; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; @@ -162,7 +163,7 @@ class CompanyMergeService } if (! $survivor->is_active && collect($plan->losers)->contains(fn (Company $loser): bool => (bool) $loser->is_active)) { - $this->record($merge, CompanyMergeSubject::Field, null, $plan->losers[0], 'is_active', '0'); + $this->record($merge, CompanyMergeSubject::Field, null, $plan->losers[0], 'is_active', '0', merged: '1'); $survivorChanges['is_active'] = true; } @@ -183,6 +184,8 @@ class CompanyMergeService return $merge; }); + $this->reassignCanonicals($merge); + if ($flushCaches) { $this->flushCaches(); } @@ -225,11 +228,44 @@ class CompanyMergeService ); }); + $this->reassignCanonicals($merge); + if ($flushCaches) { $this->flushCaches(); } } + /** + * Canonical-Zuordnung der verschobenen Meldungen neu bewerten (Umsetzungs- + * audit 29.09.2026, R-06): Nach dem Merge gehören Fassungen gleichen + * Inhalts auf beiden Portalen derselben Firma und werden zum Paar; nach + * dem Unmerge zeigt eine Kopie sonst auf die Meldung einer fremden Firma. + * Bewusst nach dem Commit — die Zuordnung ist ableitbar und lässt sich + * mit `press-releases:assign-canonicals` jederzeit nachziehen. + */ + private function reassignCanonicals(CompanyMerge $merge): void + { + $assigner = app(CanonicalAssigner::class); + + if (! $assigner->enabled()) { + return; + } + + $moves = $merge->moves()->where('subject', CompanyMergeSubject::PressRelease->value); + $companyIds = $merge->moves()->distinct()->pluck('from_company_id')->push($merge->survivor_company_id)->unique()->values()->all(); + + $moves->select(['id', 'subject_id'])->chunkById(500, function ($chunk) use ($assigner, $companyIds): void { + $hashes = PressRelease::query()->withoutGlobalScopes() + ->whereIn('id', $chunk->pluck('subject_id')->all()) + ->whereNotNull('content_hash') + ->distinct() + ->pluck('content_hash') + ->all(); + + $assigner->reassignWithin($hashes, $companyIds); + }); + } + public function flushCaches(): void { WebCaches::forgetPublicPages(); @@ -341,11 +377,16 @@ class CompanyMergeService ], $chunk)); } - $query->whereIn('id', $ids)->update(['company_id' => $survivor->getKey()]); - if ($subject === CompanyMergeSubject::PressRelease) { + // toBase(): ohne updated_at — der Inhalt der Meldung ändert + // sich nicht (Umsetzungsaudit 29.09.2026, R-02). + $query->whereIn('id', $ids)->toBase()->update(['company_id' => $survivor->getKey()]); $moved = count($ids); + + continue; } + + $query->whereIn('id', $ids)->update(['company_id' => $survivor->getKey()]); } return $moved; @@ -400,14 +441,14 @@ class CompanyMergeService continue; } - $this->record($merge, CompanyMergeSubject::Field, null, $loser, $field, $survivor->getRawOriginal($field)); + $this->record($merge, CompanyMergeSubject::Field, null, $loser, $field, $survivor->getRawOriginal($field), merged: (string) $loser->getRawOriginal($field)); $fills[$field] = $loser->getRawOriginal($field); } return $fills; } - private function record(CompanyMerge $merge, CompanyMergeSubject $subject, ?int $subjectId, Company $from, ?string $field = null, ?string $previous = null, bool $dropped = false): void + private function record(CompanyMerge $merge, CompanyMergeSubject $subject, ?int $subjectId, Company $from, ?string $field = null, ?string $previous = null, bool $dropped = false, ?string $merged = null): void { CompanyMergeMove::query()->create([ 'company_merge_id' => $merge->getKey(), @@ -416,6 +457,7 @@ class CompanyMergeService 'from_company_id' => $from->getKey(), 'field' => $field, 'previous_value' => $previous, + 'merged_value' => $merged, 'dropped' => $dropped, ]); } @@ -425,17 +467,58 @@ class CompanyMergeService $from = $move->from_company_id; match ($move->subject) { - CompanyMergeSubject::PressRelease => PressRelease::query()->withoutGlobalScopes()->whereKey($move->subject_id)->where('company_id', $survivorId)->update(['company_id' => $from]), + CompanyMergeSubject::PressRelease => PressRelease::query()->withoutGlobalScopes()->whereKey($move->subject_id)->where('company_id', $survivorId)->toBase()->update(['company_id' => $from]), CompanyMergeSubject::Contact => Contact::query()->withoutGlobalScopes()->whereKey($move->subject_id)->where('company_id', $survivorId)->update(['company_id' => $from]), CompanyMergeSubject::AiDeclaration => PressReleaseAiDeclaration::query()->whereKey($move->subject_id)->where('company_id', $survivorId)->update(['company_id' => $from]), CompanyMergeSubject::ConversionEvent => ConversionEvent::query()->whereKey($move->subject_id)->where('company_id', $survivorId)->update(['company_id' => $from]), CompanyMergeSubject::CompanyUser => $this->undoPivot('company_user', 'user_id', $move, $survivorId, ['role' => $move->previous_value]), CompanyMergeSubject::PaymentOption => $this->undoPivot('user_payment_option_company', 'user_payment_option_id', $move, $survivorId, ['is_active' => (bool) $move->previous_value]), - CompanyMergeSubject::Field => Company::query()->withoutGlobalScope(PortalScope::class)->whereKey($survivorId)->update([(string) $move->field => $move->previous_value]), + CompanyMergeSubject::Field => $this->undoField($move, $survivorId), CompanyMergeSubject::Company => Company::withTrashed()->withoutGlobalScope(PortalScope::class)->whereKey($from)->update(['deleted_at' => null]), }; } + /** + * Ein aufgefülltes Feld nur zurücksetzen, wenn es noch den beim Merge + * eingefüllten Wert trägt — hat der Kunde es inzwischen geändert (etwa + * nach dem Beanspruchen des Newsrooms), bleibt seine Fassung stehen + * (Umsetzungsaudit 29.09.2026, R-05). Zeilen von vor der Spalte + * `merged_value` vergleichen gegen die Herkunftsfirma: Die wurde beim + * Merge nur soft-gelöscht, ihr Wert ist der eingefüllte. + */ + private function undoField(CompanyMergeMove $move, int $survivorId): void + { + $field = (string) $move->field; + $filled = $move->merged_value; + + if ($filled === null) { + $filled = $field === 'is_active' + ? '1' + : Company::withTrashed()->withoutGlobalScope(PortalScope::class)->whereKey($move->from_company_id)->toBase()->value($field); + } + + $current = Company::query()->withoutGlobalScope(PortalScope::class)->whereKey($survivorId)->toBase()->value($field); + + if (! $this->sameRawValue($current, $filled)) { + return; + } + + Company::query()->withoutGlobalScope(PortalScope::class)->whereKey($survivorId)->update([$field => $move->previous_value]); + } + + private function sameRawValue(mixed $current, mixed $filled): bool + { + if (is_bool($current)) { + $current = (int) $current; + } + + if (is_bool($filled)) { + $filled = (int) $filled; + } + + return $current !== null && $filled !== null && (string) $current === (string) $filled; + } + /** * @param array $extra */ diff --git a/app/Services/Company/MergedNewsroomLookup.php b/app/Services/Company/MergedNewsroomLookup.php index 4cc62833..17fc4cdd 100644 --- a/app/Services/Company/MergedNewsroomLookup.php +++ b/app/Services/Company/MergedNewsroomLookup.php @@ -16,6 +16,8 @@ use Illuminate\Database\Eloquent\Builder; */ class MergedNewsroomLookup { + private const MaxChainLength = 10; + /** * Der Überlebende zu einem aufgegangenen Slug, oder null, wenn der Slug * nie aufgegangen ist oder die Zusammenführung rückabgewickelt wurde. @@ -34,9 +36,40 @@ class MergedNewsroomLookup return null; } + $survivorId = $this->survivorIdOf($mergedIds->all()); + $visited = []; + + // Merge-Ketten (A → B, später B → C): Ist der Überlebende selbst + // aufgegangen, weiter bis zur lebenden Firma. Zyklenschutz über die + // besuchten IDs und eine feste Obergrenze (Umsetzungsaudit + // 29.09.2026, R-11). + while ($survivorId !== null && ! isset($visited[$survivorId]) && count($visited) < self::MaxChainLength) { + $visited[$survivorId] = true; + + $survivor = Company::withTrashed()->withoutGlobalScope(PortalScope::class)->find($survivorId); + + if ($survivor === null) { + return null; + } + + if (! $survivor->trashed()) { + return $survivor; + } + + $survivorId = $this->survivorIdOf([$survivorId]); + } + + return null; + } + + /** + * @param list $companyIds + */ + private function survivorIdOf(array $companyIds): ?int + { $survivorId = CompanyMergeMove::query() ->where('subject', CompanyMergeSubject::Company->value) - ->whereIn('subject_id', $mergedIds) + ->whereIn('subject_id', $companyIds) ->whereHas('merge', fn (Builder $query) => $query->whereNull('reverted_at')) ->with('merge:id,survivor_company_id') ->latest('id') @@ -44,10 +77,6 @@ class MergedNewsroomLookup ?->merge ?->survivor_company_id; - if ($survivorId === null) { - return null; - } - - return Company::query()->withoutGlobalScope(PortalScope::class)->find($survivorId); + return $survivorId !== null ? (int) $survivorId : null; } } diff --git a/app/Services/Mail/BounceMailProcessor.php b/app/Services/Mail/BounceMailProcessor.php index 4c30f2e2..61f0fb2a 100644 --- a/app/Services/Mail/BounceMailProcessor.php +++ b/app/Services/Mail/BounceMailProcessor.php @@ -26,6 +26,7 @@ class BounceMailProcessor private readonly DsnParser $parser, private readonly EmailSuppressionService $suppressions, private readonly ActivityLog $activityLog, + private readonly BounceReference $references, ) {} /** @@ -69,6 +70,16 @@ class BounceMailProcessor $kind = $this->reclassify($recipient['status'], $recipient['diagnostic']); } + $reference = $this->references->plain($report->reference); + $verified = $this->references->verify($report->reference, $recipient['email']); + + // Jede Art, die auf eine Sperre zählt — hart wie weich —, braucht + // den signierten Nachweis für genau diesen Empfänger + // (Umsetzungsaudit 02.10.2026, O01: der weiche Weg lief vorbei). + if ($kind !== EmailBounceKind::Policy && ! $verified) { + [$kind, $reference] = $this->unverified($report->reference, $reference); + } + $bounces++; if ($dryRun) { @@ -84,7 +95,7 @@ class BounceMailProcessor 'action' => $recipient['action'], 'status' => $recipient['status'], 'diagnostic' => $recipient['diagnostic'], - 'reference' => $report->reference, + 'reference' => $reference, 'received_at' => $report->receivedAt, ], ); @@ -93,6 +104,42 @@ class BounceMailProcessor return ['folder' => $bounces > 0 ? self::Processed : self::Ignored, 'bounces' => $bounces, 'subject' => $subject]; } + /** + * Bounce ohne passende Signatur (Umsetzungsaudit 29.09.2026, R-04, und + * 02.10.2026, O01): Jeder kann ein `multipart/report` ans Return-Path- + * Postfach schicken. Auf eine Sperre zählen darf — hart wie weich — nur + * ein Bericht, dessen `X-Pk-Ref` wir für genau diesen Empfänger signiert + * haben. + * + * - Signatur vorhanden, aber falsch (verändert, oder die echte Signatur + * einer Mail an eine andere Adresse): Richtlinie — nur protokolliert, + * zur Sichtung im Admin, zählt auf keinen Schwellwert. + * - Gar keine Signatur (vor der Umstellung versendet, Freitext-Bericht + * ohne Original-Header): `bounces.unsigned_kind`, Standard ebenfalls + * Richtlinie. `soft` zählt bewusst auf den Schwellwert und ist damit + * fälschbar; hart wird ein unsignierter Bericht nie. + * + * @return array{0: EmailBounceKind, 1: string|null} + */ + private function unverified(?string $header, ?string $reference): array + { + if ($this->references->isSigned($header)) { + return [EmailBounceKind::Policy, $this->annotate($reference, 'Signatur ungültig')]; + } + + $kind = EmailBounceKind::tryFrom((string) config('bounces.unsigned_kind', 'policy')); + + return [ + $kind === EmailBounceKind::Soft ? EmailBounceKind::Soft : EmailBounceKind::Policy, + $this->annotate($reference, 'unsigniert'), + ]; + } + + private function annotate(?string $reference, string $note): string + { + return $reference === null ? '('.$note.')' : mb_substr($reference, 0, 100).' ('.$note.')'; + } + /** * `failed` heißt nicht immer „Adresse existiert nicht". Drei Gruppen: * diff --git a/app/Services/Mail/BounceReference.php b/app/Services/Mail/BounceReference.php new file mode 100644 index 00000000..82a6ce6e --- /dev/null +++ b/app/Services/Mail/BounceReference.php @@ -0,0 +1,124 @@ +; s=[,…]` — je + * Empfänger eine HMAC über Referenz und Empfängeradresse. Ein Zustellbericht + * bringt die Original-Header zurück; nur wenn die Signatur zur gemeldeten + * Adresse passt, stammt der Bericht nachweislich von einer Mail, die wir an + * genau diese Adresse geschickt haben. Erst dann darf ein harter Bounce + * sperren — sonst könnte jeder mit einer Mail ans Return-Path-Postfach + * beliebige Adressen sperren und damit Magic Links, Resets und Rechnungen + * abschneiden. + * + * Schlüssel: `bounces.signing_key` (BOUNCE_SIGNING_KEY), sonst aus APP_KEY + * abgeleitet. Ein Schlüsselwechsel entwertet nur die Signaturen bereits + * versendeter Mails — deren Rückläufer zählen dann weich. + */ +class BounceReference +{ + /** Ohne fachliche Referenz der Mailable. */ + public const DefaultReference = 'mail'; + + /** Mehr Signaturen schreibt kein Header; weitere Empfänger bleiben unsigniert. */ + public const MaxRecipients = 20; + + private const SignatureLength = 32; + + /** + * @param list $recipients + */ + public function sign(?string $reference, array $recipients): string + { + $reference = $this->plain($reference) ?? self::DefaultReference; + + $signatures = collect($recipients) + ->map(fn (string $email): ?string => EmailSuppressionService::normalise($email)) + ->filter() + ->unique() + ->take(self::MaxRecipients) + ->map(fn (string $email): string => $this->signature($reference, $email)) + ->implode(','); + + return $signatures === '' ? $reference : $reference.'; s='.$signatures; + } + + /** + * Ob der Header eine gültige Signatur für genau diesen Empfänger trägt. + */ + public function verify(?string $header, string $recipient): bool + { + $reference = $this->plain($header); + $email = EmailSuppressionService::normalise($recipient); + + if ($reference === null || $email === null) { + return false; + } + + $expected = $this->signature($reference, $email); + + foreach ($this->signatures($header) as $signature) { + if (hash_equals($expected, $signature)) { + return true; + } + } + + return false; + } + + /** + * Ob der Header überhaupt Signaturen trägt — unterscheidet „vor der + * Umstellung versendet" (keine) von „gefälscht oder verändert" (falsche). + */ + public function isSigned(?string $header): bool + { + return $this->signatures($header) !== []; + } + + /** + * Die fachliche Referenz ohne Signaturteil (für Protokoll und Anzeige). + */ + public function plain(?string $header): ?string + { + if ($header === null) { + return null; + } + + $reference = trim((string) preg_replace('/;\s*s=.*$/s', '', $header)); + + return $reference === '' ? null : $reference; + } + + /** + * @return list + */ + private function signatures(?string $header): array + { + if ($header === null || preg_match('/;\s*s=(.+)$/s', $header, $m) !== 1) { + return []; + } + + $list = (string) preg_replace('/\s+/', '', $m[1]); + + return array_values(array_filter(explode(',', $list), fn (string $sig): bool => preg_match('/^[0-9a-f]{'.self::SignatureLength.'}$/', $sig) === 1)); + } + + private function signature(string $reference, string $email): string + { + return substr(hash_hmac('sha256', $reference."\n".$email, $this->key()), 0, self::SignatureLength); + } + + private function key(): string + { + $configured = (string) config('bounces.signing_key'); + + if ($configured !== '') { + return $configured; + } + + return hash_hmac('sha256', 'pk-bounce-reference', (string) config('app.key')); + } +} diff --git a/app/Services/PressRelease/CanonicalAssigner.php b/app/Services/PressRelease/CanonicalAssigner.php index 519a8a20..2f2622d1 100644 --- a/app/Services/PressRelease/CanonicalAssigner.php +++ b/app/Services/PressRelease/CanonicalAssigner.php @@ -3,6 +3,7 @@ namespace App\Services\PressRelease; use App\Enums\Portal; +use App\Enums\PressReleaseStatus; use App\Http\Middleware\SetEdition; use App\Models\Category; use App\Models\PressRelease; @@ -26,8 +27,8 @@ use Illuminate\Support\Facades\DB; */ class CanonicalAssigner { - /** @var array Kategorie-ID → DE-Slug der Hauptkategorie */ - private array $categorySlugs = []; + /** @var array Kategorie-ID → Hauptkategorie (ID und DE-Slug) */ + private array $categoryRoots = []; public function enabled(): bool { @@ -44,12 +45,41 @@ class CanonicalAssigner return null; } - $slug = $this->categorySlug((int) $pressRelease->category_id); - $portal = $slug !== null ? config('canonical.portal_by_category.'.$slug) : null; + return $this->preferredPortalForCategory((int) $pressRelease->category_id); + } + + /** + * Zuordnung über die ID der Hauptkategorie (`portal_by_category_id`), + * rückwärtskompatibel über den DE-Slug (`portal_by_category`) — eine + * Slug-Änderung schaltet die Zuordnung damit nicht mehr still ab + * (Umsetzungsaudit 29.09.2026, R-11). + */ + public function preferredPortalForCategory(int $categoryId): ?Portal + { + ['root' => $rootId, 'slug' => $slug] = $this->categoryRoot($categoryId); + + $byId = (array) config('canonical.portal_by_category_id', []); + $portal = $rootId !== null ? ($byId[$rootId] ?? null) : null; + $portal ??= $slug !== null ? config('canonical.portal_by_category.'.$slug) : null; return is_string($portal) ? Portal::tryFrom($portal) : null; } + /** + * IDs aller Kategorien (Haupt- und Unterkategorien), deren Erstquelle + * `$portal` führt — für SQL-Auswertungen, die isCanonicalOn() für + * portal=both nachbilden (IndexStatusReport). + * + * @return list + */ + public function categoryIdsPreferring(Portal $portal): array + { + return Category::query()->withoutGlobalScopes()->pluck('id') + ->filter(fn (int $id): bool => $this->preferredPortalForCategory($id) === $portal) + ->values() + ->all(); + } + /** * Öffentliche Adresse, auf die rel=canonical der Meldung zeigt, wenn sie * auf `$viewedOn` ausgeliefert wird. Ohne Freigabe (config) oder ohne @@ -159,15 +189,102 @@ class CanonicalAssigner } /** - * Alle Paare im Bestand zuordnen. + * Zuordnung aller Meldungen mit diesen Prüfsummen innerhalb dieser Firmen + * neu bestimmen — nach Firmen-Merge und -Unmerge, die `company_id` am + * Query-Builder vorbei umhängen (Umsetzungsaudit 29.09.2026, R-06). + * Verweise, die kein gültiges Paar mehr bilden (andere Firma, anderer + * Inhalt, Ziel nicht mehr veröffentlicht), fallen; danach wird jede + * veröffentlichte Meldung wie nach einer Veröffentlichung zugeordnet. + * + * @param list $hashes + * @param list $companyIds + */ + public function reassignWithin(array $hashes, array $companyIds): void + { + if (! $this->enabled() || $hashes === [] || $companyIds === []) { + return; + } + + foreach (array_chunk(array_values(array_unique($hashes)), 200) as $chunk) { + $releases = PressRelease::query() + ->withoutGlobalScope(PortalScope::class) + ->whereIn('content_hash', $chunk) + ->whereIn('company_id', $companyIds) + ->get(); + + foreach ($releases as $release) { + if ($release->canonical_press_release_id === null) { + continue; + } + + $target = $releases->firstWhere('id', $release->canonical_press_release_id); + + if ($target === null || ! $this->formsPair($release, $target)) { + $this->write($release->getKey(), null); + $release->canonical_press_release_id = null; + } + } + + foreach ($releases as $release) { + if ($this->isPairable($release)) { + $this->assignFor($release); + } + } + } + } + + /** + * Ob `$copy` zu Recht auf `$target` verweist — dieselben Bedingungen wie + * pairsQuery(), nur für ein einzelnes Paar. + */ + private function formsPair(PressRelease $copy, PressRelease $target): bool + { + return $target->company_id === $copy->company_id + && $target->content_hash !== null + && $target->content_hash === $copy->content_hash + && $target->portal !== $copy->portal + && $target->portal !== Portal::Both + && $copy->portal !== Portal::Both + && $this->isPairable($target); + } + + /** + * Wie `published()` plus die Filter aus pairsQuery(). + */ + private function isPairable(PressRelease $pressRelease): bool + { + return $pressRelease->status === PressReleaseStatus::Published + && $pressRelease->published_at !== null + && $pressRelease->gone_at === null + && ! $pressRelease->trashed(); + } + + /** + * Alle Paare im Bestand zuordnen. Vorher fallen Verweise, die kein Paar + * aus pairsQuery() mehr bilden (Kopie zeigt auf eine gelöschte oder + * nicht mehr veröffentlichte Meldung, auf eine andere Firma, einen + * anderen Inhalt oder dasselbe Portal) — Umsetzungsaudit 29.09.2026, R-06. * * @param callable(int $canonicalId, int $duplicateId, bool $changed): void|null $each - * @return array{pairs: int, changed: int} + * @return array{pairs: int, changed: int, reset: int} */ public function assignAll(bool $dryRun = false, ?callable $each = null): array { $pairs = 0; $changed = 0; + $reset = 0; + + // chunkById: Die Schreibvorgänge nehmen Zeilen aus der Ergebnismenge, + // eine Offset-Paginierung würde dabei Zeilen überspringen. + $this->staleReferencesQuery()->chunkById(500, function ($rows) use ($dryRun, &$reset): void { + foreach ($rows as $row) { + $reset++; + + if (! $dryRun) { + $this->write((int) $row->id, null); + } + } + }, 'p.id', 'id'); $this->pairsQuery()->orderBy('a.id')->chunk(500, function ($rows) use ($dryRun, $each, &$pairs, &$changed): void { $ids = $rows->flatMap(fn (object $row): array => [(int) $row->a_id, (int) $row->b_id])->unique()->all(); @@ -201,7 +318,31 @@ class CanonicalAssigner } }); - return ['pairs' => $pairs, 'changed' => $changed]; + return ['pairs' => $pairs, 'changed' => $changed, 'reset' => $reset]; + } + + /** + * Meldungen mit einem Verweis, der kein gültiges Paar (mehr) ist. + */ + public function staleReferencesQuery(): QueryBuilder + { + return DB::table('press_releases as p') + ->leftJoin('press_releases as c', 'c.id', '=', 'p.canonical_press_release_id') + ->whereNotNull('p.canonical_press_release_id') + ->where(fn (QueryBuilder $query) => $query + ->whereNull('c.id') + ->orWhereNotNull('c.deleted_at') + ->orWhere('c.status', '!=', 'published') + ->orWhereNotNull('c.gone_at') + ->orWhere('c.portal', 'both') + ->orWhere('p.portal', 'both') + ->orWhereColumn('c.portal', '=', 'p.portal') + ->orWhereNull('c.company_id') + ->orWhereColumn('c.company_id', '!=', 'p.company_id') + ->orWhereNull('c.content_hash') + ->orWhereNull('p.content_hash') + ->orWhereColumn('c.content_hash', '!=', 'p.content_hash')) + ->select(['p.id']); } /** @@ -300,16 +441,22 @@ class CanonicalAssigner return sprintf('%s/%s/press-release/%s', $base, $edition, $pressRelease->slug); } - private function categorySlug(int $categoryId): ?string + /** + * @return array{root: int|null, slug: string|null} + */ + private function categoryRoot(int $categoryId): array { - if (array_key_exists($categoryId, $this->categorySlugs)) { - return $this->categorySlugs[$categoryId]; + if (array_key_exists($categoryId, $this->categoryRoots)) { + return $this->categoryRoots[$categoryId]; } $category = Category::query()->withoutGlobalScopes()->with(['parent.translations', 'translations'])->find($categoryId); $root = $category !== null && $category->parent !== null ? $category->parent : $category; $slug = $root?->translations->firstWhere('locale', 'de')?->slug; - return $this->categorySlugs[$categoryId] = is_string($slug) ? $slug : null; + return $this->categoryRoots[$categoryId] = [ + 'root' => $root?->getKey(), + 'slug' => is_string($slug) ? $slug : null, + ]; } } diff --git a/app/Services/PressRelease/FeedFetchReport.php b/app/Services/PressRelease/FeedFetchReport.php index 6f6be9ff..dd53874f 100644 --- a/app/Services/PressRelease/FeedFetchReport.php +++ b/app/Services/PressRelease/FeedFetchReport.php @@ -68,26 +68,17 @@ class FeedFetchReport */ public function topFeeds(int $days = 30, int $limit = 20): array { + // Gruppiert und begrenzt in SQL (Umsetzungsaudit 29.09.2026, OPT-1). + // Reader ohne Kennung zählen wie bisher als ein eigener Reader. $rows = FeedFetch::query() ->where('created_at', '>=', now()->subDays($days)) - ->selectRaw('feed, feed_key, portal, language, reader, count(*) as fetches, max(subscribers) as subscribers, max(created_at) as last_at') - ->groupBy('feed', 'feed_key', 'portal', 'language', 'reader') + ->selectRaw("feed, feed_key, portal, language, count(*) as fetches, count(distinct coalesce(reader, '')) as readers, max(subscribers) as subscribers, max(created_at) as last_at") + ->groupBy('feed', 'feed_key', 'portal', 'language') + ->orderByDesc('fetches') + ->orderByDesc('last_at') + ->limit($limit) ->toBase() - ->get() - ->groupBy(fn (object $row): string => implode('|', [(string) $row->feed, (string) $row->feed_key, (string) $row->portal, (string) $row->language])) - ->map(fn ($readers): object => (object) [ - 'feed' => (string) $readers->first()->feed, - 'feed_key' => $readers->first()->feed_key, - 'portal' => (string) $readers->first()->portal, - 'language' => (string) $readers->first()->language, - 'fetches' => (int) $readers->sum('fetches'), - 'readers' => $readers->count(), - 'subscribers' => $readers->whereNotNull('subscribers')->max('subscribers'), - 'last_at' => $readers->max('last_at'), - ]) - ->sortByDesc('fetches') - ->take($limit) - ->values(); + ->get(); $categoryNames = $this->categoryNames($rows->where('feed', FeedFetch::FEED_CATEGORY)->pluck('feed_key')->filter()->unique()->values()->all()); $companyNames = $this->companyNames($rows->where('feed', FeedFetch::FEED_NEWSROOM)->pluck('feed_key')->filter()->unique()->values()->all()); @@ -152,19 +143,32 @@ class FeedFetchReport return []; } - $since = now()->subDays($days); + $key = static fn (string $feed, ?string $feedKey, string $portal, string $language): string => implode(':', [$feed, (string) $feedKey, $portal, $language]); + + // Eine Abfrage für alle Feeds statt einer je Feed (OPT-1). + $found = FeedFetch::query() + ->where('created_at', '>=', now()->subDays($days)) + ->where(function ($query) use ($feeds): void { + foreach ($feeds as $feed) { + $query->orWhere(fn ($match) => $match + ->where('feed', $feed['feed']) + ->when($feed['feed_key'] === null, fn ($q) => $q->whereNull('feed_key'), fn ($q) => $q->where('feed_key', $feed['feed_key'])) + ->where('portal', $feed['portal']) + ->where('language', $feed['language'])); + } + }) + ->selectRaw('feed, feed_key, portal, language, count(*) as fetches') + ->groupBy('feed', 'feed_key', 'portal', 'language') + ->toBase() + ->get() + ->mapWithKeys(fn (object $row): array => [ + $key((string) $row->feed, $row->feed_key === null ? null : (string) $row->feed_key, (string) $row->portal, (string) $row->language) => (int) $row->fetches, + ]); + $counts = []; foreach ($feeds as $feed) { - $key = implode(':', [$feed['feed'], (string) $feed['feed_key'], $feed['portal'], $feed['language']]); - - $counts[$key] = FeedFetch::query() - ->where('feed', $feed['feed']) - ->when($feed['feed_key'] === null, fn ($query) => $query->whereNull('feed_key'), fn ($query) => $query->where('feed_key', $feed['feed_key'])) - ->where('portal', $feed['portal']) - ->where('language', $feed['language']) - ->where('created_at', '>=', $since) - ->count(); + $counts[$key($feed['feed'], $feed['feed_key'], $feed['portal'], $feed['language'])] = (int) ($found[$key($feed['feed'], $feed['feed_key'], $feed['portal'], $feed['language'])] ?? 0); } return $counts; diff --git a/app/Services/PressRelease/VisibilityStats.php b/app/Services/PressRelease/VisibilityStats.php index 2fa039e9..bc3a1fdb 100644 --- a/app/Services/PressRelease/VisibilityStats.php +++ b/app/Services/PressRelease/VisibilityStats.php @@ -180,8 +180,13 @@ class VisibilityStats 'shares_total' => (int) $shares->sum(), // Die Sitemap listet jede im Archiv sichtbare Erstquelle — ab dem // Moment der Veröffentlichung. Das ist eine Aussage über uns, nicht - // über Google. - 'in_sitemap_since' => $sitemapPortals !== [] && $pressRelease->published_at !== null ? Carbon::instance($pressRelease->published_at) : null, + // über Google. Gibt es eine Fassung auf dem anderen Portal, kann + // die Rolle gewechselt haben (Kopie → Erstquelle); einen Zeitpunkt + // dafür speichern wir nicht — dann kein Datum statt eines falschen + // (Umsetzungsaudit 29.09.2026, N17-Rest). + 'in_sitemap_since' => $sitemapPortals !== [] && $pressRelease->published_at !== null && ! $this->hasCounterpart($pressRelease) + ? Carbon::instance($pressRelease->published_at) + : null, 'sitemap_role' => $links === [] ? 'none' : ($sitemapPortals === [] ? 'copy' : 'source'), 'sitemap_portals' => $sitemapPortals, 'crawlers' => $this->crawlerFetches($pressRelease), @@ -190,6 +195,26 @@ class VisibilityStats ]; } + /** + * Ob es dieselbe Meldung (Prüfsumme, Firma) auf einem anderen Portal gibt + * oder gab — dann ist „gelistet seit Veröffentlichung" nicht belegbar. + */ + private function hasCounterpart(PressRelease $pressRelease): bool + { + if ($pressRelease->content_hash === null || ! app(CanonicalAssigner::class)->enabled()) { + return false; + } + + return PressRelease::query() + ->withoutGlobalScope(PortalScope::class) + ->withTrashed() + ->where('content_hash', $pressRelease->content_hash) + ->where('company_id', $pressRelease->company_id) + ->whereKeyNot($pressRelease->getKey()) + ->where('portal', '!=', $pressRelease->portal->value) + ->exists(); + } + /** * RSS-Feeds, in denen die Meldung erscheint (Roadmap 3.1): je Portal der * Feed der Branche, der des Newsrooms und der Portal-Feed. Beide führen die jüngsten diff --git a/app/Services/SearchConsole/IndexStatusReport.php b/app/Services/SearchConsole/IndexStatusReport.php index ea1ede58..a64d2d91 100644 --- a/app/Services/SearchConsole/IndexStatusReport.php +++ b/app/Services/SearchConsole/IndexStatusReport.php @@ -7,6 +7,7 @@ use App\Enums\Portal; use App\Models\PressRelease; use App\Models\PressReleaseIndexCheck; use App\Scopes\PortalScope; +use App\Services\PressRelease\CanonicalAssigner; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; @@ -32,13 +33,6 @@ class IndexStatusReport private const int CACHE_SECONDS = 900; - /** - * Rolle einer Meldung in der kanonischen Zuordnung (1c.8): Erstquelle - * oder Kopie mit rel=canonical auf die andere Fassung. Für eine Kopie ist - * „ausgeschlossen" (Google folgt dem Verweis) der erwartete Befund. - */ - private const string ROLE_SQL = "case when press_releases.canonical_press_release_id is null then 'source' else 'copy' end"; - /** @var list */ public const array ORIGINS = ['direct', 'api']; @@ -65,13 +59,15 @@ class IndexStatusReport $rows = []; foreach ([Portal::Presseecho, Portal::Businessportal24] as $portal) { + $roleSql = $this->roleSql($portal); + // Herkunft und Rolle je Meldung erst in einer abgeleiteten Tabelle // bestimmen, dann darüber gruppieren — MySQL (only_full_group_by) // lehnt den korrelierten Ausdruck direkt im GROUP BY ab. $visible = DB::query() ->fromSub( $this->apiJoin($this->visibleOn($portal)) - ->selectRaw('press_releases.id, '.self::ORIGIN_SQL.' as origin, '.self::ROLE_SQL.' as role'), + ->selectRaw('press_releases.id, '.self::ORIGIN_SQL.' as origin, '.$roleSql.' as role'), 'r' ) ->selectRaw('origin, role, count(*) as total') @@ -87,7 +83,7 @@ class IndexStatusReport $this->apiJoin($this->latestChecks($portal) ->join('press_releases', 'press_releases.id', '=', 'press_release_index_checks.press_release_id') ->whereIn('press_releases.id', $this->visibleOn($portal)->select('press_releases.id'))) - ->selectRaw('press_release_index_checks.id, press_release_index_checks.state, '.self::ORIGIN_SQL.' as origin, '.self::ROLE_SQL.' as role'), + ->selectRaw('press_release_index_checks.id, press_release_index_checks.state, '.self::ORIGIN_SQL.' as origin, '.$roleSql.' as role'), 'c' ) ->selectRaw('origin, role, state, count(*) as total') @@ -198,6 +194,33 @@ class IndexStatusReport * @param Builder $query * @return Builder */ + /** + * Rolle einer Meldung auf `$portal` in der kanonischen Zuordnung (1c.8): + * Erstquelle oder Kopie mit rel=canonical auf die andere Fassung. Für + * eine Kopie ist „ausgeschlossen" (Google folgt dem Verweis) der + * erwartete Befund. Dieselbe Regel wie CanonicalAssigner::isCanonicalOn() + * und damit die Sitemap: Ohne Freigabe ist alles Erstquelle; eine + * Meldung mit portal=both ist nur auf dem Portal ihrer Kategorie + * Erstquelle, auf dem anderen Kopie (Umsetzungsaudit 29.09.2026, + * N18-Rest). + */ + private function roleSql(Portal $portal): string + { + $assigner = app(CanonicalAssigner::class); + + if (! $assigner->enabled()) { + return "'source'"; + } + + $other = $portal === Portal::Presseecho ? Portal::Businessportal24 : Portal::Presseecho; + $foreignCategories = array_map('intval', $assigner->categoryIdsPreferring($other)); + $bothOnOtherPortal = $foreignCategories === [] + ? '' + : " when press_releases.portal = '".Portal::Both->value."' and press_releases.category_id in (".implode(',', $foreignCategories).") then 'copy'"; + + return "case when press_releases.canonical_press_release_id is not null then 'copy'".$bothOnOtherPortal." else 'source' end"; + } + private function apiJoin(Builder $query): Builder { $api = DB::table('press_release_status_logs') diff --git a/app/Services/Web/ReleaseDetailPageData.php b/app/Services/Web/ReleaseDetailPageData.php index aa11cb59..6f428c8e 100644 --- a/app/Services/Web/ReleaseDetailPageData.php +++ b/app/Services/Web/ReleaseDetailPageData.php @@ -89,20 +89,23 @@ class ReleaseDetailPageData } $releaseId = $release->getKey(); - defer(static fn () => PressRelease::recordDeferredView($releaseId)); // Crawler-Abruf-Protokoll (Roadmap 1c.0): nur bekannte Such- und - // KI-Dienste, erkannt am User-Agent, ohne IP. + // KI-Dienste, erkannt am User-Agent, ohne IP. Ein erkannter Crawler + // ist kein Aufruf (Umsetzungsaudit 29.09.2026, R-07) — er landet nur + // im Crawler-Protokoll, nicht im Aufrufzähler. $userAgent = (string) request()->userAgent(); $crawler = CrawlerIdentifier::identify($userAgent); if ($crawler !== null) { defer(static fn () => PressRelease::recordDeferredCrawlerHit($releaseId, $crawler, $userAgent)); - } + } else { + defer(static fn () => PressRelease::recordDeferredView($releaseId)); - // Die Antwort zeigt den aktuellen Aufruf bereits an; die atomaren - // Schreibzugriffe erfolgen nach dem Senden der Response. - $release->hits = ($release->hits ?? 0) + 1; + // Die Antwort zeigt den aktuellen Aufruf bereits an; die atomaren + // Schreibzugriffe erfolgen nach dem Senden der Response. + $release->hits = ($release->hits ?? 0) + 1; + } $with = [ 'company', diff --git a/app/Support/WebCaches.php b/app/Support/WebCaches.php index d65a38a6..6c2352b4 100644 --- a/app/Support/WebCaches.php +++ b/app/Support/WebCaches.php @@ -24,7 +24,8 @@ class WebCaches /** * Sitemap-Caches aller Hosts verwerfen (SitemapController cached 1h je - * Host) — nach einer Mutation darf kein Cache den alten Stand halten. + * Host), dazu llms.txt — nach einer Mutation darf kein Cache den alten + * Stand halten. */ public static function forgetSitemaps(): void { @@ -34,6 +35,9 @@ class WebCaches if ($host) { Cache::forget('web.sitemap.'.$host); Cache::forget('web.sitemap-news.'.$host); + // llms.txt nennt die aktiven Kategorien (LlmsTxtController, + // 1 h je Host) — Umsetzungsaudit 29.09.2026, R-11. + Cache::forget('web.llms.'.$host); } } } diff --git a/config/bounces.php b/config/bounces.php index 97136938..c9ead79d 100644 --- a/config/bounces.php +++ b/config/bounces.php @@ -14,7 +14,8 @@ return [ | | reference_header: Eigener Header je Mail (z. B. `pm:123:company:456`). | Ein DSN bringt die Original-Header zurück; damit ordnet der Parser den - | Rückläufer dem Datensatz zu, ohne aus dem Fließtext zu raten. + | Rückläufer dem Datensatz zu, ohne aus dem Fließtext zu raten. Das Gate + | hängt je Empfänger eine Signatur an (`; s=…`, BounceReference). | | soft: Weiche Bounces (4.x.x, delayed) sperren erst ab `threshold` | Ereignissen innerhalb von `window_days`. @@ -30,6 +31,24 @@ return [ 'reference_header' => 'X-Pk-Ref', + // Umsetzungsaudit 29.09.2026, R-04: Das Gate signiert X-Pk-Ref je + // Empfänger (HMAC). Ein harter Bounce sperrt nur mit gültiger Signatur + // für genau diese Adresse. Leer = aus APP_KEY abgeleitet. + 'signing_key' => env('BOUNCE_SIGNING_KEY'), + + // Bounce OHNE Signatur (vor der Umstellung versendet, Freitext-Bericht + // des Relays), hart wie weich: `policy` nur protokollieren (Standard, + // zur Sichtung im Admin), `soft` zählt auf den Schwellwert — dann sperren + // drei gefälschte Berichte in sieben Tagen jede Adresse. Hart wird ein + // unsignierter Bericht nie (Umsetzungsaudit 02.10.2026, O01). + 'unsigned_kind' => env('BOUNCE_UNSIGNED_KIND', 'policy'), + + // Aufbewahrung der Rückläufer-Ereignisse (`email_bounces`: Adresse, + // Diagnosetext, Referenz) — Umsetzungsaudit 29.09.2026, R-10. Gelöscht + // von `conversion:purge-events`; 395 Tage wie die übrigen Ereignis- + // protokolle (DSE 5a). Die Sperrliste selbst bleibt bestehen. + 'retention_days' => (int) env('BOUNCE_RETENTION_DAYS', 395), + 'soft' => [ 'threshold' => (int) env('BOUNCE_SOFT_THRESHOLD', 3), 'window_days' => (int) env('BOUNCE_SOFT_WINDOW_DAYS', 7), diff --git a/config/canonical.php b/config/canonical.php index 5e4b4c2c..73e2603f 100644 --- a/config/canonical.php +++ b/config/canonical.php @@ -21,8 +21,32 @@ return [ 'enabled' => (bool) env('CANONICAL_ASSIGNMENT_ENABLED', true), - // DE-Slug der Hauptkategorie => Portal, das die Erstquelle führt. - // Vorschlag zu E9; Kategorien ohne Eintrag fallen auf `fallback`. + // ID der Hauptkategorie => Portal, das die Erstquelle führt (E9). + // Maßgeblich vor `portal_by_category`: eine Slug-Änderung im Admin + // schaltete die Zuordnung sonst still ab (Umsetzungsaudit 29.09.2026, + // R-11). IDs aus dem Bestand (Stand 29.09.2026, identisch mit der + // Slug-Liste unten — gleiche Wirkung). Im Testlauf leer: dort vergibt + // SQLite die IDs neu, Tests setzen ihre Zuordnung selbst. + 'portal_by_category_id' => env('APP_ENV') === 'testing' ? [] : [ + 1 => 'businessportal24', // handel + 2 => 'businessportal24', // it + 3 => 'businessportal24', // finanzen + 4 => 'presseecho', // reisen + 5 => 'presseecho', // kultur + 6 => 'presseecho', // politik + 7 => 'presseecho', // medizin + 8 => 'presseecho', // sport + 9 => 'businessportal24', // motor-verkehr + 10 => 'presseecho', // haus + 11 => 'presseecho', // mode + 12 => 'presseecho', // sonstiges + 13 => 'businessportal24', // wissenschaft-forschung + 14 => 'businessportal24', // recht + ], + + // Rückwärtskompatibel: DE-Slug der Hauptkategorie => Portal. Greift nur + // für Hauptkategorien ohne Eintrag in `portal_by_category_id`. + // Kategorien ohne beides fallen auf `fallback`. 'portal_by_category' => [ 'finanzen' => 'businessportal24', 'handel' => 'businessportal24', diff --git a/config/conversion.php b/config/conversion.php index 6fc7ac0b..fd1f336b 100644 --- a/config/conversion.php +++ b/config/conversion.php @@ -24,4 +24,25 @@ return [ 'retention_days' => (int) env('CONVERSION_EVENTS_RETENTION_DAYS', 395), ], + /* + | Stufe 1 (Mail-Link geklickt) — Umsetzungsaudit 29.09.2026, R-08. + | Derselbe Klick (IP + Meldung) zählt höchstens einmal je + | `throttle_minutes`; die IP wird dafür nur gehasht im Cache gehalten. + | Link-Scanner der Mailserver (Sicherheits-Gateways, Vorschau-Dienste) + | holen Links vorab — erkannt an HEAD oder an diesen Kennungen, gezählt + | wird dann nichts. Bekannte Such- und KI-Crawler (config/crawlers.php) + | gelten ebenfalls als kein Klick. + */ + + 'mail_clicks' => [ + 'throttle_minutes' => (int) env('CONVERSION_MAIL_CLICK_THROTTLE_MINUTES', 30), + 'scanner_agents' => [ + 'barracuda', 'mimecast', 'proofpoint', 'bingpreview', 'symantec', 'messagelabs', + 'trendmicro', 'trend micro', 'forcepoint', 'sophos', 'zscaler', 'ironport', 'fortiguard', + 'safelinks', 'microsoft office', 'ms-office', 'google-safety', 'headlesschrome', + 'python-requests', 'python-urllib', 'go-http-client', 'curl/', 'wget/', 'okhttp', 'java/', + 'libwww-perl', 'scrapy', 'phantomjs', 'slackbot', 'facebookexternalhit', 'whatsapp', + ], + ], + ]; diff --git a/database/migrations/2026_09_29_170000_add_content_changed_at_to_press_releases_table.php b/database/migrations/2026_09_29_170000_add_content_changed_at_to_press_releases_table.php new file mode 100644 index 00000000..61a0a387 --- /dev/null +++ b/database/migrations/2026_09_29_170000_add_content_changed_at_to_press_releases_table.php @@ -0,0 +1,35 @@ +timestamp('content_changed_at')->nullable()->after('content_hash'); + }); + } + + public function down(): void + { + Schema::table('press_releases', function (Blueprint $table) { + $table->dropColumn('content_changed_at'); + }); + } +}; diff --git a/database/migrations/2026_09_29_170100_add_merged_value_to_company_merge_moves_table.php b/database/migrations/2026_09_29_170100_add_merged_value_to_company_merge_moves_table.php new file mode 100644 index 00000000..6f1c1024 --- /dev/null +++ b/database/migrations/2026_09_29_170100_add_merged_value_to_company_merge_moves_table.php @@ -0,0 +1,29 @@ +text('merged_value')->nullable()->after('previous_value'); + }); + } + + public function down(): void + { + Schema::table('company_merge_moves', function (Blueprint $table) { + $table->dropColumn('merged_value'); + }); + } +}; diff --git a/resources/views/livewire/admin/categories/create.blade.php b/resources/views/livewire/admin/categories/create.blade.php index 2a95f3ab..a088f7ab 100644 --- a/resources/views/livewire/admin/categories/create.blade.php +++ b/resources/views/livewire/admin/categories/create.blade.php @@ -3,6 +3,7 @@ use App\Enums\Portal; use App\Models\Category; use App\Models\CategoryTranslation; +use App\Support\WebCaches; use Illuminate\Support\Facades\DB; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; @@ -92,6 +93,13 @@ new #[Layout('components.layouts.app'), Title('Kategorie anlegen')] class extend return $category; }); + // Navigation, Kategorienliste und llms.txt liegen bis zu einer Stunde + // im Cache — eine aktive neue Branche soll sofort erscheinen + // (Umsetzungsaudit 29.09.2026, 5.2-Rest; edit leert ebenso). + if ($this->isActive) { + WebCaches::forgetPublicPages(); + } + session()->flash('success', __('Kategorie wurde angelegt.')); $this->redirect(route('admin.categories.index'), navigate: true); } diff --git a/resources/views/livewire/admin/reports/index-status.blade.php b/resources/views/livewire/admin/reports/index-status.blade.php index f6a10fbe..06a88705 100644 --- a/resources/views/livewire/admin/reports/index-status.blade.php +++ b/resources/views/livewire/admin/reports/index-status.blade.php @@ -340,7 +340,7 @@ new #[Layout('components.layouts.app'), Title('Indexierung und Abrufe')] class e @endif
- {{ __('Ein Abruf belegt, dass der Crawler des Dienstes die Seite geholt hat — nicht, dass sie dort angezeigt oder zitiert wird. Erkennung über den User-Agent (config/crawlers.php), keine IP-Adressen.') }} + {{ __('Ein Abruf belegt, dass der Crawler des Dienstes die Seite geholt hat — nicht, dass sie dort angezeigt oder zitiert wird. Erkennung allein über die Kennung des Abrufers (User-Agent, config/crawlers.php), ohne Prüfung der Herkunft und ohne IP-Adressen; erkannte Crawler zählen nicht als Aufrufe.') }} @if ($crawlersSince) {{ __('Protokoll seit :date.', ['date' => $crawlersSince->format('d.m.Y')]) }} @endif diff --git a/resources/views/web/release-detail.blade.php b/resources/views/web/release-detail.blade.php index f8bbc705..1f4fd914 100644 --- a/resources/views/web/release-detail.blade.php +++ b/resources/views/web/release-detail.blade.php @@ -103,6 +103,11 @@ ? $shareUrl : app(CanonicalAssigner::class)->canonicalUrlFor($release, $brandLabel === 'presseecho' ? Portal::Presseecho : Portal::Businessportal24); + // dateModified/article:modified_time = letzte inhaltliche Änderung, nie + // vor datePublished (PressRelease::contentModifiedAt()); updated_at + // bewegen auch Aufrufzähler und Merge (Umsetzungsaudit 29.09.2026, R-02). + $modifiedAt = $release->contentModifiedAt() ?? $publishedAt; + // SEO: NewsArticle + BreadcrumbList als JSON-LD — nur für die öffentliche // Seite (die Vorschau ist noindex und hat keine stabile URL). $jsonLd = null; @@ -111,12 +116,6 @@ $portalBase = StructuredData::portalBase($domainUrl ?? null); $authorNewsroomUrl = $company?->slug ? StructuredData::newsroomUrl($portalBase, $company->slug) : null; - // dateModified darf laut Google nicht vor datePublished liegen - // (updated_at kann bei terminierten PMs älter sein als published_at). - $modifiedAt = $release->updated_at?->greaterThan($publishedAt ?? $release->updated_at) - ? $release->updated_at - : $publishedAt; - // URLs aus der Portal-Basis (wie der Canonical) statt route(), damit // Host und Canonical auch bei forceRootUrl-Abweichungen übereinstimmen. $breadcrumbs = collect([ @@ -180,8 +179,8 @@ @if ($publishedAt) @endif - @if ($release->updated_at?->greaterThan($publishedAt ?? $release->updated_at)) - + @if ($publishedAt && $modifiedAt?->greaterThan($publishedAt)) + @endif @if ($categoryName) diff --git a/tests/Feature/Admin/AdminCategoryManagementTest.php b/tests/Feature/Admin/AdminCategoryManagementTest.php index 46854264..7e821df2 100644 --- a/tests/Feature/Admin/AdminCategoryManagementTest.php +++ b/tests/Feature/Admin/AdminCategoryManagementTest.php @@ -4,7 +4,9 @@ use App\Enums\Portal; use App\Models\Category; use App\Models\PressRelease; use App\Models\User; +use App\Support\WebPageCache; use Database\Seeders\RolesAndPermissionsSeeder; +use Illuminate\Support\Facades\Cache; use Livewire\Volt\Volt as LivewireVolt; use Tests\TestCase; @@ -176,3 +178,21 @@ test('deactivating a category clears the cached public pages and its feed at onc // Nicht erst nach Ablauf der Stale-Grenze: sofort weg. $this->get('https://presseecho.test/de/category/handel/feed')->assertNotFound(); }); + +test('creating an active category clears the cached public pages at once', function () { + /** @var TestCase $this */ + $generation = WebPageCache::generation(); + Cache::put('web.llms.presseecho.test', 'alt', 3600); + + LivewireVolt::test('admin.categories.create') + ->set('portal', Portal::Presseecho->value) + ->set('nameDe', 'Energie') + ->set('nameEn', 'Energy') + ->call('save') + ->assertHasNoErrors(); + + // Umsetzungsaudit 29.09.2026, 5.2-Rest: Navigation und llms.txt zeigen + // die neue Branche sofort, nicht erst nach bis zu einer Stunde. + expect(WebPageCache::generation())->not->toBe($generation) + ->and(Cache::has('web.llms.presseecho.test'))->toBeFalse(); +}); diff --git a/tests/Feature/Api/LegacyServiceParityTest.php b/tests/Feature/Api/LegacyServiceParityTest.php index e75587c9..fad67ebd 100644 --- a/tests/Feature/Api/LegacyServiceParityTest.php +++ b/tests/Feature/Api/LegacyServiceParityTest.php @@ -301,3 +301,21 @@ test('api submissions without press contact address the company by name', functi && ! str_contains($html, 'Sebastian Einbock'); }); }); + +test('the legacy view url follows the language of the press release', function () { + /** @var TestCase $this */ + $user = parityApiUser(); + $company = Company::factory()->create(['portal' => Portal::Businessportal24]); + $pressRelease = PressRelease::factory()->create([ + 'user_id' => $user->id, + 'company_id' => $company->id, + 'portal' => Portal::Businessportal24, + 'language' => 'en', + 'status' => PressReleaseStatus::Published, + 'published_at' => now()->subDay(), + ]); + + // Englische Meldungen liegen unter /en/ — /de/ antwortet für sie 404. + expect($this->getJson("/service/pressreleases/{$pressRelease->id}")->assertOk()->json('view_url')) + ->toEndWith("/en/press-release/{$pressRelease->slug}"); +}); diff --git a/tests/Feature/BounceMailProcessingTest.php b/tests/Feature/BounceMailProcessingTest.php index 23c6cff9..7dc65a66 100644 --- a/tests/Feature/BounceMailProcessingTest.php +++ b/tests/Feature/BounceMailProcessingTest.php @@ -5,6 +5,7 @@ use App\Enums\EmailBounceKind; use App\Models\ActivityEvent; use App\Models\EmailBounce; use App\Services\Mail\BounceMailProcessor; +use App\Services\Mail\BounceReference; use App\Services\Mail\DsnParser; use App\Services\Mail\EmailSuppressionService; use Tests\TestCase; @@ -18,6 +19,19 @@ function bounceFixture(string $name): string return (string) file_get_contents(base_path("tests/Fixtures/bounces/{$name}.eml")); } +/** + * Fixture, deren X-Pk-Ref wie beim echten Versand für `$recipient` signiert + * ist (Umsetzungsaudit 29.09.2026, R-04). + */ +function signedBounceFixture(string $name, string $recipient): string +{ + return (string) preg_replace_callback( + '/^X-Pk-Ref: (.+)$/m', + fn (array $m): string => 'X-Pk-Ref: '.app(BounceReference::class)->sign(trim($m[1]), [$recipient]), + bounceFixture($name), + ); +} + test('a postfix hard bounce yields recipient, status, diagnostic and our reference header', function () { /** @var TestCase $this */ $report = app(DsnParser::class)->parse(bounceFixture('hard-5.1.1')); @@ -35,7 +49,7 @@ test('a postfix hard bounce yields recipient, status, diagnostic and our referen test('processing a hard bounce suppresses the address and keeps the reference', function () { /** @var TestCase $this */ - $result = app(BounceMailProcessor::class)->process(bounceFixture('hard-5.1.1')); + $result = app(BounceMailProcessor::class)->process(signedBounceFixture('hard-5.1.1', 'presse@alte-firma.example')); expect($result['folder'])->toBe(BounceMailProcessor::Processed) ->and($result['bounces'])->toBe(1) @@ -44,13 +58,13 @@ test('processing a hard bounce suppresses the address and keeps the reference', ->and(EmailBounce::query()->first()->kind)->toBe(EmailBounceKind::Hard); // Zweiter Durchlauf derselben Mail (abgebrochener Lauf): nichts doppelt. - app(BounceMailProcessor::class)->process(bounceFixture('hard-5.1.1')); + app(BounceMailProcessor::class)->process(signedBounceFixture('hard-5.1.1', 'presse@alte-firma.example')); expect(EmailBounce::query()->count())->toBe(1); }); test('a delayed report counts as a soft bounce and lower-case field names are fine', function () { /** @var TestCase $this */ - $result = app(BounceMailProcessor::class)->process(bounceFixture('delayed-4.4.1')); + $result = app(BounceMailProcessor::class)->process(signedBounceFixture('delayed-4.4.1', 'voll@example.test')); expect($result['folder'])->toBe(BounceMailProcessor::Processed) ->and(EmailBounce::query()->first()->kind)->toBe(EmailBounceKind::Soft) @@ -89,7 +103,7 @@ test('the utf-8 dsn variant of rfc 6533 is read like a classic report', function ->and($report->recipients[0]['status'])->toBe('5.0.0') ->and($report->reference)->toBe('user:4711'); - $result = app(BounceMailProcessor::class)->process(bounceFixture('global-delivery-status')); + $result = app(BounceMailProcessor::class)->process(signedBounceFixture('global-delivery-status', 'weiterleitung@example.net')); expect($result['folder'])->toBe(BounceMailProcessor::Processed) ->and(EmailBounce::query()->first()->kind)->toBe(EmailBounceKind::Hard); @@ -104,10 +118,23 @@ test('the free-text notice of our own relay is read and never mistaken for an au $result = app(BounceMailProcessor::class)->process(bounceFixture('kas-antispam')); $suppressions = app(EmailSuppressionService::class); + // Der Freitext trägt keine Original-Header, also keine signierte + // Referenz: nur zur Sichtung protokolliert, zählt auf keinen + // Schwellwert (R-04, Umsetzungsaudit 02.10.2026, O01). expect($result['folder'])->toBe(BounceMailProcessor::Processed) ->and($result['bounces'])->toBe(2) - ->and($suppressions->isSuppressed('presse@example.net'))->toBeTrue() - ->and($suppressions->isSuppressed('info@example.org'))->toBeTrue(); + ->and(EmailBounce::query()->where('email', 'presse@example.net')->value('kind'))->toBe(EmailBounceKind::Policy) + ->and($suppressions->isSuppressed('presse@example.net'))->toBeFalse(); + + // Schwellwert-Fenster (7 Tage) relativ zum Datum der Fixture. + $this->travelTo('2026-09-03 12:00:00'); + + foreach ([2, 3] as $i) { + app(BounceMailProcessor::class)->process(str_replace('844FE5F41C65@', '844FE5F41C6'.$i.'@', bounceFixture('kas-antispam'))); + } + + expect($suppressions->isSuppressed('presse@example.net'))->toBeFalse() + ->and($suppressions->isSuppressed('info@example.org'))->toBeFalse(); }); test('policy rejections and full mailboxes never count hard', function () { @@ -124,7 +151,7 @@ test('policy rejections and full mailboxes never count hard', function () { $quota = str_replace( ['Status: 5.7.1', '554 5.7.1 : Relay access denied', '<20260915120000.ABC@'], ['Status: 5.2.2', '552 Requested mail action aborted: exceeded storage allocation', '<20260915120001.ABC@'], - bounceFixture('policy-5.7.1'), + signedBounceFixture('policy-5.7.1', 'pr@example.net'), ); app(BounceMailProcessor::class)->process($quota); @@ -153,3 +180,110 @@ test('a policy rejection is logged but never counts toward suppression', functio expect(EmailBounce::query()->where('kind', EmailBounceKind::Policy->value)->count())->toBe(4) ->and(app(EmailSuppressionService::class)->isSuppressed('pr@example.net'))->toBeFalse(); }); + +test('a forged report without our signature never suppresses hard', function () { + /** @var TestCase $this */ + // Jeder kann ein multipart/report ans Return-Path-Postfach schicken. + $result = app(BounceMailProcessor::class)->process(bounceFixture('hard-5.1.1')); + + expect($result['folder'])->toBe(BounceMailProcessor::Processed) + ->and(EmailBounce::query()->sole()->kind)->toBe(EmailBounceKind::Policy) + ->and(EmailBounce::query()->sole()->reference)->toBe('pm:4711:company:815 (unsigniert)') + ->and(app(EmailSuppressionService::class)->isSuppressed('presse@alte-firma.example'))->toBeFalse(); +}); + +test('a report with a wrong or foreign signature is parked for review', function () { + /** @var TestCase $this */ + // Signatur einer echten Mail an den Angreifer selbst, auf die Opferadresse umgeschrieben. + $replayed = signedBounceFixture('hard-5.1.1', 'angreifer@example.test'); + $result = app(BounceMailProcessor::class)->process($replayed); + + expect($result['bounces'])->toBe(1) + ->and(EmailBounce::query()->sole()->kind)->toBe(EmailBounceKind::Policy) + ->and(EmailBounce::query()->sole()->reference)->toBe('pm:4711:company:815 (Signatur ungültig)') + ->and(app(EmailSuppressionService::class)->isSuppressed('presse@alte-firma.example'))->toBeFalse(); + + // Beliebig oft wiederholt: Richtlinie zählt auf keinen Schwellwert. + foreach (range(1, 3) as $i) { + app(BounceMailProcessor::class)->process(str_replace('<20260915071233.ABC123@', '<2026091507123'.$i.'.ABC123@', $replayed)); + } + + expect(app(EmailSuppressionService::class)->isSuppressed('presse@alte-firma.example'))->toBeFalse(); +}); + +test('unsigned reports only count when the operator opts in, and then never hard', function () { + /** @var TestCase $this */ + config()->set('bounces.unsigned_kind', 'soft'); + + app(BounceMailProcessor::class)->process(bounceFixture('hard-5.1.1')); + + expect(EmailBounce::query()->sole()->kind)->toBe(EmailBounceKind::Soft) + ->and(app(EmailSuppressionService::class)->isSuppressed('presse@alte-firma.example'))->toBeFalse(); +}); + +test('repeated unsigned or wrongly signed soft reports never suppress an address', function (string $variant) { + /** @var TestCase $this */ + // Umsetzungsaudit 02.10.2026, O01: Der weiche Weg (delayed, failed 4.x) + // lief an der Signaturprüfung vorbei — drei gefälschte Berichte in + // sieben Tagen sperrten jede beliebige Adresse. + $raw = match ($variant) { + 'delayed, unsigned' => bounceFixture('delayed-4.4.1'), + 'delayed, foreign signature' => signedBounceFixture('delayed-4.4.1', 'angreifer@example.test'), + 'failed 4.x, unsigned' => str_replace(['Action: delayed', 'action: delayed'], ['Action: failed', 'action: failed'], bounceFixture('delayed-4.4.1')), + }; + + $this->travelTo('2026-09-17 12:00:00'); + + foreach (range(1, 4) as $i) { + app(BounceMailProcessor::class)->process(str_replace('count())->toBe(4) + ->and(EmailBounce::query()->where('kind', '!=', EmailBounceKind::Policy->value)->count())->toBe(0) + ->and(app(EmailSuppressionService::class)->isSuppressed('voll@example.test'))->toBeFalse(); +})->with(['delayed, unsigned', 'delayed, foreign signature', 'failed 4.x, unsigned']); + +test('validly signed soft reports still suppress at the threshold', function () { + /** @var TestCase $this */ + // Schwellwert-Fenster (7 Tage) relativ zum Datum der Fixture. + $this->travelTo('2026-09-17 12:00:00'); + + foreach (range(1, 3) as $i) { + app(BounceMailProcessor::class)->process(str_replace('where('kind', EmailBounceKind::Soft->value)->count())->toBe(3) + ->and(app(EmailSuppressionService::class)->isSuppressed('voll@example.test'))->toBeTrue(); +}); + +test('the reference signature is bound to the recipient', function () { + $references = app(BounceReference::class); + $header = $references->sign('user:5', ['A@Example.test', 'b@example.test']); + + expect($header)->toStartWith('user:5; s=') + ->and($references->plain($header))->toBe('user:5') + ->and($references->verify($header, 'a@example.test'))->toBeTrue() + ->and($references->verify($header, 'b@example.test'))->toBeTrue() + ->and($references->verify($header, 'c@example.test'))->toBeFalse() + ->and($references->verify(str_replace('user:5', 'user:6', $header), 'a@example.test'))->toBeFalse() + ->and($references->verify('user:5', 'a@example.test'))->toBeFalse() + ->and($references->isSigned('user:5'))->toBeFalse(); +}); + +test('bounce events are purged after the retention period while the suppression stays', function () { + /** @var TestCase $this */ + app(BounceMailProcessor::class)->process(signedBounceFixture('hard-5.1.1', 'presse@alte-firma.example')); + app(BounceMailProcessor::class)->process(bounceFixture('delayed-4.4.1')); + EmailBounce::query()->where('email', 'presse@alte-firma.example')->update(['created_at' => now()->subDays(400)]); + + $this->artisan('conversion:purge-events', ['--dry-run' => true]) + ->expectsOutputToContain('[Dry-Run] 1 Rückläufer-Ereignisse') + ->assertSuccessful(); + expect(EmailBounce::query()->count())->toBe(2); + + // Umsetzungsaudit 29.09.2026, R-10: Adresse und Diagnosetext nach 395 Tagen weg. + $this->artisan('conversion:purge-events')->assertSuccessful(); + + expect(EmailBounce::query()->pluck('email')->all())->toBe(['voll@example.test']) + ->and(app(EmailSuppressionService::class)->isSuppressed('presse@alte-firma.example'))->toBeTrue(); +}); diff --git a/tests/Feature/CanonicalAssignmentTest.php b/tests/Feature/CanonicalAssignmentTest.php index 5b8f79bc..0554e04d 100644 --- a/tests/Feature/CanonicalAssignmentTest.php +++ b/tests/Feature/CanonicalAssignmentTest.php @@ -287,3 +287,76 @@ test('a soft-deleted source is no canonical target even while the copy still poi ->and(app(CanonicalAssigner::class)->canonicalUrlFor($pe, Portal::Presseecho)) ->toBe('https://presseecho.test/de/press-release/doppel-standort'); }); + +test('assigning the backlog resets references that no longer form a pair', function () { + /** @var TestCase $this */ + config()->set('canonical.enabled', true); + config()->set('canonical.portal_by_category', ['it' => 'businessportal24']); + + [$pe, $b24] = duplicatePair('it'); + [$pe2, $b242] = duplicatePair('it', ['slug' => 'zweites-paar', 'title' => 'Zweites Paar'], ['slug' => 'zweites-paar', 'title' => 'Zweites Paar']); + $assigner = app(CanonicalAssigner::class); + $assigner->assignAll(); + expect($pe->fresh()->canonical_press_release_id)->toBe($b24->id) + ->and($pe2->fresh()->canonical_press_release_id)->toBe($b242->id); + + // Ohne Hook umgehängt (Query-Builder): die Kopie gehört jetzt einer anderen Firma. + DB::table('press_releases')->where('id', $pe->id)->update(['company_id' => Company::factory()->create()->id]); + // Zweites Paar: Text der Erstquelle geändert, ohne Neubewertung. + DB::table('press_releases')->where('id', $b242->id)->update(['content_hash' => str_repeat('a', 64)]); + + $dry = $assigner->assignAll(dryRun: true); + expect($dry['reset'])->toBe(2) + ->and($pe->fresh()->canonical_press_release_id)->toBe($b24->id); + + $result = $assigner->assignAll(); + + expect($result['reset'])->toBe(2) + ->and($pe->fresh()->canonical_press_release_id)->toBeNull() + ->and($pe2->fresh()->canonical_press_release_id)->toBeNull(); + + $this->artisan(AssignCanonicalPressReleases::class)->assertSuccessful(); +}); + +test('copies are re-evaluated when their source is archived by an admin (Audit N19-Rest)', function () { + /** @var TestCase $this */ + Mail::fake(); + config()->set('canonical.enabled', true); + config()->set('canonical.portal_by_category', ['it' => 'businessportal24']); + + [$pe, $b24] = duplicatePair('it'); + $assigner = app(CanonicalAssigner::class); + $assigner->assignFor($pe); + expect($pe->fresh()->canonical_press_release_id)->toBe($b24->id); + + app(PressReleaseService::class)->archive($b24->fresh()); + + expect($pe->fresh()->canonical_press_release_id)->toBeNull() + ->and($assigner->isCanonicalOn($pe->fresh(), Portal::Presseecho))->toBeTrue(); +}); + +test('the mapping by category id survives a slug change and wins over the slug list', function () { + config()->set('canonical.enabled', true); + + [$pe, $b24, $category] = duplicatePair('it'); + config()->set('canonical.portal_by_category', ['it' => 'businessportal24']); + config()->set('canonical.portal_by_category_id', [$category->id => 'presseecho']); + + $assigner = app(CanonicalAssigner::class); + expect($assigner->preferredPortal($pe))->toBe(Portal::Presseecho); + + // Slug im Admin umbenannt: die ID-Zuordnung greift weiter. + $category->translations()->where('locale', 'de')->update(['slug' => 'informationstechnik']); + $fresh = app()->make(CanonicalAssigner::class); + expect($fresh->preferredPortal($pe))->toBe(Portal::Presseecho); + + // Unterkategorien folgen der Hauptkategorie. + $child = Category::factory()->create(['portal' => Portal::Both, 'parent_id' => $category->id]); + expect($fresh->preferredPortalForCategory($child->id))->toBe(Portal::Presseecho) + ->and($fresh->categoryIdsPreferring(Portal::Presseecho))->toContain($category->id, $child->id); + + // Ohne ID-Eintrag: rückwärtskompatibel über den DE-Slug. + config()->set('canonical.portal_by_category_id', []); + config()->set('canonical.portal_by_category', ['informationstechnik' => 'businessportal24']); + expect(app()->make(CanonicalAssigner::class)->preferredPortal($pe))->toBe(Portal::Businessportal24); +}); diff --git a/tests/Feature/CompanyMergeTest.php b/tests/Feature/CompanyMergeTest.php index 00f1ae7d..44d45e00 100644 --- a/tests/Feature/CompanyMergeTest.php +++ b/tests/Feature/CompanyMergeTest.php @@ -3,6 +3,7 @@ use App\Enums\CompanyAddressClass; use App\Enums\CompanyMergeSubject; use App\Enums\Portal; +use App\Models\Category; use App\Models\Company; use App\Models\CompanyEmailGroup; use App\Models\CompanyMerge; @@ -12,6 +13,7 @@ use App\Models\User; use App\Services\Company\CompanyMergePlan; use App\Services\Company\CompanyMergeService; use App\Services\Company\CompanyMergeSkip; +use App\Services\Company\MergedNewsroomLookup; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; use Tests\TestCase; @@ -345,3 +347,118 @@ test('the merge list shows survivor, merged companies and the URLs to check', fu ->expectsOutputToContain('muster-gmbh') ->assertSuccessful(); }); + +test('merge and unmerge leave updated_at of the moved releases untouched', function () { + /** @var TestCase $this */ + mergeGroup('info@muster.de'); + mergeMember('info@muster.de', Portal::Presseecho, 3); + $loser = mergeMember('info@muster.de', Portal::Businessportal24, 1); + $release = PressRelease::withoutGlobalScopes()->where('company_id', $loser->id)->sole(); + $before = $release->updated_at->toDateTimeString(); + + $this->travel(3)->days(); + $service = app(CompanyMergeService::class); + $merge = $service->merge(planFor('info@muster.de'), 'b'); + expect(PressRelease::withoutGlobalScopes()->find($release->id)->updated_at->toDateTimeString())->toBe($before); + + $service->revert($merge); + expect(PressRelease::withoutGlobalScopes()->find($release->id)) + ->company_id->toBe($loser->id) + ->updated_at->toDateTimeString()->toBe($before); +}); + +test('unmerge keeps a field the customer changed after the merge', function () { + /** @var TestCase $this */ + mergeGroup('info@muster.de'); + $survivor = mergeMember('info@muster.de', Portal::Presseecho, 3, ['website' => null, 'boilerplate' => null, 'phone' => null]); + mergeMember('info@muster.de', Portal::Businessportal24, 0, ['website' => 'https://muster.de', 'boilerplate' => 'Über Muster', 'phone' => '030 2']); + + $service = app(CompanyMergeService::class); + $merge = $service->merge(planFor('info@muster.de'), 'b'); + + expect($merge->moves()->where('field', 'boilerplate')->value('merged_value'))->toBe('Über Muster'); + + // Der Kunde beansprucht den Newsroom und schreibt den Text neu. + $survivor->refresh()->update(['boilerplate' => 'Unser eigener Text']); + + $service->revert($merge); + $survivor->refresh(); + + expect($survivor->boilerplate)->toBe('Unser eigener Text') + ->and($survivor->website)->toBeNull() + ->and($survivor->phone)->toBeNull(); +}); + +test('unmerge of an older merge without stored fill value compares against the merged company', function () { + /** @var TestCase $this */ + mergeGroup('info@muster.de'); + $survivor = mergeMember('info@muster.de', Portal::Presseecho, 3, ['website' => null, 'boilerplate' => null]); + mergeMember('info@muster.de', Portal::Businessportal24, 0, ['website' => 'https://muster.de', 'boilerplate' => 'Über Muster']); + + $service = app(CompanyMergeService::class); + $merge = $service->merge(planFor('info@muster.de'), 'b'); + // Stand vor der Migration: kein Füllwert in der Move-Zeile. + $merge->moves()->update(['merged_value' => null]); + + $survivor->refresh()->update(['boilerplate' => 'Unser eigener Text']); + $service->revert($merge); + $survivor->refresh(); + + expect($survivor->boilerplate)->toBe('Unser eigener Text') + ->and($survivor->website)->toBeNull(); +}); + +test('merge and unmerge re-evaluate the canonical assignment of the moved releases', function () { + /** @var TestCase $this */ + config()->set('canonical.enabled', true); + config()->set('canonical.portal_by_category', ['it' => 'businessportal24']); + + $category = Category::factory()->create(['portal' => Portal::Both]); + $category->translations()->create(['locale' => 'de', 'name' => 'IT', 'slug' => 'it']); + + mergeGroup('info@muster.de'); + $survivor = mergeMember('info@muster.de', Portal::Businessportal24, 2); + $loser = mergeMember('info@muster.de', Portal::Presseecho); + + $source = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->for($category) + ->create(['company_id' => $survivor->id, 'title' => 'Neuer Standort', 'text' => '

Gleicher Text

']); + $copy = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category) + ->create(['company_id' => $loser->id, 'title' => 'Neuer Standort', 'text' => '

Gleicher Text

']); + + // Verschiedene Firmen: kein Paar. + expect($copy->fresh()->canonical_press_release_id)->toBeNull(); + + $service = app(CompanyMergeService::class); + $merge = $service->merge(planFor('info@muster.de'), 'b'); + + expect($copy->fresh()->canonical_press_release_id)->toBe($source->id) + ->and($source->fresh()->canonical_press_release_id)->toBeNull(); + + $service->revert($merge); + + // Wieder verschiedene Firmen: der Verweis auf die fremde Meldung fällt. + expect($copy->fresh()->canonical_press_release_id)->toBeNull() + ->and($source->fresh()->canonical_press_release_id)->toBeNull(); +}); + +test('an old newsroom slug follows a chain of merges to the current survivor', function () { + /** @var TestCase $this */ + mergeGroup('info@muster.de'); + $middle = mergeMember('info@muster.de', Portal::Presseecho, 2, ['slug' => 'muster-mitte']); + mergeMember('info@muster.de', Portal::Presseecho, 1, ['slug' => 'muster-alt']); + + $service = app(CompanyMergeService::class); + $service->merge(planFor('info@muster.de'), 'b1'); + + // Später: Die Mitte geht ihrerseits in einer weiteren Firma auf. + $final = mergeMember('info@muster.de', Portal::Presseecho, 5, ['slug' => 'muster-neu']); + PressRelease::withoutGlobalScopes()->where('company_id', $final->id)->update(['published_at' => now()->subDay()]); + $service->merge(planFor('info@muster.de'), 'b2'); + + expect(Company::withTrashed()->find($middle->id)->trashed())->toBeTrue() + ->and(app(MergedNewsroomLookup::class)->survivorFor(['presseecho', 'both'], 'muster-alt')?->id)->toBe($final->id); + + $this->get('https://presseecho.test/de/newsroom/muster-alt') + ->assertRedirect('https://presseecho.test/de/newsroom/muster-neu') + ->assertStatus(301); +}); diff --git a/tests/Feature/ConversionFunnelTest.php b/tests/Feature/ConversionFunnelTest.php index 76f74dd3..4cd91d6f 100644 --- a/tests/Feature/ConversionFunnelTest.php +++ b/tests/Feature/ConversionFunnelTest.php @@ -267,3 +267,33 @@ test('the ladder is complete and ordered', function () { expect($steps)->toBe([1, 2, 3, 4, 5]) ->and(ConversionEventType::NewsroomEdited->diagnosis())->toBe('Kein Anreiz, das Profil zu pflegen'); }); + +test('mail link clicks are throttled per address and release and ignore link scanners', function () { + /** @var TestCase $this */ + $company = Company::factory()->create(); + $release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24) + ->create(['company_id' => $company->id, 'published_at' => now()->subDay()]); + $url = 'https://businessportal24.test/de/press-release/'.$release->slug.'?src='.ConversionSource::MailPublish->value.'&pr='.$release->id; + + // Umsetzungsaudit 29.09.2026, R-08: Link-Scanner der Mailserver holen + // Links vorab — per HEAD oder mit eigener Kennung. + $this->call('HEAD', $url)->assertOk(); + $this->withHeaders(['User-Agent' => 'Mozilla/5.0 (compatible; Barracuda Sentinel)'])->get($url)->assertOk(); + $this->withHeaders(['User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) BingPreview/1.0b'])->get($url)->assertOk(); + expect(ConversionEvent::query()->count())->toBe(0); + + // Derselbe Klick mehrfach (Doppelklick, erneutes Öffnen): einmal gezählt. + $browser = ['User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 Safari/605.1.15']; + $this->withHeaders($browser)->get($url)->assertOk(); + $this->withHeaders($browser)->get($url)->assertOk(); + $this->withHeaders($browser)->get($url)->assertOk(); + + expect(ConversionEvent::query()->where('type', ConversionEventType::MailLinkClicked->value)->count())->toBe(1); + + // Eine andere Meldung aus derselben Mail zählt eigenständig. + $other = PressRelease::factory()->published()->forPortal(Portal::Businessportal24) + ->create(['company_id' => $company->id, 'published_at' => now()->subDay()]); + $this->withHeaders($browser)->get('https://businessportal24.test/de/press-release/'.$other->slug.'?src='.ConversionSource::MailPublish->value.'&pr='.$other->id)->assertOk(); + + expect(ConversionEvent::query()->count())->toBe(2); +}); diff --git a/tests/Feature/EmailSuppressionTest.php b/tests/Feature/EmailSuppressionTest.php index 24b58952..38478dee 100644 --- a/tests/Feature/EmailSuppressionTest.php +++ b/tests/Feature/EmailSuppressionTest.php @@ -16,6 +16,7 @@ use App\Models\EmailSuppression; use App\Models\PressRelease; use App\Models\PressReleaseStatusLog; use App\Models\User; +use App\Services\Mail\BounceReference; use App\Services\Mail\EmailSuppressionService; use App\Services\Mail\MailCircuitBreaker; use App\Services\Mail\SmtpFailureClassifier; @@ -130,7 +131,22 @@ test('the gate keeps the other recipients and sets the return path', function () expect($sent)->toHaveCount(1) ->and(array_map(fn ($a) => $a->getAddress(), $email->getTo()))->toBe(['lebt@example.test']) ->and($email->getReturnPath()?->getAddress())->toBe('bounce@pressekonto.test') - ->and($email->getHeaders()->get('X-Pk-Ref')?->getBodyAsString())->toBe('user:'.$user->id); + ->and($email->getHeaders()->get('X-Pk-Ref')?->getBodyAsString())->toStartWith('user:'.$user->id.'; s='); + + // Signiert je verbliebenem Empfänger (Umsetzungsaudit 29.09.2026, R-04). + $reference = $email->getHeaders()->get('X-Pk-Ref')?->getBodyAsString(); + expect(app(BounceReference::class)->verify($reference, 'lebt@example.test'))->toBeTrue() + ->and(app(BounceReference::class)->verify($reference, 'tot@example.test'))->toBeFalse(); +}); + +test('the gate signs a reference on mails without their own', function () { + /** @var TestCase $this */ + Mail::raw('Hallo', fn ($message) => $message->to('kunde@example.test')->subject('Test')); + + $reference = app('mailer')->getSymfonyTransport()->messages()[0]->getOriginalMessage()->getHeaders()->get('X-Pk-Ref')?->getBodyAsString(); + + expect($reference)->toStartWith('mail; s=') + ->and(app(BounceReference::class)->verify($reference, 'kunde@example.test'))->toBeTrue(); }); // ------------------------------------------------- Pfad 2: SMTP-Ablehnung diff --git a/tests/Feature/IndexStatusCheckTest.php b/tests/Feature/IndexStatusCheckTest.php index 64e1c893..7924ae17 100644 --- a/tests/Feature/IndexStatusCheckTest.php +++ b/tests/Feature/IndexStatusCheckTest.php @@ -3,6 +3,7 @@ use App\Console\Commands\CheckIndexStatus; use App\Enums\IndexState; use App\Enums\Portal; +use App\Models\Category; use App\Models\Company; use App\Models\PressRelease; use App\Models\PressReleaseIndexCheck; @@ -230,3 +231,21 @@ test('the distribution counts states over the same population as the total, one ->and($bucket['unchecked'])->toBe(0) ->and($states + $bucket['unchecked'])->toBe($bucket['total']); }); + +test('a release on both portals counts as copy on the portal that does not lead, like the sitemap', function () { + /** @var TestCase $this */ + config()->set('canonical.enabled', true); + config()->set('canonical.portal_by_category', ['handel' => 'businessportal24']); + + $category = Category::factory()->create(['portal' => Portal::Both]); + $category->translations()->create(['locale' => 'de', 'name' => 'Handel', 'slug' => 'handel']); + publishedOn(Portal::Both, 5, ['category_id' => $category->id]); + + $distribution = app(IndexStatusReport::class)->distribution(); + + // Umsetzungsaudit 29.09.2026, N18-Rest: die Sitemap von presseecho führt sie nicht. + expect($distribution['businessportal24']['direct']['source']['total'])->toBe(1) + ->and($distribution['businessportal24']['direct']['copy']['total'])->toBe(0) + ->and($distribution['presseecho']['direct']['source']['total'])->toBe(0) + ->and($distribution['presseecho']['direct']['copy']['total'])->toBe(1); +}); diff --git a/tests/Feature/PurgePressReleaseEventsTest.php b/tests/Feature/PurgePressReleaseEventsTest.php index 6cbcf953..525d17ea 100644 --- a/tests/Feature/PurgePressReleaseEventsTest.php +++ b/tests/Feature/PurgePressReleaseEventsTest.php @@ -8,6 +8,7 @@ use App\Models\PressRelease; use App\Models\PressReleaseCrawlerHit; use App\Models\PressReleaseEvent; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; use Tests\TestCase; /** @@ -125,3 +126,25 @@ test('the purge reads the watermark from the database, not from a stale cache (A expect(PressReleaseEvent::query()->find($old->id))->toBeNull() ->and(Cache::get('app-setting.'.AggregatePressReleaseViews::WatermarkKey))->toBe(0); }); + +test('the purge deletes by id range below a boundary instead of scanning created_at', function () { + /** @var TestCase $this */ + $release = PressRelease::factory()->create(); + + $old = collect(range(1, 3))->map(fn () => purgeTestEvent($release, now()->subDays(400)->toDateTimeString())); + $recent = purgeTestEvent($release, now()->subDays(10)->toDateTimeString()); + AppSetting::setValue(AggregatePressReleaseViews::WatermarkKey, $recent->id); + + DB::enableQueryLog(); + $this->artisan('press-releases:purge-events', ['--chunk' => 100])->assertSuccessful(); + + // Umsetzungsaudit 29.09.2026, OPT-2: press_release_events hat keinen + // Index auf created_at — gelöscht wird nach Primärschlüssel. + $deletes = collect(DB::getQueryLog())->pluck('query') + ->filter(fn (string $sql): bool => str_starts_with(strtolower($sql), 'delete') && str_contains($sql, 'press_release_events')); + + expect($deletes)->not->toBeEmpty() + ->and($deletes->every(fn (string $sql): bool => ! str_contains($sql, 'created_at')))->toBeTrue() + ->and(PressReleaseEvent::query()->whereIn('id', $old->pluck('id'))->count())->toBe(0) + ->and(PressReleaseEvent::query()->find($recent->id))->not->toBeNull(); +}); diff --git a/tests/Feature/VisibilityStatsTest.php b/tests/Feature/VisibilityStatsTest.php index eef94922..7e5c5044 100644 --- a/tests/Feature/VisibilityStatsTest.php +++ b/tests/Feature/VisibilityStatsTest.php @@ -234,3 +234,26 @@ test('the release summary reports the sitemap role: the source is listed, a copy config()->set('canonical.enabled', false); expect($stats->releaseSummary($copy)['sitemap_role'])->toBe('source'); }); + +test('the listing date is not claimed when the release has a counterpart on the other portal', function () { + /** @var TestCase $this */ + [$customer, $company] = claimant(); + config()->set('canonical.enabled', true); + + // Fassung auf dem anderen Portal, inzwischen depubliziert: Die Kopie ist + // erst seitdem Erstquelle — wann genau, ist nicht gespeichert (N17-Rest). + $former = PressRelease::factory()->forPortal(Portal::Presseecho)->create(['company_id' => $company->id, 'title' => 'Gleich', 'text' => 'Gleicher Text', 'status' => 'archived', 'published_at' => now()->subDays(20)]); + $release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'user_id' => $customer->id, 'title' => 'Gleich', 'text' => 'Gleicher Text', 'published_at' => now()->subDays(10)]); + expect($former->content_hash)->toBe($release->content_hash); + + $summary = app(VisibilityStats::class)->releaseSummary($release); + + expect($summary['sitemap_role'])->toBe('source') + ->and($summary['in_sitemap_since'])->toBeNull() + ->and($summary['sitemap_portals'])->toBe([Portal::Businessportal24->label()]); + + LivewireVolt::test('customer.press-releases.show', ['id' => $release->id]) + ->assertSeeText('In der Sitemap gelistet (Businessportal24)') + ->assertDontSeeText('Gelistet seit') + ->assertDontSeeText('Derzeit nicht gelistet'); +}); diff --git a/tests/Feature/Web/ContentChangedAtTest.php b/tests/Feature/Web/ContentChangedAtTest.php new file mode 100644 index 00000000..57f179ab --- /dev/null +++ b/tests/Feature/Web/ContentChangedAtTest.php @@ -0,0 +1,112 @@ +create(['portal' => Portal::Presseecho, 'is_active' => true]); + $company = Company::factory()->presseecho()->create(['is_active' => true]); + + return PressRelease::factory() + ->published() + ->forPortal(Portal::Presseecho) + ->for($category) + ->for($company) + ->create(array_merge(['slug' => 'lastmod-pm', 'published_at' => Carbon::parse('2026-01-10 10:00:00')], $attributes)); +} + +test('view aggregation leaves updated_at untouched', function () { + /** @var TestCase $this */ + $release = lastmodRelease(['hits' => 0]); + $before = PressRelease::withoutGlobalScopes()->find($release->id)->updated_at; + + PressReleaseEvent::factory()->count(3)->create([ + 'press_release_id' => $release->id, + 'type' => PressReleaseEventType::View, + ]); + + $this->travel(2)->days(); + $this->artisan('press-releases:aggregate-views')->assertSuccessful(); + + $fresh = PressRelease::withoutGlobalScopes()->find($release->id); + expect($fresh->hits)->toBe(3) + ->and($fresh->updated_at->equalTo($before))->toBeTrue(); +}); + +test('a text change sets content_changed_at, other saves do not', function () { + $release = lastmodRelease(); + expect($release->fresh()->content_changed_at)->toBeNull(); + + $this->travel(1)->days(); + $release->update(['keywords' => 'nur, schlagworte']); + expect($release->fresh()->content_changed_at)->toBeNull(); + + $this->travel(1)->days(); + $release->update(['text' => '

Neuer Text

']); + expect($release->fresh()->content_changed_at?->toDateTimeString())->toBe(now()->toDateTimeString()); +}); + +test('sitemap lastmod follows content_changed_at and ignores updated_at', function () { + /** @var TestCase $this */ + $release = lastmodRelease(); + + // Aufrufaggregation / Merge alter Art: updated_at springt, Inhalt nicht. + PressRelease::withoutGlobalScopes()->whereKey($release->id)->toBase()->update(['updated_at' => '2026-09-20 12:00:00']); + + Cache::flush(); + $xml = $this->get('https://presseecho.test/sitemap.xml')->assertSuccessful()->getContent(); + expect($xml)->toContain(''.Carbon::parse('2026-01-10 10:00:00')->toAtomString().'') + ->not->toContain('2026-09-20'); + + PressRelease::withoutGlobalScopes()->whereKey($release->id)->toBase()->update(['content_changed_at' => '2026-03-05 08:00:00']); + + Cache::flush(); + $xml = $this->get('https://presseecho.test/sitemap.xml')->assertSuccessful()->getContent(); + expect($xml)->toContain(''.Carbon::parse('2026-03-05 08:00:00')->toAtomString().''); +}); + +test('the detail page reports content_changed_at as modification date', function () { + /** @var TestCase $this */ + $release = lastmodRelease(); + PressRelease::withoutGlobalScopes()->whereKey($release->id)->toBase()->update(['updated_at' => '2026-09-20 12:00:00']); + + $html = $this->get('https://presseecho.test/de/press-release/lastmod-pm')->assertSuccessful()->getContent(); + expect($html)->not->toContain('article:modified_time') + ->not->toContain('2026-09-20T12:00:00'); + + PressRelease::withoutGlobalScopes()->whereKey($release->id)->toBase()->update(['content_changed_at' => '2026-03-05 08:00:00']); + Cache::flush(); + + $html = $this->get('https://presseecho.test/de/press-release/lastmod-pm')->assertSuccessful()->getContent(); + $modified = Carbon::parse('2026-03-05 08:00:00')->toIso8601String(); + expect($html)->toContain('') + ->toContain('"dateModified":"'.str_replace('/', '\/', $modified).'"'); +}); + +test('the sitemap lists english releases under the english edition', function () { + /** @var TestCase $this */ + lastmodRelease(['slug' => 'english-release', 'language' => 'en']); + lastmodRelease(['slug' => 'deutsche-meldung', 'language' => 'de']); + + $xml = $this->get('https://presseecho.test/sitemap.xml')->assertSuccessful()->getContent(); + + expect($xml)->toContain('https://presseecho.test/en/press-release/english-release') + ->toContain('https://presseecho.test/de/press-release/deutsche-meldung') + ->not->toContain('/de/press-release/english-release'); + + $this->get('https://presseecho.test/en/press-release/english-release')->assertSuccessful(); +}); diff --git a/tests/Feature/Web/CrawlerHitLogTest.php b/tests/Feature/Web/CrawlerHitLogTest.php index bb8ce2d4..a9d69b18 100644 --- a/tests/Feature/Web/CrawlerHitLogTest.php +++ b/tests/Feature/Web/CrawlerHitLogTest.php @@ -8,6 +8,7 @@ use App\Models\Category; use App\Models\Company; use App\Models\PressRelease; use App\Models\PressReleaseCrawlerHit; +use App\Models\PressReleaseEvent; use App\Services\PressRelease\CrawlerHitReport; use App\Support\CrawlerIdentifier; use Tests\TestCase; @@ -124,3 +125,22 @@ test('the crawler report aggregates fetches by service, by day and by release', ->and($top[0]['fetches'])->toBe(4) ->and($top[0]['services'])->toBe(2); }); + +test('a fetch by a known crawler is not counted as a view', function () { + /** @var TestCase $this */ + $release = makeCrawlerLogRelease(); + + $this->withHeaders(['User-Agent' => 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)']) + ->get('https://presseecho.test/de/press-release/crawler-protokoll-pm') + ->assertSuccessful(); + + // Umsetzungsaudit 29.09.2026, R-07: Crawler-Abruf ja, Aufruf nein. + expect(PressReleaseCrawlerHit::query()->count())->toBe(1) + ->and(PressReleaseEvent::query()->where('press_release_id', $release->id)->count())->toBe(0); + + $this->withHeaders(['User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/128.0 Safari/537.36']) + ->get('https://presseecho.test/de/press-release/crawler-protokoll-pm') + ->assertSuccessful(); + + expect(PressReleaseEvent::query()->where('press_release_id', $release->id)->count())->toBe(1); +}); diff --git a/tests/Feature/Web/FeedFetchLogTest.php b/tests/Feature/Web/FeedFetchLogTest.php index e799570d..6ac63470 100644 --- a/tests/Feature/Web/FeedFetchLogTest.php +++ b/tests/Feature/Web/FeedFetchLogTest.php @@ -12,6 +12,7 @@ use App\Models\User; use App\Services\PressRelease\FeedFetchReport; use App\Services\PressRelease\VisibilityStats; use App\Support\FeedReaderIdentifier; +use Illuminate\Support\Facades\DB; use Tests\TestCase; /** @@ -162,3 +163,42 @@ test('the reader summary keeps feeds apart by portal and language', function () ->and($feedly['feeds'])->toBe(3) ->and($feedly['subscribers'])->toBe(35); }); + +test('the report groups and limits in sql: one query for top feeds and for the per-release counts', function () { + /** @var TestCase $this */ + FeedFetch::factory()->category('handel')->reader('feedly', 12)->count(3)->create(); + FeedFetch::factory()->category('handel')->reader(null)->create(); + FeedFetch::factory()->newsroom('nordwind')->reader('feedly', 2)->create(); + FeedFetch::factory()->reader('inoreader', 5)->count(2)->create(); + FeedFetch::factory()->reader('inoreader', 5)->create(['language' => 'en']); + FeedFetch::factory()->category('handel')->create(['created_at' => now()->subDays(40)]); + + $report = app(FeedFetchReport::class); + + DB::enableQueryLog(); + $top = $report->topFeeds(30, limit: 2); + $feedQueries = collect(DB::getQueryLog())->pluck('query')->filter(fn (string $sql): bool => str_contains($sql, 'feed_fetches')); + + // Umsetzungsaudit 29.09.2026, OPT-1. + expect($feedQueries)->toHaveCount(1) + ->and(strtolower((string) $feedQueries->first()))->toContain('limit') + ->and(collect($top)->pluck('fetches')->all())->toBe([4, 2]) + ->and($top[0]['readers'])->toBe(2) + ->and($top[0]['subscribers'])->toBe(12); + + DB::flushQueryLog(); + $counts = $report->fetchesFor([ + ['feed' => FeedFetch::FEED_CATEGORY, 'feed_key' => 'handel', 'portal' => 'presseecho', 'language' => 'de'], + ['feed' => FeedFetch::FEED_NEWSROOM, 'feed_key' => 'nordwind', 'portal' => 'presseecho', 'language' => 'de'], + ['feed' => FeedFetch::FEED_PORTAL, 'feed_key' => null, 'portal' => 'presseecho', 'language' => 'de'], + ['feed' => FeedFetch::FEED_PORTAL, 'feed_key' => null, 'portal' => 'businessportal24', 'language' => 'de'], + ]); + + expect(DB::getQueryLog())->toHaveCount(1) + ->and($counts)->toBe([ + 'category:handel:presseecho:de' => 4, + 'newsroom:nordwind:presseecho:de' => 1, + 'portal::presseecho:de' => 2, + 'portal::businessportal24:de' => 0, + ]); +}); diff --git a/tests/Feature/Web/FeedTest.php b/tests/Feature/Web/FeedTest.php index d6a5ca55..43213428 100644 --- a/tests/Feature/Web/FeedTest.php +++ b/tests/Feature/Web/FeedTest.php @@ -225,3 +225,15 @@ test('the feed is cached with the page data and cleared with the public pages', expect(Cache::has(WebPageCache::key('feed', ['presseecho', 'de', 'category', 'handel'])))->toBeFalse(); }); + +test('the feed teaser decodes stored entities before escaping them once', function () { + /** @var TestCase $this */ + $category = feedCategory(); + feedRelease($category, 'entities', ['text' => '

Müller & Söhne "neu" – jetzt

']); + + $xml = (string) $this->get('https://presseecho.test/de/category/handel/feed')->assertSuccessful()->getContent(); + + expect($xml)->toContain('Müller & Söhne "neu" – jetzt') + ->not->toContain('&amp;') + ->not->toContain('&quot;'); +}); diff --git a/tests/Feature/Web/LlmsTxtTest.php b/tests/Feature/Web/LlmsTxtTest.php index 03404153..cc044f75 100644 --- a/tests/Feature/Web/LlmsTxtTest.php +++ b/tests/Feature/Web/LlmsTxtTest.php @@ -2,6 +2,8 @@ use App\Enums\Portal; use App\Models\Category; +use App\Support\WebCaches; +use Illuminate\Support\Facades\Cache; use Tests\TestCase; /** @@ -43,3 +45,14 @@ test('the hub serves llms.txt that points to both portals', function () { $this->assertStringNotContainsString('press-release/{slug}', $text); }); + +test('llms.txt is part of the public page caches that a content change clears', function () { + /** @var TestCase $this */ + $this->get('https://presseecho.test/llms.txt')->assertSuccessful(); + expect(Cache::has('web.llms.presseecho.test'))->toBeTrue(); + + WebCaches::forgetPublicPages(); + + // Umsetzungsaudit 29.09.2026, R-11: sonst bis zu 1 h alte Kategorienliste. + expect(Cache::has('web.llms.presseecho.test'))->toBeFalse(); +}); diff --git a/tests/Feature/Web/NewsroomClaimTest.php b/tests/Feature/Web/NewsroomClaimTest.php index 98195e76..f8b6ecd3 100644 --- a/tests/Feature/Web/NewsroomClaimTest.php +++ b/tests/Feature/Web/NewsroomClaimTest.php @@ -230,3 +230,25 @@ test('a claim from the publish mail lands on that release with its views, a fore $this->post(claimUrl(), ['pr' => $foreign->id])->assertSessionHas('newsroom-claim-status'); expect(MagicLink::query()->where('user_id', $user->id)->latest('id')->firstOrFail()->payload)->toBe(['company_id' => $company->id]); }); + +test('attempts without a sent link do not use up the daily links of the newsroom', function () { + /** @var TestCase $this */ + $company = claimCompany(['email' => null]); + RateLimiter::clear('newsroom-claim|company|'.$company->id); + config()->set('newsroom.claim.links_per_day', 2); + + // Umsetzungsaudit 29.09.2026, R-09: Ohne Versand zählt der Firmenzähler + // nicht — Dritte können den Inhaber so nicht aussperren. + foreach (range(1, 3) as $i) { + $this->post(claimUrl())->assertSessionHas('newsroom-claim-none')->assertSessionHasNoErrors(); + } + + $company->update(['email' => 'info@muster.de']); + + $this->post(claimUrl())->assertSessionHas('newsroom-claim-status')->assertSessionHasNoErrors(); + Mail::assertQueuedCount(1); +}); + +test('the default daily link budget per newsroom is moderate', function () { + expect((int) config('newsroom.claim.links_per_day'))->toBe(5); +});