presseportale/app/Http/Controllers/Api/Legacy/LegacyPressReleaseController.php
Kevin Adametz c2e0b85242 web/seo: Änderungsdatum unabhängig von Aufrufen, EN-URLs in Sitemap und Legacy-API, signierte Bounce-Referenz, Merge/Canonical konsistent (Umsetzungsaudit 29.09.: R-02 bis R-11, N17, N18, OPT-1, OPT-2; 02.10.: O01)
- R-02/K-03: Aufrufaggregation und Merge ändern updated_at nicht mehr; neue
  Spalte press_releases.content_changed_at (INSTANT), contentModifiedAt()
  für Sitemap-lastmod, dateModified und article:modified_time
- R-03: Sitemap und Legacy-view_url mit /en/ für englische Meldungen
- R-04/O01: X-Pk-Ref je Empfänger per HMAC signiert; auf eine Sperre zählt —
  hart wie weich — nur ein gültig signierter Rückläufer, unsignierte
  standardmäßig nur zur Sichtung (BOUNCE_UNSIGNED_KIND=policy)
- R-05/R-06: Unmerge setzt nur unveränderte Füllwerte zurück
  (company_merge_moves.merged_value), Canonical-Neubewertung nach Merge und
  Unmerge, assignAll setzt veraltete Verweise zurück
- R-07 bis R-11: Crawler zählen nicht als Aufruf, Mail-Klick gedrosselt und
  verzögert geschrieben, Claim-Zähler erst nach Versand, email_bounces nach
  395 Tagen gelöscht, Canonical-Portal über Kategorie-ID, llms-Cache geleert,
  Feed-Teaser ohne Doppel-Escaping, Merge-Ketten aufgelöst
- N17/N18: kein falsches „gelistet seit", Rollen im Indexbericht wie Sitemap
- OPT-1/OPT-2: topFeeds in SQL, purge-events löscht nach Grenz-ID

Enthält aus PressRelease.php auch die Card-Bild-Helfer (B-01) und
wasEverPublished()/neverPublished() (A-05), weil die Datei ungeteilt
committet ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:09:00 +00:00

211 lines
8.9 KiB
PHP

<?php
namespace App\Http\Controllers\Api\Legacy;
use App\Enums\AiTextDeclaration;
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Http\Controllers\Api\Concerns\ResolvesRequestedPortal;
use App\Http\Controllers\Controller;
use App\Models\AppSetting;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\BookingRequiredException;
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use function Illuminate\Support\defer;
/**
* Alt-Vertrag POST/GET /service/pressreleases (Paritäts-Doku §1/§4):
*
* POST: Felder unter press_release[...]; Anlegen IST Einreichen. Kunden mit
* Permission api:immediate-publication (connektar) veröffentlichen sofort —
* außer der Admin-Schalter „KI-Prüfung Legacy-API" ist an, dann Status review
* und die KI routet (Gelb/Grün auto-publish, Rot Ablehnung). KI-Deklaration
* defaulted auf „ohne generative KI" (Entscheidung 10.07.).
*
* GET {id}: nur published (404 sonst — Alt-Clients pollen darüber den
* Veröffentlichungsstatus), zählt Hits, flaches Alt-Objekt.
*/
class LegacyPressReleaseController extends Controller
{
use ResolvesRequestedPortal, RespondsLikeLegacyApi;
public const SETTING_AI_REVIEW = 'legacy_api_ai_review';
public function store(
Request $request,
PressReleaseService $service,
PressReleaseHtmlSanitizer $sanitizer,
): JsonResponse {
abort_unless($request->user()->tokenCan('press-releases:write'), 403);
try {
$validated = $request->validate([
'press_release' => ['required', 'array'],
'press_release.company_id' => ['required', 'integer'],
'press_release.category_id' => ['required', 'integer'],
'press_release.language' => ['nullable', 'string', 'max:5'],
'press_release.portal' => ['nullable', Rule::enum(Portal::class)->only([Portal::Presseecho, Portal::Businessportal24])],
'press_release.title' => ['required', 'string', 'max:255'],
'press_release.text' => ['required', 'string'],
'press_release.backlink_url' => ['nullable', 'url', 'max:255'],
'press_release.keywords' => ['nullable', 'string', 'max:255'],
'press_release.contacts_list' => ['nullable', 'array'],
'press_release.contacts_list.*' => ['integer'],
])['press_release'];
} catch (ValidationException $exception) {
return $this->legacyValidationError($exception);
}
$user = $request->user();
$company = Company::withoutGlobalScope(PortalScope::class)
->whereKey((int) $validated['company_id'])
->accessibleBy($user)
->first();
if (! $company) {
return $this->legacyError(403, ['You may only access companies which are related to your user-account']);
}
// Alt-Clients senden Legacy-Kategorie-IDs; nach der Migration sind
// id und legacy_id identisch — legacy_id zuerst, id als Fallback.
$category = Category::withoutGlobalScope(PortalScope::class)->where('legacy_id', (int) $validated['category_id'])->first()
?? Category::withoutGlobalScope(PortalScope::class)->find((int) $validated['category_id']);
if (! $category) {
return $this->legacyError(422, ['Unknown category.']);
}
$language = strtolower(substr((string) ($validated['language'] ?? 'de'), 0, 2)) ?: 'de';
$text = $sanitizer->isHtml($validated['text'])
? $sanitizer->clean($validated['text'])
: $validated['text'];
$pressRelease = new PressRelease([
'company_id' => $company->id,
'category_id' => $category->id,
'language' => $language,
'title' => $validated['title'],
'text' => $text,
'backlink_url' => $validated['backlink_url'] ?? null,
'keywords' => $validated['keywords'] ?? null,
'status' => PressReleaseStatus::Draft->value,
// Kompatibilitäts-Default (Entscheidung 10.07.): Alt-Clients
// kennen die KI-Kennzeichnung nicht.
'ai_text_declaration' => AiTextDeclaration::None->value,
]);
$pressRelease->uuid = (string) Str::uuid();
$pressRelease->user_id = $user->id;
// Firmen können seit 1a.3 `both` sein — das ist kein Ziel-Portal für
// eine Meldung. Dann entscheidet das Portal des API-Nutzers.
$pressRelease->portal = $this->resolveRequestedPortal(
$validated['portal'] ?? null,
$company->portal === Portal::Both ? $user->portal : $company->portal,
);
$pressRelease->ai_declaration_confirmed_at = now();
$pressRelease->slug = $pressRelease->generateUniqueSlug($pressRelease->title, [
'portal' => $pressRelease->portal,
'language' => $language,
]);
$pressRelease->saveWithUniqueSlugRetry($pressRelease->title, [
'portal' => $pressRelease->portal,
'language' => $language,
]);
if (! empty($validated['contacts_list'])) {
$ownContactIds = $company->contacts()->whereIn('contacts.id', $validated['contacts_list'])->pluck('contacts.id');
$pressRelease->contacts()->sync($ownContactIds);
}
// Anlegen ist Einreichen (Alt-Verhalten): Gates + KI-Jobs laufen
// über denselben Funnel wie Panel/v1.
try {
$service->submitForReview($pressRelease, 'api');
} catch (BookingRequiredException|QuotaExceededException $exception) {
// Gates greifen vor jeder Statusänderung -> Draft rückstandslos
// entfernen (Alt-Verhalten: ungültiger Request legt nichts an).
$pressRelease->delete();
return $this->legacyError(422, [$exception->getMessage()]);
} catch (BlacklistViolationException $exception) {
// PM ist bereits automatisch abgelehnt + protokolliert -- stehen
// lassen (wie v1), dem Client den Alt-Fehler melden.
return $this->legacyError(422, [$exception->getMessage()]);
}
$aiReviewEnabled = AppSetting::getBool(self::SETTING_AI_REVIEW);
if (! $aiReviewEnabled && $user->can('api:immediate-publication')) {
try {
$service->publish($pressRelease, 'api');
} catch (BlacklistViolationException $exception) {
return $this->legacyError(422, [$exception->getMessage()]);
}
}
return $this->legacyCreated(url("/service/pressreleases/{$pressRelease->id}"));
}
public function show(Request $request, int $pressRelease): JsonResponse
{
$pressRelease = PressRelease::withoutGlobalScope(PortalScope::class)
->whereKey($pressRelease)
->with(['company'])
->first();
if (
! $pressRelease
|| $pressRelease->status !== PressReleaseStatus::Published
|| ($pressRelease->company && ! $pressRelease->company->is_active)
) {
return $this->legacyError(404, ['Not found.']);
}
// Hit-Zählung wie im Altsystem (dort auch für API-Abrufe), aber erst
// nach der Antwort und atomar, damit parallele Polls keine Hits verlieren.
$pressReleaseId = $pressRelease->getKey();
defer(static fn () => PressRelease::recordDeferredView($pressReleaseId));
$spokeUrl = $this->spokeBaseUrl($pressRelease->portal?->value);
$payload = [
'id' => $pressRelease->id,
'title' => $pressRelease->title,
'text' => $pressRelease->text,
'teaser_end' => $pressRelease->teaser_end,
'language' => $pressRelease->language,
'category_id' => $pressRelease->category_id,
// Englische Meldungen liegen unter /en/ (die Detailseite filtert
// nach Sprache); /de/ würde für sie 404 liefern.
'view_url' => $spokeUrl.'/'.($pressRelease->language === 'en' ? 'en' : 'de').'/press-release/'.$pressRelease->slug,
];
if ($pressRelease->company) {
$payload['company'] = [
'viewUrl' => "{$spokeUrl}/de/newsroom/{$pressRelease->company->slug}",
'name' => $pressRelease->company->name,
];
}
return response()->json($payload);
}
private function spokeBaseUrl(?string $portal): string
{
$key = in_array($portal, ['presseecho', 'businessportal24'], true) ? $portal : 'businessportal24';
return rtrim((string) config("domains.domains.{$key}.url", config('app.url')), '/');
}
}