- /{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>
224 lines
9.1 KiB
PHP
224 lines
9.1 KiB
PHP
<?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();
|
||
}
|
||
}
|