Optimierung 4.2: Published-Release-Query ueber PressRelease-Scopes zentralisiert

Vier komponierbare Scopes auf PressRelease:
- published()         Status freigegeben + published_at gesetzt (ohne Zeitschranke,
                      damit Statistik-Fenster frei einschraenken koennen)
- visibleInArchive()  published() + published_at <= jetzt (oeffentlich sichtbar)
- forPortalValues()   whereIn portal
- forLanguage()       where language

~19 duplizierte Query-Bloecke in routes/web.php (Home, Kategorie, Detail,
Veroeffentlichen, Suche, Newsrooms, Newsroom-Detail, Legacy-Redirect) nutzen
jetzt diese Scopes. Jedes withoutGlobalScope(PortalScope) bleibt an seiner
Stelle erhalten; die Klausel-Reihenfolge ist fuers Ergebnis irrelevant (reine
AND-Verknuepfung), daher verhaltensgleich.

Test: Web/PublishedReleaseScopeTest; die 89 bestehenden Web/-Tests bleiben gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-19 11:35:55 +00:00
parent 6961cac4da
commit f20a95f9a4
4 changed files with 137 additions and 83 deletions

View file

@ -184,6 +184,41 @@ class PressRelease extends Model
return $query->whereHas('boosts', fn (Builder $q) => $q->active());
}
/**
* Eine veröffentlichte PM: freigegebener Status mit gesetztem Datum. Bewusst
* OHNE Zeitschranke, damit Statistik-Fenster (z. B. „letzte 7 Tage") frei auf
* `published_at` einschränken können. Single Source of Truth für die
* öffentlichen Web-Abfragen.
*/
public function scopePublished(Builder $query): Builder
{
return $query->where('status', PressReleaseStatus::Published)
->whereNotNull('published_at');
}
/**
* Öffentlich sichtbare PM im Archiv: veröffentlicht UND der Termin liegt nicht
* in der Zukunft (`published_at <= jetzt`). Basis für alle Listen-/Detail-/
* Lead-Abfragen der Web-Seiten.
*/
public function scopeVisibleInArchive(Builder $query): Builder
{
return $query->published()->where('published_at', '<=', now());
}
/**
* @param list<string> $portalValues
*/
public function scopeForPortalValues(Builder $query, array $portalValues): Builder
{
return $query->whereIn('portal', $portalValues);
}
public function scopeForLanguage(Builder $query, string $language): Builder
{
return $query->where('language', $language);
}
/**
* Beschränkt auf PMs, die dem User gehören (als Autor) oder zu einer ihm
* zugeordneten Firma. Bewusst ohne Portal-GlobalScope: ein Kunde darf

View file

@ -73,7 +73,7 @@ Keine Fachlogik-Änderung. Reduziert erreichbare Demo-Komponenten und Altlasten.
`routes/web.php` > 1100 Zeilen mit kompletter Datenaufbereitung. Schrittweise, gut absicherbar.
- [ ] **4.1 Portal-/Domain-Kontext extrahieren** (baut auf 3.3 `DomainContext` auf).
- [ ] **4.2 Published-Release-Query zentralisieren** — gleiche Bedingung (Portal `both`, `status=published`, Sprache, `published_at <= now()`) mehrfach, teils mit/ohne `withoutGlobalScope(PortalScope)`. → Scopes `scopePublished`, `scopeForPortalValues`, `scopeForLanguage`, `scopeVisibleInArchive`.
- [x] **4.2 Published-Release-Query zentralisieren** — vier komponierbare Scopes auf `PressRelease`: `scopePublished()` (Status + gesetztes Datum, ohne Zeitschranke → für Statistik-Fenster), `scopeVisibleInArchive()` (`published()` + `published_at <= now()`), `scopeForPortalValues(array)`, `scopeForLanguage(string)`. ~19 dupliziierte Query-Blöcke in `routes/web.php` (Home, Kategorie, Detail, Veröffentlichen, Suche, Newsrooms, Newsroom-Detail, Legacy-Redirect) darüber zentralisiert; jedes `withoutGlobalScope(PortalScope)` an seiner Stelle erhalten (Klausel-Reihenfolge ist für das Ergebnis irrelevant, da reine AND-Verknüpfung). Test: `Web/PublishedReleaseScopeTest`; die 89 bestehenden `Web/`-Tests bleiben grün.
- [ ] **4.3 Page-Data in Services** — `HomePageData`/`CategoryPageData`/`NewsroomPageData` für Home/Kategorie/Newsroom/Detail/Suche/Preise.
- [ ] **4.4 Routen auf schlanke (Invokable) Controller umstellen** — schrittweise je Seite.

View file

@ -56,11 +56,9 @@ $webHomeData = static function (Portal $primaryPortal, string $language = 'de'):
$previousSince = $referenceNow->copy()->subDays(14);
$publishedQuery = static fn (): Builder => PressRelease::query()
->whereIn('portal', $portalValues)
->where('status', PressReleaseStatus::Published)
->where('language', $language)
->whereNotNull('published_at')
->where('published_at', '<=', now());
->forPortalValues($portalValues)
->forLanguage($language)
->visibleInArchive();
$with = [
'company',
@ -98,26 +96,24 @@ $webHomeData = static function (Portal $primaryPortal, string $language = 'de'):
->where('is_active', true)
->whereHas('pressReleases', function (Builder $query) use ($portalValues, $recentSince, $language): void {
$query
->whereIn('portal', $portalValues)
->where('status', PressReleaseStatus::Published)
->where('language', $language)
->whereNotNull('published_at')
->forPortalValues($portalValues)
->forLanguage($language)
->published()
->where('published_at', '>=', $recentSince);
})
->withCount([
'pressReleases as recent_releases_count' => function (Builder $query) use ($portalValues, $recentSince, $language): void {
$query
->whereIn('portal', $portalValues)
->where('status', PressReleaseStatus::Published)
->where('language', $language)
->whereNotNull('published_at')
->forPortalValues($portalValues)
->forLanguage($language)
->published()
->where('published_at', '>=', $recentSince);
},
'pressReleases as today_releases_count' => function (Builder $query) use ($portalValues, $referenceNow, $language): void {
$query
->whereIn('portal', $portalValues)
->where('status', PressReleaseStatus::Published)
->where('language', $language)
->forPortalValues($portalValues)
->forLanguage($language)
->published()
->whereDate('published_at', $referenceNow->toDateString());
},
])
@ -138,17 +134,16 @@ $webHomeData = static function (Portal $primaryPortal, string $language = 'de'):
->withCount([
'pressReleases as recent_count' => function (Builder $query) use ($portalValues, $recentSince, $language): void {
$query
->whereIn('portal', $portalValues)
->where('status', PressReleaseStatus::Published)
->where('language', $language)
->where('published_at', '>=', $recentSince)
->whereNotNull('published_at');
->forPortalValues($portalValues)
->forLanguage($language)
->published()
->where('published_at', '>=', $recentSince);
},
'pressReleases as previous_count' => function (Builder $query) use ($portalValues, $recentSince, $previousSince, $language): void {
$query
->whereIn('portal', $portalValues)
->where('status', PressReleaseStatus::Published)
->where('language', $language)
->forPortalValues($portalValues)
->forLanguage($language)
->published()
->where('published_at', '>=', $previousSince)
->where('published_at', '<', $recentSince);
},
@ -182,10 +177,9 @@ $webHomeData = static function (Portal $primaryPortal, string $language = 'de'):
->withCount([
'pressReleases as window_count' => function (Builder $query) use ($portalValues, $recentSince, $referenceNow, $language): void {
$query
->whereIn('portal', $portalValues)
->where('status', PressReleaseStatus::Published)
->where('language', $language)
->whereNotNull('published_at')
->forPortalValues($portalValues)
->forLanguage($language)
->published()
->where('published_at', '>=', $recentSince)
->where('published_at', '<=', $referenceNow);
},
@ -200,11 +194,9 @@ $webHomeData = static function (Portal $primaryPortal, string $language = 'de'):
if ($spotlightCategory && $spotlightTranslation = $spotlightCategory->translations->first()) {
$spotlightBase = static fn (): Builder => PressRelease::query()
->whereIn('portal', $portalValues)
->where('status', PressReleaseStatus::Published)
->where('language', $language)
->whereNotNull('published_at')
->where('published_at', '<=', now())
->forPortalValues($portalValues)
->forLanguage($language)
->visibleInArchive()
->where('category_id', $spotlightCategory->id);
$spotlightReleases = $spotlightBase()
@ -314,11 +306,9 @@ $categoryPage = static function (string $edition, string $slug) use ($applyWebDo
$base = static fn (): Builder => PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->whereIn('portal', $portalValues)
->where('status', PressReleaseStatus::Published)
->where('language', $language)
->whereNotNull('published_at')
->where('published_at', '<=', now())
->forPortalValues($portalValues)
->forLanguage($language)
->visibleInArchive()
->whereIn('category_id', $categoryIds);
// Kategoriespezifischer Stichtag: jüngste Meldung dieser Branche.
@ -373,10 +363,9 @@ $categoryPage = static function (string $edition, string $slug) use ($applyWebDo
$newsroomFilter = static function (Builder $query) use ($portalValues, $categoryIds, $language): void {
$query
->whereIn('portal', $portalValues)
->where('status', PressReleaseStatus::Published)
->where('language', $language)
->whereNotNull('published_at')
->forPortalValues($portalValues)
->forLanguage($language)
->published()
->whereIn('category_id', $categoryIds);
};
@ -410,10 +399,9 @@ $categoryPage = static function (string $edition, string $slug) use ($applyWebDo
]);
$publishedCount = static fn (Builder $query) => $query
->whereIn('portal', $portalValues)
->where('status', PressReleaseStatus::Published)
->where('language', $language)
->whereNotNull('published_at');
->forPortalValues($portalValues)
->forLanguage($language)
->published();
$subCategories = $childCategories
->map(function (Category $child) use ($recentSince, $previousSince, $publishedCount): ?array {
@ -501,11 +489,9 @@ $releaseDetailPage = static function (string $edition, string $slug) use ($apply
$portalValues = [$portal->value, Portal::Both->value];
$publishedScope = static fn (Builder $query): Builder => $query
->whereIn('portal', $portalValues)
->where('status', PressReleaseStatus::Published)
->where('language', $language)
->whereNotNull('published_at')
->where('published_at', '<=', now());
->forPortalValues($portalValues)
->forLanguage($language)
->visibleInArchive();
$release = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
@ -584,11 +570,9 @@ $veroeffentlichenPage = static function (string $edition) use ($applyWebDomainCo
$publishedBase = static fn (): Builder => PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->whereIn('portal', $portalValues)
->where('status', PressReleaseStatus::Published)
->where('language', $language)
->whereNotNull('published_at')
->where('published_at', '<=', now());
->forPortalValues($portalValues)
->forLanguage($language)
->visibleInArchive();
$archiveTotal = $publishedBase()->count();
$publishedToday = $publishedBase()->whereDate('published_at', $referenceNow->toDateString())->count();
@ -612,10 +596,9 @@ $veroeffentlichenPage = static function (string $edition) use ($applyWebDomainCo
$newsroomFilter = static function (Builder $query) use ($portalValues, $language, $recentSince): void {
$query
->whereIn('portal', $portalValues)
->where('status', PressReleaseStatus::Published)
->where('language', $language)
->whereNotNull('published_at')
->forPortalValues($portalValues)
->forLanguage($language)
->published()
->where('published_at', '>=', $recentSince);
};
@ -702,11 +685,9 @@ $suchePage = static function (string $edition) use ($applyWebDomainConfig) {
$mostReadReleases = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->whereIn('portal', $portalValues)
->where('status', PressReleaseStatus::Published)
->where('language', $language)
->whereNotNull('published_at')
->where('published_at', '<=', now())
->forPortalValues($portalValues)
->forLanguage($language)
->visibleInArchive()
->orderByDesc('hits')
->orderByDesc('published_at')
->limit(5)
@ -739,11 +720,9 @@ $newsroomsIndex = static function (string $edition) use ($applyWebDomainConfig)
$published = static function (Builder $query) use ($portalValues, $language): void {
$query
->whereIn('portal', $portalValues)
->where('status', PressReleaseStatus::Published)
->where('language', $language)
->whereNotNull('published_at')
->where('published_at', '<=', now());
->forPortalValues($portalValues)
->forLanguage($language)
->visibleInArchive();
};
$activeCompanies = static fn (): Builder => Company::query()
@ -817,11 +796,9 @@ $newsroomDetail = static function (string $edition, string $slug) use ($applyWeb
$base = static fn (): Builder => PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->where('company_id', $company->id)
->whereIn('portal', $portalValues)
->where('status', PressReleaseStatus::Published)
->where('language', $language)
->whereNotNull('published_at')
->where('published_at', '<=', now());
->forPortalValues($portalValues)
->forLanguage($language)
->visibleInArchive();
// Keine öffentliche Newsroom-Seite ohne veröffentlichte Meldung.
if (! $base()->exists()) {
@ -861,10 +838,9 @@ $newsroomDetail = static function (string $edition, string $slug) use ($applyWeb
$query
->withoutGlobalScope(PortalScope::class)
->where('company_id', $company->id)
->whereIn('portal', $portalValues)
->where('status', PressReleaseStatus::Published)
->where('language', $language)
->whereNotNull('published_at');
->forPortalValues($portalValues)
->forLanguage($language)
->published();
};
$categories = Category::query()
@ -975,9 +951,7 @@ $htmlRedirect = static function (string $edition, string $path) {
&& $company->is_active
&& $company->pressReleases()
->withoutGlobalScope(PortalScope::class)
->where('status', PressReleaseStatus::Published)
->whereNotNull('published_at')
->where('published_at', '<=', now())
->visibleInArchive()
->exists();
return redirect(

View file

@ -0,0 +1,45 @@
<?php
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
test('published includes published releases with a date but excludes drafts, reviews and dateless', function () {
$live = PressRelease::factory()->published()->create(['portal' => Portal::Presseecho->value]);
$future = PressRelease::factory()->published()->create(['portal' => Portal::Presseecho->value, 'published_at' => now()->addDays(3)]);
$draft = PressRelease::factory()->create(['status' => PressReleaseStatus::Draft->value, 'portal' => Portal::Presseecho->value]);
$review = PressRelease::factory()->inReview()->create(['portal' => Portal::Presseecho->value]);
$dateless = PressRelease::factory()->create(['status' => PressReleaseStatus::Published->value, 'published_at' => null, 'portal' => Portal::Presseecho->value]);
$ids = PressRelease::withoutGlobalScopes()->published()->pluck('id');
expect($ids)->toContain($live->id)
->toContain($future->id)
->not->toContain($draft->id)
->not->toContain($review->id)
->not->toContain($dateless->id);
});
test('visibleInArchive excludes future-dated releases', function () {
$live = PressRelease::factory()->published()->create(['portal' => Portal::Presseecho->value]);
$future = PressRelease::factory()->published()->create(['portal' => Portal::Presseecho->value, 'published_at' => now()->addDay()]);
$ids = PressRelease::withoutGlobalScopes()->visibleInArchive()->pluck('id');
expect($ids)->toContain($live->id)->not->toContain($future->id);
});
test('forPortalValues and forLanguage filter as expected', function () {
$match = PressRelease::factory()->published()->create(['portal' => Portal::Presseecho->value, 'language' => 'de']);
$otherPortal = PressRelease::factory()->published()->create(['portal' => Portal::Businessportal24->value, 'language' => 'de']);
$otherLang = PressRelease::factory()->published()->create(['portal' => Portal::Presseecho->value, 'language' => 'en']);
$ids = PressRelease::withoutGlobalScopes()
->forPortalValues([Portal::Presseecho->value, Portal::Both->value])
->forLanguage('de')
->pluck('id');
expect($ids)->toContain($match->id)
->not->toContain($otherPortal->id)
->not->toContain($otherLang->id);
});