web/seo: Änderungsdatum unabhängig von Aufrufen, EN-URLs in Sitemap und Legacy-API, signierte Bounce-Referenz, Merge/Canonical konsistent (Umsetzungsaudit 29.09.: R-02 bis R-11, N17, N18, OPT-1, OPT-2; 02.10.: O01)

- 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 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-10-02 13:09:00 +00:00
parent 1ccb4a8479
commit d6e49c11c3
48 changed files with 1676 additions and 134 deletions

View file

@ -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++;

View file

@ -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.');
}

View file

@ -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;
}
}

View file

@ -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<covariant Model> $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;
}
}

View file

@ -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();

View file

@ -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) {

View file

@ -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;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();

View file

@ -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',

View file

@ -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'));
}

View file

@ -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(),
];
}

View file

@ -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;
}

View file

@ -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<string>

View file

@ -22,6 +22,7 @@ class CompanyMergeMove extends Model
'from_company_id',
'field',
'previous_value',
'merged_value',
'dropped',
];

View file

@ -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.

View file

@ -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<string, mixed> $extra
*/

View file

@ -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<int> $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;
}
}

View file

@ -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:
*

View file

@ -0,0 +1,124 @@
<?php
namespace App\Services\Mail;
/**
* Signierte Bounce-Referenz (Umsetzungsaudit 29.09.2026, R-04).
*
* Jede ausgehende Mail trägt `X-Pk-Ref: <referenz>; s=<sig>[,<sig>…]` — 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<string> $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<string>
*/
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'));
}
}

View file

@ -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<int, string|null> Kategorie-ID → DE-Slug der Hauptkategorie */
private array $categorySlugs = [];
/** @var array<int, array{root: int|null, slug: string|null}> 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<int>
*/
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<string> $hashes
* @param list<int> $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,
];
}
}

View file

@ -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;

View file

@ -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

View file

@ -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<string> */
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<TModel> $query
* @return Builder<TModel>
*/
/**
* 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')

View file

@ -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',

View file

@ -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);
}
}
}