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:
parent
1ccb4a8479
commit
d6e49c11c3
48 changed files with 1676 additions and 134 deletions
|
|
@ -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++;
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ class CompanyMergeMove extends Model
|
|||
'from_company_id',
|
||||
'field',
|
||||
'previous_value',
|
||||
'merged_value',
|
||||
'dropped',
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
*
|
||||
|
|
|
|||
124
app/Services/Mail/BounceReference.php
Normal file
124
app/Services/Mail/BounceReference.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Umsetzungsaudit 29.09.2026, R-02/K-03: Zeitpunkt der letzten inhaltlichen
|
||||
* Änderung (Titel oder Text, also neuer `content_hash`). Sitemap-lastmod,
|
||||
* JSON-LD dateModified und article:modified_time lesen diese Spalte statt
|
||||
* `updated_at`, das Aufrufaggregation und Firmen-Merge verfälscht haben.
|
||||
*
|
||||
* Kein Backfill: `updated_at` ist nicht mehr rekonstruierbar; ohne Wert gilt
|
||||
* `published_at` (PressRelease::contentModifiedAt()). Kein Index — die
|
||||
* Sitemap filtert nicht danach, sie liest die Spalte nur mit.
|
||||
*
|
||||
* press_releases trägt keinen FULLTEXT-Index, ADD COLUMN (nullable, ohne
|
||||
* Index) läuft in MySQL 8 als INSTANT.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('press_releases', function (Blueprint $table) {
|
||||
$table->timestamp('content_changed_at')->nullable()->after('content_hash');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('press_releases', function (Blueprint $table) {
|
||||
$table->dropColumn('content_changed_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Umsetzungsaudit 29.09.2026, R-05: Bei Feld-Auffüllungen den eingefüllten
|
||||
* Wert mitschreiben. Die Rückabwicklung setzt ein Feld nur zurück, wenn es
|
||||
* noch diesen Wert trägt — eine spätere Kundenänderung bleibt stehen.
|
||||
* Ältere Zeilen ohne Wert vergleichen gegen die (unveränderte, soft-
|
||||
* gelöschte) Herkunftsfirma. Kleine Protokolltabelle, kein Backfill.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('company_merge_moves', function (Blueprint $table) {
|
||||
$table->text('merged_value')->nullable()->after('previous_value');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('company_merge_moves', function (Blueprint $table) {
|
||||
$table->dropColumn('merged_value');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -340,7 +340,7 @@ new #[Layout('components.layouts.app'), Title('Indexierung und Abrufe')] class e
|
|||
@endif
|
||||
|
||||
<div class="px-5 py-3 border-t border-[color:var(--color-bg-rule)] text-[11.5px] text-[color:var(--color-ink-4)]">
|
||||
{{ __('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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
<meta property="article:published_time" content="{{ $publishedAt->toIso8601String() }}">
|
||||
@endif
|
||||
@if ($release->updated_at?->greaterThan($publishedAt ?? $release->updated_at))
|
||||
<meta property="article:modified_time" content="{{ $release->updated_at->toIso8601String() }}">
|
||||
@if ($publishedAt && $modifiedAt?->greaterThan($publishedAt))
|
||||
<meta property="article:modified_time" content="{{ $modifiedAt->toIso8601String() }}">
|
||||
@endif
|
||||
@if ($categoryName)
|
||||
<meta property="article:section" content="{{ $categoryName }}">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 <pr@example.net>: 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('<delay-1@', '<delay-'.$i.'0@', $raw));
|
||||
}
|
||||
|
||||
expect(EmailBounce::query()->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('<delay-1@', '<delay-'.$i.'0@', signedBounceFixture('delayed-4.4.1', 'voll@example.test')));
|
||||
}
|
||||
|
||||
expect(EmailBounce::query()->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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' => '<p>Gleicher Text</p>']);
|
||||
$copy = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category)
|
||||
->create(['company_id' => $loser->id, 'title' => 'Neuer Standort', 'text' => '<p>Gleicher Text</p>']);
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
112
tests/Feature/Web/ContentChangedAtTest.php
Normal file
112
tests/Feature/Web/ContentChangedAtTest.php
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Enums\PressReleaseEventType;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseEvent;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Umsetzungsaudit 29.09.2026, R-02/K-03 und R-03: `updated_at` taugt nicht
|
||||
* als Änderungsdatum (Aufrufaggregation, Merge). Sitemap-lastmod, JSON-LD
|
||||
* dateModified und article:modified_time folgen `content_changed_at`, die
|
||||
* Sitemap führt englische Meldungen unter /en/.
|
||||
*/
|
||||
function lastmodRelease(array $attributes = []): PressRelease
|
||||
{
|
||||
$category = Category::factory()->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' => '<p>Neuer Text</p>']);
|
||||
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('<lastmod>'.Carbon::parse('2026-01-10 10:00:00')->toAtomString().'</lastmod>')
|
||||
->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('<lastmod>'.Carbon::parse('2026-03-05 08:00:00')->toAtomString().'</lastmod>');
|
||||
});
|
||||
|
||||
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('<meta property="article:modified_time" content="'.$modified.'">')
|
||||
->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('<loc>https://presseecho.test/en/press-release/english-release</loc>')
|
||||
->toContain('<loc>https://presseecho.test/de/press-release/deutsche-meldung</loc>')
|
||||
->not->toContain('/de/press-release/english-release');
|
||||
|
||||
$this->get('https://presseecho.test/en/press-release/english-release')->assertSuccessful();
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' => '<p>Müller & Söhne "neu" – jetzt</p>']);
|
||||
|
||||
$xml = (string) $this->get('https://presseecho.test/de/category/handel/feed')->assertSuccessful()->getContent();
|
||||
|
||||
expect($xml)->toContain('<description>Müller & Söhne "neu" – jetzt</description>')
|
||||
->not->toContain('&amp;')
|
||||
->not->toContain('&quot;');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue