presseportale/app/Http/Controllers/Web/FeedController.php
Kevin Adametz ff75159b8f web: RSS-Feed je Newsroom, RSS-Übersichtsseite, „RSS" in der Topleiste (Roadmap 3.1, Nachtrag)
- /{edition}/newsroom/{slug}/feed: Sichtbarkeit wie die Newsroom-Seite
  (aktiv, nicht entfernt, mindestens eine Meldung), Prüfung vor dem Cache,
  301 bei zusammengeführter Firma; Autodiscovery und Link „RSS-Feed" auf
  der Newsroom-Seite; Hub leitet auf das Portal der Firma.
- /{edition}/rss (RssPageController, static.rss DE/EN): Was RSS ist, wie
  ein Reader funktioniert, Hauptfeed, alle Branchen-Feeds mit Adresse,
  Muster des Newsroom-Feeds, Hinweise. Portalseite mit Portal-Kontext,
  Hub → presseecho, in Sitemap und llms.txt.
- Topleiste, Burger-Menü und Footer verlinken RSS neben FAQ/Hilfe/API.
- FeedController in portal/category/newsroom aufgeteilt; Panel
  „Auslieferung" nennt zusätzlich den Newsroom-Feed.
- Tests: FeedTest (+3), RssPageTest (4), StaticPagesTest, VisibilityStatsTest.
- dev/2026-09-01: Nachtrag in Protokoll Teil 11 und Roadmap 3.1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 08:27:14 +00:00

224 lines
9.1 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\Company\MergedNewsroomLookup;
use App\Support\StructuredData;
use App\Support\WebPageCache;
use App\Support\WebPortalContext;
use Closure;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
/**
* RSS-Feeds (Roadmap 3.1): je Portal (`/{edition}/feed`), je Branche
* (`/{edition}/category/{slug}/feed`, inklusive Unterkategorien wie die
* Branchenseite) und je Newsroom (`/{edition}/newsroom/{slug}/feed`). Immer
* die jüngsten Meldungen der Ausgabe (Portal × Sprache). Bei
* Doppelveröffentlichung erscheint je Portal die eigene Fassung; Leser und
* Feeds sehen beide (Anhang 2), nur die Suchmaschine bekommt die Erstquelle.
*
* Gecacht wie die Seitendaten (WebPageCache: 5 min frisch, danach stale und
* Neuaufbau nach der Antwort); Entfernungen leeren ihn mit den übrigen
* Seiten-Caches. Der Hub hat keine Meldungen — die Routen leiten dort auf
* das Portal (config/canonical.php, portal_only_content_routes).
*/
class FeedController extends Controller
{
public const MAX_ITEMS = 50;
public const TEASER_LENGTH = 300;
public function __construct(private readonly MergedNewsroomLookup $mergedNewsrooms) {}
public function portal(Request $request, string $edition): Response
{
$context = WebPortalContext::fromRequest();
$base = rtrim($request->getSchemeAndHttpHost(), '/');
return $this->respond($context, ['all'], function () use ($context, $base): string {
$releases = $this->releases($context)->get($this->columns());
$brand = (array) ($context->domainConfig['brand'] ?? []);
return $this->render($context, $base, $releases, [
'title' => __('web.rss.portal_title', ['brand' => $this->publisher($base)]),
'link' => $base.'/'.$context->language,
'self' => $base.'/'.$context->language.'/feed',
'description' => (string) ($brand['tagline_long'] ?? __('web.rss.default_description', ['brand' => $this->publisher($base)])),
]);
});
}
public function category(Request $request, string $edition, string $slug): Response
{
$context = WebPortalContext::fromRequest();
$base = rtrim($request->getSchemeAndHttpHost(), '/');
$language = $context->language;
return $this->respond($context, ['category', $slug], function () use ($context, $base, $slug, $language): string {
$category = Category::query()
->whereIn('portal', $context->portalValues)
->where('is_active', true)
->whereHas('translations', fn (Builder $query) => $query->where('locale', $language)->where('slug', $slug))
->with(['translations' => fn ($query) => $query->where('locale', $language)])
->first();
if (! $category) {
abort(404);
}
$categoryIds = Category::query()
->where('parent_id', $category->id)
->where('is_active', true)
->pluck('id')
->prepend($category->id)
->unique()
->values()
->all();
$releases = $this->releases($context)->whereIn('category_id', $categoryIds)->get($this->columns());
$translation = $category->translations->first();
$brand = (array) ($context->domainConfig['brand'] ?? []);
return $this->render($context, $base, $releases, [
'title' => __('web.rss.category_title', ['category' => (string) $translation?->name, 'brand' => $this->publisher($base)]),
'link' => $base.'/'.$language.'/category/'.$slug,
'self' => $base.'/'.$language.'/category/'.$slug.'/feed',
'description' => filled($translation?->description)
? (string) $translation->description
: (string) ($brand['tagline_long'] ?? __('web.rss.default_description', ['brand' => $this->publisher($base)])),
]);
});
}
/**
* Feed eines Newsrooms: dieselbe Sichtbarkeitsregel wie die Newsroom-Seite
* (aktive Firma, nicht entfernt, mindestens eine veröffentlichte Meldung).
* Die Prüfung läuft bewusst vor dem Cache, damit eine Entfernung sofort
* greift; eine in einer Zusammenführung aufgegangene Firma führt per 301
* zum bleibenden Newsroom (Roadmap 1a.3).
*/
public function newsroom(Request $request, string $edition, string $slug): Response|RedirectResponse
{
$context = WebPortalContext::fromRequest();
$base = rtrim($request->getSchemeAndHttpHost(), '/');
$language = $context->language;
$company = Company::query()
->withoutGlobalScope(PortalScope::class)
->whereIn('portal', $context->portalValues)
->where('slug', $slug)
->where('is_active', true)
->whereNull('gone_at')
->first(['id', 'name', 'slug', 'boilerplate']);
if (! $company) {
$survivor = $this->mergedNewsrooms->survivorFor($context->portalValues, $slug);
if ($survivor !== null && $survivor->is_active && $survivor->slug !== $slug) {
return redirect($base.'/'.$language.'/newsroom/'.$survivor->slug.'/feed', 301);
}
abort(404);
}
return $this->respond($context, ['newsroom', $slug], function () use ($context, $base, $company, $language): string {
$releases = $this->releases($context)->where('company_id', $company->id)->get($this->columns());
if ($releases->isEmpty()) {
abort(404);
}
$boilerplate = trim((string) Str::of(strip_tags((string) $company->boilerplate))->squish());
return $this->render($context, $base, $releases, [
'title' => __('web.rss.newsroom_title', ['company' => (string) $company->name, 'brand' => $this->publisher($base)]),
'link' => $base.'/'.$language.'/newsroom/'.$company->slug,
'self' => $base.'/'.$language.'/newsroom/'.$company->slug.'/feed',
'description' => $boilerplate !== ''
? Str::limit($boilerplate, 300)
: __('web.rss.newsroom_description', ['company' => (string) $company->name, 'brand' => $this->publisher($base)]),
]);
});
}
/**
* @param list<string> $keySegments
* @param Closure(): string $build
*/
private function respond(WebPortalContext $context, array $keySegments, Closure $build): Response
{
$xml = WebPageCache::remember(
WebPageCache::key('feed', [$context->portal->value, $context->language, ...$keySegments]),
$build,
);
return response($xml, 200, ['Content-Type' => 'application/rss+xml; charset=UTF-8']);
}
/**
* @return Builder<PressRelease>
*/
private function releases(WebPortalContext $context): Builder
{
return PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->forPortalValues($context->portalValues)
->forLanguage($context->language)
->visibleInArchive()
->with([
'company:id,name',
'category.translations' => fn ($query) => $query->where('locale', $context->language),
])
->orderByDesc('published_at')
->limit(self::MAX_ITEMS);
}
/**
* @return list<string>
*/
private function columns(): array
{
return ['id', 'slug', 'title', 'text', 'portal', 'language', 'category_id', 'company_id', 'published_at'];
}
private function publisher(string $base): string
{
return (string) StructuredData::publisher($base)['name'];
}
/**
* @param Collection<int, PressRelease> $releases
* @param array{title: string, link: string, self: string, description: string} $channel
*/
private function render(WebPortalContext $context, string $base, Collection $releases, array $channel): string
{
$language = $context->language;
$items = $releases->map(fn (PressRelease $release): array => [
'title' => (string) $release->title,
'link' => $base.'/'.$language.'/press-release/'.$release->slug,
'published_at' => $release->published_at?->toRfc2822String() ?? '',
'description' => $release->teaserText(self::TEASER_LENGTH),
'category' => $release->localizedCategoryName($language),
'creator' => $release->company?->name,
])->all();
$channel += [
'language' => $language,
'last_build' => ($releases->first()->published_at ?? Carbon::now())->toRfc2822String(),
];
return view('web.feed', ['channel' => $channel, 'items' => $items])->render();
}
}