Backend-WIP: Kontakt-Queue, Page-Cache, Validierungs-Feedback, i18n

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>
This commit is contained in:
Kevin Adametz 2026-07-09 15:38:11 +00:00
parent ef5de54300
commit 72e6a3c210
60 changed files with 3963 additions and 327 deletions

View file

@ -9,18 +9,39 @@ 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];
@ -29,6 +50,10 @@ class HomePageData
$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)
@ -52,12 +77,14 @@ class HomePageData
->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();
->get(['id', 'portal', 'language', 'slug', 'title', 'category_id', 'company_id', 'published_at', 'content_score']);
$mostReadReleases = $publishedQuery()
->orderByDesc('hits')
@ -83,12 +110,12 @@ class HomePageData
->published()
->where('published_at', '>=', $recentSince);
},
'pressReleases as today_releases_count' => function (Builder $query) use ($portalValues, $referenceNow, $language): void {
'pressReleases as today_releases_count' => function (Builder $query) use ($portalValues, $todayRange, $language): void {
$query
->forPortalValues($portalValues)
->forLanguage($language)
->published()
->whereDate('published_at', $referenceNow->toDateString());
->whereBetween('published_at', $todayRange);
},
])
->orderByDesc('today_releases_count')
@ -224,7 +251,7 @@ class HomePageData
'spotlight' => $spotlight,
'homeStats' => [
'publishedCount' => $publishedQuery()->count(),
'publishedToday' => $publishedQuery()->whereDate('published_at', $referenceNow->toDateString())->count(),
'publishedToday' => $publishedQuery()->whereBetween('published_at', $todayRange)->count(),
'archiveSince' => $oldestPublishedAt ? (int) Carbon::parse($oldestPublishedAt)->format('Y') : null,
],
];