Gebuendelt, da ueber gemeinsame lang-/Service-Dateien verschraenkt: - Kontakt-Nachrichten: contact_messages-Tabelle + Model/Enums/Factory, Admin-Queue (livewire/admin/contact-messages), ContactController persistiert das Anliegen zusaetzlich zur Brand-Mail - Page-Data-Cache (Services/Web/*PageData) + web-archive-Indizes auf press_releases - Validierungs-Feedback + Company-Portal-Restriction (Feature-Tests) - i18n-Auslagerung lang/de|en (auth, passwords, validation, static) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
259 lines
11 KiB
PHP
259 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Web;
|
|
|
|
use App\Enums\Portal;
|
|
use App\Models\Category;
|
|
use App\Models\Company;
|
|
use App\Models\PressRelease;
|
|
use App\Support\EditorialClock;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\Cache;
|
|
|
|
/**
|
|
* Datenaufbereitung der öffentlichen Startseite einer Ausgabe: Lead-/Seiten-/
|
|
* meistgelesene Meldungen, aktive Newsrooms, Branchen-Index, „Heute im Fokus"
|
|
* (Spotlight) und Kennzahlen. Der Ad-Hoc-Ticker kommt zentral aus TickerData.
|
|
*
|
|
* Das Ergebnis wird kurz gecacht (analog TickerData): Der Aufbau umfasst
|
|
* ~25 Queries über das gesamte Archiv und die Daten ändern sich höchstens
|
|
* minütlich — neue Meldungen erscheinen spätestens nach Ablauf der TTL.
|
|
*/
|
|
class HomePageData
|
|
{
|
|
private const CACHE_TTL_SECONDS = 300;
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function build(Portal $portal, string $language = 'de'): array
|
|
{
|
|
$cacheKey = sprintf('web.home.%s.%s', $portal->value, $language);
|
|
|
|
return Cache::remember(
|
|
$cacheKey,
|
|
self::CACHE_TTL_SECONDS,
|
|
fn (): array => $this->buildFresh($portal, $language)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function buildFresh(Portal $portal, string $language): array
|
|
{
|
|
$portalValues = [$portal->value, Portal::Both->value];
|
|
|
|
// Aktualitäts-Stichtag an den jüngsten Datensatz koppeln (historisches Archiv).
|
|
$referenceNow = EditorialClock::reference($portalValues, $language);
|
|
$recentSince = $referenceNow->copy()->subDays(7);
|
|
$previousSince = $referenceNow->copy()->subDays(14);
|
|
|
|
// Bereichsvergleich statt whereDate(): DATE(published_at) würde die
|
|
// Index-Nutzung auf published_at verhindern (non-sargable).
|
|
$todayRange = [$referenceNow->copy()->startOfDay(), $referenceNow->copy()->endOfDay()];
|
|
|
|
$publishedQuery = static fn (): Builder => PressRelease::query()
|
|
->forPortalValues($portalValues)
|
|
->forLanguage($language)
|
|
->visibleInArchive();
|
|
|
|
$with = [
|
|
'company',
|
|
'category.translations' => fn ($query) => $query->where('locale', $language),
|
|
'images' => fn ($query) => $query
|
|
->orderByDesc('is_preview')
|
|
->orderBy('sort_order')
|
|
->limit(1),
|
|
];
|
|
|
|
$oldestPublishedAt = $publishedQuery()
|
|
->oldest('published_at')
|
|
->value('published_at');
|
|
|
|
$leadRelease = $publishedQuery()
|
|
->with($with)
|
|
->orderByDesc('published_at')
|
|
->first();
|
|
|
|
// Ohne mediumText-Spalte `text`: die Seitenleisten-Teaser rendern nur
|
|
// Titel/Meta, der Volltext wäre unnötiger I/O.
|
|
$sideReleases = $publishedQuery()
|
|
->with($with)
|
|
->when($leadRelease, fn (Builder $query) => $query->where('id', '!=', $leadRelease->id))
|
|
->orderByDesc('published_at')
|
|
->limit(4)
|
|
->get(['id', 'portal', 'language', 'slug', 'title', 'category_id', 'company_id', 'published_at', 'content_score']);
|
|
|
|
$mostReadReleases = $publishedQuery()
|
|
->orderByDesc('hits')
|
|
->orderByDesc('published_at')
|
|
->limit(4)
|
|
->get(['id', 'slug', 'title', 'hits', 'portal', 'language']);
|
|
|
|
$activeNewsrooms = Company::query()
|
|
->whereIn('portal', $portalValues)
|
|
->where('is_active', true)
|
|
->whereHas('pressReleases', function (Builder $query) use ($portalValues, $recentSince, $language): void {
|
|
$query
|
|
->forPortalValues($portalValues)
|
|
->forLanguage($language)
|
|
->published()
|
|
->where('published_at', '>=', $recentSince);
|
|
})
|
|
->withCount([
|
|
'pressReleases as recent_releases_count' => function (Builder $query) use ($portalValues, $recentSince, $language): void {
|
|
$query
|
|
->forPortalValues($portalValues)
|
|
->forLanguage($language)
|
|
->published()
|
|
->where('published_at', '>=', $recentSince);
|
|
},
|
|
'pressReleases as today_releases_count' => function (Builder $query) use ($portalValues, $todayRange, $language): void {
|
|
$query
|
|
->forPortalValues($portalValues)
|
|
->forLanguage($language)
|
|
->published()
|
|
->whereBetween('published_at', $todayRange);
|
|
},
|
|
])
|
|
->orderByDesc('today_releases_count')
|
|
->orderByDesc('recent_releases_count')
|
|
->limit(6)
|
|
->get()
|
|
->map(fn (Company $company): array => [
|
|
'name' => $company->name,
|
|
'slug' => $company->slug,
|
|
'initial' => mb_strtoupper(mb_substr((string) $company->name, 0, 1)),
|
|
'count' => (int) $company->recent_releases_count,
|
|
'today' => (int) $company->today_releases_count > 0,
|
|
]);
|
|
|
|
$industryIndex = Category::query()
|
|
->with(['translations' => fn ($query) => $query->where('locale', $language)])
|
|
->withCount([
|
|
'pressReleases as recent_count' => function (Builder $query) use ($portalValues, $recentSince, $language): void {
|
|
$query
|
|
->forPortalValues($portalValues)
|
|
->forLanguage($language)
|
|
->published()
|
|
->where('published_at', '>=', $recentSince);
|
|
},
|
|
'pressReleases as previous_count' => function (Builder $query) use ($portalValues, $recentSince, $previousSince, $language): void {
|
|
$query
|
|
->forPortalValues($portalValues)
|
|
->forLanguage($language)
|
|
->published()
|
|
->where('published_at', '>=', $previousSince)
|
|
->where('published_at', '<', $recentSince);
|
|
},
|
|
])
|
|
->whereIn('portal', $portalValues)
|
|
->where('is_active', true)
|
|
->whereNull('parent_id')
|
|
->orderByDesc('recent_count')
|
|
->limit(7)
|
|
->get()
|
|
->map(function (Category $category): ?array {
|
|
$translation = $category->translations->first();
|
|
|
|
if (! $translation) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'name' => $translation->name,
|
|
'href' => $translation->slug ? route('kategorie', ['slug' => $translation->slug]) : route('web.home'),
|
|
'count' => (int) ($category->recent_count ?? 0),
|
|
'delta' => (int) ($category->recent_count ?? 0) - (int) ($category->previous_count ?? 0),
|
|
];
|
|
})
|
|
->filter()
|
|
->values();
|
|
|
|
// "Heute im Fokus · Branche": aktivste Top-Rubrik im Stichtag-Fenster mit echten Meldungen.
|
|
$spotlightCategory = Category::query()
|
|
->with(['translations' => fn ($query) => $query->where('locale', $language)])
|
|
->withCount([
|
|
'pressReleases as window_count' => function (Builder $query) use ($portalValues, $recentSince, $referenceNow, $language): void {
|
|
$query
|
|
->forPortalValues($portalValues)
|
|
->forLanguage($language)
|
|
->published()
|
|
->where('published_at', '>=', $recentSince)
|
|
->where('published_at', '<=', $referenceNow);
|
|
},
|
|
])
|
|
->whereIn('portal', $portalValues)
|
|
->where('is_active', true)
|
|
->whereNull('parent_id')
|
|
->orderByDesc('window_count')
|
|
->first();
|
|
|
|
$spotlight = null;
|
|
|
|
if ($spotlightCategory && $spotlightTranslation = $spotlightCategory->translations->first()) {
|
|
$spotlightBase = static fn (): Builder => PressRelease::query()
|
|
->forPortalValues($portalValues)
|
|
->forLanguage($language)
|
|
->visibleInArchive()
|
|
->where('category_id', $spotlightCategory->id);
|
|
|
|
$spotlightReleases = $spotlightBase()
|
|
->with(['company'])
|
|
->orderByDesc('published_at')
|
|
->limit(3)
|
|
->get()
|
|
->map(static function (PressRelease $release) use ($spotlightTranslation): array {
|
|
$words = str_word_count(strip_tags((string) $release->text));
|
|
$publishedAt = $release->published_at;
|
|
|
|
return [
|
|
'time' => $publishedAt?->format('H:i') ?? '',
|
|
'date' => $publishedAt?->translatedFormat('j. M') ?? '',
|
|
'category' => $spotlightTranslation->name,
|
|
'title' => $release->title,
|
|
'company' => $release->company?->name ?? '',
|
|
'city' => null,
|
|
'minutes' => max(1, (int) ceil($words / 200)),
|
|
'href' => $release->slug ? route('release.detail', ['slug' => $release->slug]) : '#',
|
|
];
|
|
});
|
|
|
|
if ($spotlightReleases->isNotEmpty()) {
|
|
$windowCount = (int) ($spotlightCategory->window_count ?? 0);
|
|
$archiveCount = $spotlightBase()->count();
|
|
$activeCompanies = (clone $spotlightBase())
|
|
->where('published_at', '>=', $recentSince)
|
|
->where('published_at', '<=', $referenceNow)
|
|
->distinct()
|
|
->count('company_id');
|
|
|
|
$spotlight = [
|
|
'industry' => $spotlightTranslation->name,
|
|
'stats' => [
|
|
['label' => __('web.spotlight.stat_window'), 'value' => number_format($windowCount, 0, ',', '.'), 'sub' => __('web.spotlight.stat_window_sub', ['date' => $referenceNow->translatedFormat('j. M Y')])],
|
|
['label' => __('web.spotlight.stat_companies'), 'value' => number_format($activeCompanies, 0, ',', '.'), 'sub' => __('web.spotlight.stat_companies_sub')],
|
|
['label' => __('web.spotlight.stat_archive'), 'value' => number_format($archiveCount, 0, ',', '.'), 'sub' => __('web.spotlight.stat_archive_sub')],
|
|
],
|
|
'releases' => $spotlightReleases->all(),
|
|
];
|
|
}
|
|
}
|
|
|
|
return [
|
|
'leadRelease' => $leadRelease,
|
|
'sideReleases' => $sideReleases,
|
|
'mostReadReleases' => $mostReadReleases,
|
|
'activeNewsrooms' => $activeNewsrooms,
|
|
'industryIndex' => $industryIndex,
|
|
'spotlight' => $spotlight,
|
|
'homeStats' => [
|
|
'publishedCount' => $publishedQuery()->count(),
|
|
'publishedToday' => $publishedQuery()->whereBetween('published_at', $todayRange)->count(),
|
|
'archiveSince' => $oldestPublishedAt ? (int) Carbon::parse($oldestPublishedAt)->format('Y') : null,
|
|
],
|
|
];
|
|
}
|
|
}
|