web/api/ki: Soft Deletes wieder wirksam, Caches bei Firmenwechsel, KI-Ergebnis an Inhalt gebunden (Audit Block 3+4, Deploy D)

Block 3 — Sichtbarkeit, Löschung, Cache, Eingaben

F04: withoutGlobalScopes() ohne Argument entfernte neben dem PortalScope
auch den Soft-Delete-Filter. Öffentliche, Kunden-, API-, Vorschau- und
Medienpfade sowie die PressRelease-Helfer schreiben jetzt
withoutGlobalScope(PortalScope::class). Zwei Stellen, die der Audit nicht
kannte: die öffentliche Meldungsseite zeigte gelöschte Firmenkontakte mit
Name, Mail und Telefon; das Pressefach listete gelöschte Kontakte weiter
und ließ sie bearbeiten. Ein Architekturtest friert die 39 bewusst
verbliebenen Stellen (Admin, Importer, Befehle, Slug-Eindeutigkeit) ein.
Der Vorschau-Test prüfte bisher forceDelete statt delete.

F22: LOWER(email) machte auf companies (110k Zeilen) bei jedem Login einen
Vollscan; die Kollation ist ohnehin case-insensitiv. EmailQuery vergleicht
auf MySQL direkt über den Index, auf SQLite (Tests) weiter per LOWER.
Neuer Index contacts_email_idx.

F13: Firmen-Observer verwirft die öffentlichen Seiten-Caches bei
Löschung, Wiederherstellung und is_active-Wechsel auf jedem Weg; die
Newsroom-Seite prüft Sichtbarkeit vor dem Cache-Treffer; Kontakt-
Änderungen im Pressefach flushen. Der Tombstone-Test wärmte den Cache
vorher nicht und traf den Fehler nie.

F12: Gemeldete URLs werden über Host, Ausgabe und Slug aufgelöst
(ReportedUrlResolver); fremde Hosts zählen nicht, bei Doppelveröffentlichung
gewinnt die Erstquelle.

F11: per_page 1–100, status als Enum, language nur de/en, Kategorie muss
aktiv sein und zum Ziel-Portal passen. Legacy-Aliasse behalten ihren
Vertrag.

F23: Rückgabewert von put() wird geprüft; Logo erst speichern, dann
Datenbank, dann altes Logo löschen. Fehler enden als Feldfehler, nicht
als verlorenes Logo.

Block 4 — KI-Prüfung und Scheduler

F07: Klassifikation und Score tragen die Prüfsumme des Inhalts bei
Einplanung. Weicht sie beim Speichern ab, wird das Ergebnis verworfen
(ki_audits.discarded_at) und ein neuer Lauf eingeplant. Der Scheduler
veröffentlicht keine Meldung, deren jüngste Klassifikation nicht zum
aktuellen Text passt, sondern plant eine Prüfung mit Routing ein.

F18: Kontingent an den Kalendermonat gebunden (users.press_release_quota_period).
Verbrauch aus einem früheren Monat zählt null, auch ohne Reset-Cron; der
Reset ist idempotent und lässt den laufenden Monat in Ruhe.

F20: Der Publikations-Scheduler liefert bei technischen Fehlern einen
Fehler-Exit-Code, damit der Failure-Hook greift.

F17: KI-Jobs mit $timeout = 120, retry_after 150; Runbook ergänzt.

F19: Aggregation der Aufrufe unter einem Cache-Lock für alle Einstiege.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-18 10:40:48 +00:00
parent 62202a4b13
commit f235156de9
73 changed files with 1746 additions and 170 deletions

View file

@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\Legacy;
use App\Http\Controllers\Controller;
use App\Models\Company;
use App\Rules\PlausibleEmailDomain;
use App\Scopes\PortalScope;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
@ -22,7 +23,7 @@ class LegacyContactController extends Controller
{
abort_unless($request->user()->tokenCan('press-releases:write'), 403);
$company = Company::withoutGlobalScopes()
$company = Company::withoutGlobalScope(PortalScope::class)
->whereKey($company)
->accessibleBy($request->user())
->first();

View file

@ -70,7 +70,7 @@ class LegacyPressReleaseController extends Controller
$user = $request->user();
$company = Company::withoutGlobalScopes()
$company = Company::withoutGlobalScope(PortalScope::class)
->whereKey((int) $validated['company_id'])
->accessibleBy($user)
->first();
@ -81,8 +81,8 @@ class LegacyPressReleaseController extends Controller
// Alt-Clients senden Legacy-Kategorie-IDs; nach der Migration sind
// id und legacy_id identisch — legacy_id zuerst, id als Fallback.
$category = Category::withoutGlobalScopes()->where('legacy_id', (int) $validated['category_id'])->first()
?? Category::withoutGlobalScopes()->find((int) $validated['category_id']);
$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.']);

View file

@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\Legacy;
use App\Enums\AiImageDeclaration;
use App\Http\Controllers\Controller;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\Image\ImageService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@ -26,7 +27,7 @@ class LegacyPressReleaseImageController extends Controller
{
abort_unless($request->user()->tokenCan('press-release-images:write'), 403);
$pressRelease = PressRelease::withoutGlobalScopes()->find($pressRelease);
$pressRelease = PressRelease::withoutGlobalScope(PortalScope::class)->find($pressRelease);
if (! $pressRelease) {
return $this->legacyError(404, [sprintf('Object press_release does not exist (%s).', $request->route('pressRelease'))]);

View file

@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\CompanyResource;
use App\Models\Company;
use App\Scopes\PortalScope;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@ -14,10 +15,16 @@ class CompanyController extends Controller
{
abort_unless($request->user()->tokenCan('companies:read'), 403);
$companies = Company::withoutGlobalScopes()
// Untergrenze wie Obergrenze (Audit F11): ein negatives per_page hob
// das SQL-Limit auf.
$filters = $request->validate([
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
]);
$companies = Company::withoutGlobalScope(PortalScope::class)
->accessibleBy($request->user())
->orderBy('name')
->paginate(min((int) $request->query('per_page', 25), 100));
->paginate((int) ($filters['per_page'] ?? 25));
return CompanyResource::collection($companies);
}
@ -31,7 +38,7 @@ class CompanyController extends Controller
{
abort_unless($request->user()->tokenCan('companies:read'), 403);
$company = Company::withoutGlobalScopes()
$company = Company::withoutGlobalScope(PortalScope::class)
->whereKey($company)
->accessibleBy($request->user())
->first();

View file

@ -10,8 +10,10 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\StorePressReleaseRequest;
use App\Http\Requests\Api\V1\UpdatePressReleaseRequest;
use App\Http\Resources\PressReleaseResource;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\PressRelease\AiDeclarationRequiredException;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\BookingRequiredException;
@ -24,6 +26,7 @@ use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
class PressReleaseController extends Controller
{
@ -33,12 +36,20 @@ class PressReleaseController extends Controller
{
abort_unless($request->user()->tokenCan('press-releases:read'), 403);
$pressReleases = PressRelease::withoutGlobalScopes()
// Listenparameter werden validiert statt durchgereicht (Audit F11):
// ein negatives per_page hob das SQL-Limit auf, ein Array als status
// lief in einen TypeError.
$filters = $request->validate([
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
'status' => ['sometimes', 'string', Rule::enum(PressReleaseStatus::class)],
]);
$pressReleases = PressRelease::withoutGlobalScope(PortalScope::class)
->where('user_id', $request->user()->id)
->with($this->apiResourceRelations())
->when($request->query('status'), fn ($query, string $status) => $query->where('status', $status))
->when($filters['status'] ?? null, fn ($query, string $status) => $query->where('status', $status))
->latest()
->paginate(min((int) $request->query('per_page', 25), 100));
->paginate((int) ($filters['per_page'] ?? 25));
return PressReleaseResource::collection($pressReleases);
}
@ -52,6 +63,8 @@ class PressReleaseController extends Controller
// Ziel-Portal: expliziter Parameter > Request-Domain > Firmen-Portal.
$portal = $this->resolveRequestedPortal($validated['portal'] ?? null, $company->portal)->value;
$this->assertCategoryFitsPortal((int) $validated['category_id'], $portal, $request);
$pressRelease = new PressRelease([
...$validated,
'uuid' => (string) Str::uuid(),
@ -122,6 +135,10 @@ class PressReleaseController extends Controller
?? $pressRelease->portal
?? $company->portal)->value;
if (isset($validated['category_id'])) {
$this->assertCategoryFitsPortal((int) $validated['category_id'], $portal, $request);
}
$pressRelease->fill([
...$validated,
'portal' => $portal,
@ -233,9 +250,34 @@ class PressReleaseController extends Controller
return response()->noContent();
}
/**
* Die Kategorie muss aktiv sein und zum Ziel-Portal passen (Audit F11):
* `exists:categories,id` allein ließ inaktive und portalfremde Kategorien
* durch. Legacy-Aliasse (/service/…) lösen ihre Kategorie über legacy_id
* im eigenen Controller auf und bleiben hier außen vor.
*/
private function assertCategoryFitsPortal(int $categoryId, string $portal, Request $request): void
{
if ($this->isLegacyServiceRequest($request)) {
return;
}
$fits = Category::query()
->whereKey($categoryId)
->where('is_active', true)
->whereIn('portal', [$portal, Portal::Both->value])
->exists();
if (! $fits) {
throw ValidationException::withMessages([
'category_id' => 'Die Kategorie ist inaktiv oder gehört nicht zum Ziel-Portal.',
]);
}
}
private function findOwnedCompany(int $companyId, Request $request): ?Company
{
return Company::withoutGlobalScopes()
return Company::withoutGlobalScope(PortalScope::class)
->whereKey($companyId)
->accessibleBy($request->user())
->first();
@ -254,7 +296,7 @@ class PressReleaseController extends Controller
private function findOwnedPressRelease(int $pressReleaseId, Request $request): ?PressRelease
{
return PressRelease::withoutGlobalScopes()
return PressRelease::withoutGlobalScope(PortalScope::class)
->whereKey($pressReleaseId)
->ownedByApiUser($request->user())
->first();

View file

@ -9,6 +9,7 @@ use App\Http\Requests\Api\V1\StorePressReleaseImageRequest;
use App\Http\Resources\PressReleaseImageResource;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Scopes\PortalScope;
use App\Services\Image\ImageService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@ -89,8 +90,8 @@ class PressReleaseImageController extends Controller
$image = PressReleaseImage::query()
->whereKey($pressReleaseImage)
->whereHas('pressRelease', fn ($query) => $query->withoutGlobalScopes()->where('user_id', $request->user()->id))
->with(['pressRelease' => fn ($query) => $query->withoutGlobalScopes()])
->whereHas('pressRelease', fn ($query) => $query->withoutGlobalScope(PortalScope::class)->where('user_id', $request->user()->id))
->with(['pressRelease' => fn ($query) => $query->withoutGlobalScope(PortalScope::class)])
->first();
abort_unless($image !== null, 403);
@ -109,7 +110,7 @@ class PressReleaseImageController extends Controller
private function findOwnedPressRelease(int $pressReleaseId, Request $request): ?PressRelease
{
return PressRelease::withoutGlobalScopes()
return PressRelease::withoutGlobalScope(PortalScope::class)
->whereKey($pressReleaseId)
->ownedByApiUser($request->user())
->first();

View file

@ -7,6 +7,7 @@ use App\Http\Controllers\Controller;
use App\Listeners\RecordAuthActivity;
use App\Models\MagicLink;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\Conversion\ConversionRecorder;
use App\Services\Customer\CustomerCompanyContext;
use Illuminate\Http\RedirectResponse;
@ -113,7 +114,7 @@ class MagicLinkConsumeController extends Controller
$landingPressReleaseId = (int) ($magicLink->payload['press_release_id'] ?? 0);
if ($landingPressReleaseId > 0) {
$pressRelease = PressRelease::query()->withoutGlobalScopes()->whereKey($landingPressReleaseId)->where('company_id', $landingCompanyId)->first();
$pressRelease = PressRelease::query()->withoutGlobalScope(PortalScope::class)->whereKey($landingPressReleaseId)->where('company_id', $landingCompanyId)->first();
if ($pressRelease !== null && $user->can('view', $pressRelease)) {
$home = route('me.press-releases.show', ['id' => $landingPressReleaseId], absolute: false);

View file

@ -6,6 +6,7 @@ use App\Enums\Portal;
use App\Http\Middleware\SetEdition;
use App\Models\MagicLink;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Support\DomainAssetContext;
use Illuminate\Support\Facades\URL;
use Symfony\Component\HttpFoundation\Response;
@ -30,7 +31,7 @@ class PressReleasePreviewController extends Controller
$pressReleaseId = (int) ($magicLink->payload['press_release_id'] ?? 0);
$pressRelease = $pressReleaseId
? PressRelease::withoutGlobalScopes()
? PressRelease::withoutGlobalScope(PortalScope::class)
->with([
'company',
'user:id,agency_name',

View file

@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\Billing\ProofPdfService;
use App\Services\PressRelease\ProofPdfRenderer;
use Illuminate\Support\Facades\Gate;
@ -20,7 +21,7 @@ class ProofPdfController extends Controller
ProofPdfService $service,
ProofPdfRenderer $renderer,
): Response {
$pressRelease = PressRelease::query()->withoutGlobalScopes()->findOrFail((int) $id);
$pressRelease = PressRelease::query()->withoutGlobalScope(PortalScope::class)->findOrFail((int) $id);
Gate::authorize('downloadProof', $pressRelease);

View file

@ -89,7 +89,7 @@ class NewsroomClaimController extends Controller
return null;
}
return PressRelease::query()->withoutGlobalScopes()->whereKey($id)->where('company_id', $company->getKey())->exists() ? $id : null;
return PressRelease::query()->withoutGlobalScope(PortalScope::class)->whereKey($id)->where('company_id', $company->getKey())->exists() ? $id : null;
}
private function resolveMissing(WebPortalContext $context, string $edition, string $slug): Response|RedirectResponse

View file

@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Http\Controllers\Web\Concerns\AuthorizesPressReleaseMediaAccess;
use App\Models\PressRelease;
use App\Models\PressReleaseAttachment;
use App\Scopes\PortalScope;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
@ -18,8 +19,8 @@ class PressReleaseAttachmentMediaController extends Controller
{
$attachment = PressReleaseAttachment::query()
->with(['pressRelease' => fn ($query) => $query
->withoutGlobalScopes()
->with(['company' => fn ($company) => $company->withoutGlobalScopes()])])
->withoutGlobalScope(PortalScope::class)
->with(['company' => fn ($company) => $company->withoutGlobalScope(PortalScope::class)])])
->findOrFail($attachment);
$pressRelease = $attachment->pressRelease;

View file

@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Http\Controllers\Web\Concerns\AuthorizesPressReleaseMediaAccess;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Scopes\PortalScope;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
@ -18,8 +19,8 @@ class PressReleaseImageMediaController extends Controller
{
$image = PressReleaseImage::query()
->with(['pressRelease' => fn ($query) => $query
->withoutGlobalScopes()
->with(['company' => fn ($company) => $company->withoutGlobalScopes()])])
->withoutGlobalScope(PortalScope::class)
->with(['company' => fn ($company) => $company->withoutGlobalScope(PortalScope::class)])])
->findOrFail($image);
$pressRelease = $image->pressRelease;

View file

@ -5,7 +5,6 @@ namespace App\Http\Controllers\Web;
use App\Enums\LegalRequestStatus;
use App\Enums\LegalRequestType;
use App\Enums\LegalRequestVerificationMethod;
use App\Enums\PressReleaseStatus;
use App\Http\Controllers\Controller;
use App\Mail\LegalRequestReceived;
use App\Mail\LegalRequestSubmitted;
@ -16,6 +15,7 @@ use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\Auth\MagicLinkGenerator;
use App\Services\PressRelease\AnonymizationService;
use App\Services\Web\ReportedUrlResolver;
use App\Support\WebPortalContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -36,6 +36,8 @@ class PressReleaseReportController extends Controller
{
private const SESSION_KEY = 'pm_report.verified';
public function __construct(private readonly ReportedUrlResolver $reportedUrls) {}
/** @var array<string, LegalRequestType> */
public const CONCERNS = [
'correction' => LegalRequestType::Correction,
@ -63,7 +65,7 @@ class PressReleaseReportController extends Controller
$this->ensureNotRateLimited($request);
RateLimiter::hit($this->throttleKey($request), 3600);
$pressRelease = $this->matchPublishedRelease($validated['reported_url']);
$pressRelease = $this->reportedUrls->resolve($validated['reported_url']);
if (! $pressRelease) {
throw ValidationException::withMessages([
@ -322,19 +324,6 @@ class PressReleaseReportController extends Controller
];
}
private function matchPublishedRelease(string $url): ?PressRelease
{
if (! preg_match('#/press-release/([^/?\#]+)#', $url, $matches)) {
return null;
}
return PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->where('slug', urldecode($matches[1]))
->where('status', PressReleaseStatus::Published)
->first();
}
/**
* „kontakt@firma.de" → „k***@firma.de" — die Adresse selbst wird nie
* angezeigt und nie aus dem Formular übernommen.

View file

@ -4,13 +4,11 @@ namespace App\Http\Controllers\Web;
use App\Enums\LegalRequestStatus;
use App\Enums\LegalRequestType;
use App\Enums\PressReleaseStatus;
use App\Http\Controllers\Controller;
use App\Mail\LegalRequestReceived;
use App\Mail\LegalRequestSubmitted;
use App\Models\LegalRequest;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\Web\ReportedUrlResolver;
use App\Support\WebPortalContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -31,6 +29,8 @@ class ReportFormController extends Controller
{
public const WAYS = ['rechtswidrig', 'kontaktdaten'];
public function __construct(private readonly ReportedUrlResolver $reportedUrls) {}
public function show(string $edition, string $way): View
{
return view('web.melden-form', ['way' => $way]);
@ -59,7 +59,10 @@ class ReportFormController extends Controller
$this->ensureNotRateLimited($request);
RateLimiter::hit($this->throttleKey($request), 3600);
$release = $this->matchPublishedRelease($validated['reported_url']);
// Zuordnung zu einer PM nur, wenn die URL einer Detail-Adresse eines
// Portals entspricht — sonst bleibt die Meldung ohne PM-Bezug in der
// Queue (URL steht im payload).
$release = $this->reportedUrls->resolve($validated['reported_url']);
$legalRequest = LegalRequest::query()->create([
'press_release_id' => $release?->getKey(),
@ -94,24 +97,6 @@ class ReportFormController extends Controller
]));
}
/**
* Ordnet die gemeldete URL einer veröffentlichten Pressemitteilung zu,
* wenn sie einer Detail-URL entspricht — sonst bleibt die Meldung ohne
* PM-Bezug in der Queue (URL steht im payload).
*/
private function matchPublishedRelease(string $url): ?PressRelease
{
if (! preg_match('#/press-release/([^/?\#]+)#', $url, $matches)) {
return null;
}
return PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->where('slug', urldecode($matches[1]))
->where('status', PressReleaseStatus::Published)
->first();
}
private function portalFromHost(Request $request): ?string
{
$host = $request->getHost();

View file

@ -4,6 +4,7 @@ namespace App\Http\Middleware;
use App\Models\Company;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Support\StaticPageCanonical;
use Closure;
use Illuminate\Http\Request;
@ -71,12 +72,12 @@ class RedirectPortalOnlyPagesFromHub
// dann gewinnt die Erstquelle (ohne `canonical_press_release_id`),
// damit die Weiterleitung nicht auf der Kopie endet.
$portal = $routeName === 'release.detail'
? PressRelease::query()->withoutGlobalScopes()
? PressRelease::query()->withoutGlobalScope(PortalScope::class)
->where('slug', $slug)
->orderByRaw('canonical_press_release_id is null desc')
->orderBy('id')
->value('portal')
: Company::query()->withoutGlobalScopes()->where('slug', $slug)->orderBy('id')->value('portal');
: Company::query()->withoutGlobalScope(PortalScope::class)->where('slug', $slug)->orderBy('id')->value('portal');
return $portal === null ? null : StaticPageCanonical::portalBaseFor((string) ($portal->value ?? $portal));
}

View file

@ -28,7 +28,7 @@ class StorePressReleaseRequest extends FormRequest
'portal' => ['nullable', Rule::enum(Portal::class)->only([Portal::Presseecho, Portal::Businessportal24])],
'company_id' => ['required', 'integer', 'exists:companies,id'],
'category_id' => ['required', 'integer', 'exists:categories,id'],
'language' => ['required', 'string', 'size:2'],
'language' => ['required', 'string', $this->languageRule()],
'title' => ['required', 'string', 'max:255'],
'text' => ['required', 'string'],
'backlink_url' => ['nullable', 'url', 'max:255'],
@ -38,4 +38,16 @@ class StorePressReleaseRequest extends FormRequest
'no_export' => ['nullable', 'boolean'],
];
}
/**
* Nur die ausgelieferten Ausgaben `de` und `en` (Audit F11) – ein
* beliebiger Zweizeichen-Code landete sonst im Unique-Schlüssel der PM.
* Legacy-Aliasse (/service/…) behalten den alten Vertrag.
*/
private function languageRule(): ValidationRule|string
{
return str_starts_with((string) $this->route()?->getName(), 'legacy-service.')
? 'size:2'
: Rule::in(['de', 'en']);
}
}

View file

@ -28,7 +28,7 @@ class UpdatePressReleaseRequest extends FormRequest
'portal' => ['nullable', Rule::enum(Portal::class)->only([Portal::Presseecho, Portal::Businessportal24])],
'company_id' => ['sometimes', 'required', 'integer', 'exists:companies,id'],
'category_id' => ['sometimes', 'required', 'integer', 'exists:categories,id'],
'language' => ['sometimes', 'required', 'string', 'size:2'],
'language' => ['sometimes', 'required', 'string', $this->languageRule()],
'title' => ['sometimes', 'required', 'string', 'max:255'],
'text' => ['sometimes', 'required', 'string'],
'backlink_url' => ['nullable', 'url', 'max:255'],
@ -38,4 +38,16 @@ class UpdatePressReleaseRequest extends FormRequest
'no_export' => ['nullable', 'boolean'],
];
}
/**
* Nur die ausgelieferten Ausgaben `de` und `en` (Audit F11) – ein
* beliebiger Zweizeichen-Code landete sonst im Unique-Schlüssel der PM.
* Legacy-Aliasse (/service/…) behalten den alten Vertrag.
*/
private function languageRule(): ValidationRule|string
{
return str_starts_with((string) $this->route()?->getName(), 'legacy-service.')
? 'size:2'
: Rule::in(['de', 'en']);
}
}