- legal_document_acceptances (insert-only), LegalAcceptanceGate, Pflicht-Schalter im Einreichungs-Modal über den gemeinsamen Trait; API: GET/POST /api/v1/legal/acceptances, submit antwortet 428 mit den geforderten Fassungen; LEGAL_ACCEPTANCE_GATE - ImageService: og-Variante (1200×630) mit halbtransparentem Balken und Inter SemiBold, regenerateOgVariant() für Korrekturen und Bestand; Fokuspunkt/Letterbox getestet - Uploader brennt den Nachweis beim Upload ein, ImageCorrectionService baut die OG-Variante nach einer Korrektur neu; Artikelseite nutzt og für og:image - Teaser: Nachweis in alt/title (Feed, Hero, Kategorie, Newsroom), Unterschrift am Hero-Bild; PressRelease::cardImageCredit() Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
353 lines
14 KiB
PHP
353 lines
14 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers\Api\V1;
|
||
|
||
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\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\Legal\LegalAcceptanceGate;
|
||
use App\Services\PressRelease\AiDeclarationRequiredException;
|
||
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\Http\Resources\Json\AnonymousResourceCollection;
|
||
use Illuminate\Http\Response;
|
||
use Illuminate\Support\Str;
|
||
use Illuminate\Validation\Rule;
|
||
use Illuminate\Validation\ValidationException;
|
||
|
||
class PressReleaseController extends Controller
|
||
{
|
||
use ResolvesRequestedPortal;
|
||
|
||
public function index(Request $request): AnonymousResourceCollection
|
||
{
|
||
abort_unless($request->user()->tokenCan('press-releases:read'), 403);
|
||
|
||
// 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($filters['status'] ?? null, fn ($query, string $status) => $query->where('status', $status))
|
||
->latest()
|
||
->paginate((int) ($filters['per_page'] ?? 25));
|
||
|
||
return PressReleaseResource::collection($pressReleases);
|
||
}
|
||
|
||
public function store(StorePressReleaseRequest $request): JsonResponse
|
||
{
|
||
$validated = $this->sanitizeHtmlText($request->validated());
|
||
$company = $this->findOwnedCompany((int) $validated['company_id'], $request);
|
||
abort_unless($company !== null, 403);
|
||
|
||
// 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(),
|
||
'user_id' => $request->user()->id,
|
||
'portal' => $portal,
|
||
// Über die API angelegte PMs sind immer Entwürfe. Der Übergang nach
|
||
// `review` erfolgt ausschließlich über die explizite submit-Route,
|
||
// damit Blacklist-/Quota-/Log-Prüfung garantiert durchlaufen werden.
|
||
'status' => PressReleaseStatus::Draft->value,
|
||
// Legacy-/service-Clients kennen die KI-Kennzeichnung (Art. 50
|
||
// KI-VO) nicht — Entscheidung 10.07.: fehlende Angabe gilt als
|
||
// „ohne generative KI erstellt"; das API-Kunden-Mailing (R6.4)
|
||
// weist darauf hin, dass die weitere Nutzung diese Erklärung
|
||
// darstellt. v1-Clients deklarieren explizit beim Submit.
|
||
...($this->isLegacyServiceRequest($request) ? [
|
||
'ai_text_declaration' => AiTextDeclaration::None->value,
|
||
'ai_declaration_confirmed_at' => now(),
|
||
] : []),
|
||
]);
|
||
$pressRelease->slug = $pressRelease->generateUniqueSlug($validated['title'], [
|
||
'portal' => $portal,
|
||
'language' => $validated['language'],
|
||
]);
|
||
$pressRelease->saveWithUniqueSlugRetry($validated['title'], [
|
||
'portal' => $portal,
|
||
'language' => $validated['language'],
|
||
]);
|
||
|
||
return PressReleaseResource::make(
|
||
$pressRelease->load($this->apiResourceRelations())
|
||
)->response()->setStatusCode(201);
|
||
}
|
||
|
||
public function show(Request $request, int $pressRelease): PressReleaseResource
|
||
{
|
||
abort_unless($request->user()->tokenCan('press-releases:read'), 403);
|
||
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);
|
||
abort_unless($pressRelease !== null, 403);
|
||
|
||
return PressReleaseResource::make(
|
||
$pressRelease->load($this->apiResourceRelations())
|
||
);
|
||
}
|
||
|
||
public function update(UpdatePressReleaseRequest $request, int $pressRelease): PressReleaseResource|JsonResponse
|
||
{
|
||
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);
|
||
abort_unless($pressRelease !== null, 403);
|
||
|
||
if (! in_array($pressRelease->status->value, ['draft', 'rejected'], true)) {
|
||
return response()->json([
|
||
'message' => 'Only draft or rejected press releases may be edited.',
|
||
], 409);
|
||
}
|
||
|
||
$validated = $this->sanitizeHtmlText($request->validated());
|
||
$company = isset($validated['company_id'])
|
||
? $this->findOwnedCompany((int) $validated['company_id'], $request)
|
||
: $this->findOwnedCompany((int) $pressRelease->company_id, $request);
|
||
abort_unless($company !== null, 403);
|
||
|
||
$language = $validated['language'] ?? $pressRelease->language;
|
||
$title = $validated['title'] ?? $pressRelease->title;
|
||
// Beim Update verschiebt nur ein expliziter portal-Parameter die PM —
|
||
// weder Request-Domain noch Firmen-Portal ändern eine bestehende
|
||
// Zuordnung stillschweigend.
|
||
$portal = (Portal::tryFrom((string) ($validated['portal'] ?? ''))
|
||
?? $pressRelease->portal
|
||
?? $company->portal)->value;
|
||
|
||
// Der resultierende Zustand muss passen (Audit U10): eine neue
|
||
// Kategorie gegen das Ziel-Portal, bei reinem Portalwechsel die
|
||
// bestehende Kategorie gegen das neue Portal.
|
||
if (isset($validated['category_id'])) {
|
||
$this->assertCategoryFitsPortal((int) $validated['category_id'], $portal, $request);
|
||
} elseif ($portal !== $pressRelease->portal->value) {
|
||
$this->assertCategoryFitsPortal((int) $pressRelease->category_id, $portal, $request);
|
||
}
|
||
|
||
$pressRelease->fill([
|
||
...$validated,
|
||
'portal' => $portal,
|
||
]);
|
||
|
||
if (
|
||
array_key_exists('title', $validated)
|
||
|| array_key_exists('language', $validated)
|
||
|| array_key_exists('company_id', $validated)
|
||
) {
|
||
// Selbst-Ausschluss übernimmt der Trait automatisch ($self->exists).
|
||
$pressRelease->slug = $pressRelease->generateUniqueSlug($title, [
|
||
'portal' => $portal,
|
||
'language' => $language,
|
||
]);
|
||
}
|
||
|
||
$pressRelease->saveWithUniqueSlugRetry($title, [
|
||
'portal' => $portal,
|
||
'language' => $language,
|
||
]);
|
||
|
||
// Inhaltliche Änderung einer bereits geprüften/bewerteten PM → neu prüfen.
|
||
if ($pressRelease->wasChanged(['title', 'text'])) {
|
||
$service = app(PressReleaseService::class);
|
||
$service->reclassifyIfClassified($pressRelease);
|
||
$service->rescoreIfScored($pressRelease);
|
||
}
|
||
|
||
return PressReleaseResource::make(
|
||
$pressRelease->load($this->apiResourceRelations())
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Reicht eine PM zur Prüfung ein – der einzige API-Weg nach `review`.
|
||
*
|
||
* Kapselt denselben Funnel wie das Web-Formular: Blacklist-Hard-Filter,
|
||
* Statuswechsel, Status-Log und Quota werden über den
|
||
* `PressReleaseService` garantiert durchlaufen. `published` bleibt über die
|
||
* API unerreichbar.
|
||
*/
|
||
public function submit(Request $request, int $pressRelease, PressReleaseService $service): PressReleaseResource|JsonResponse
|
||
{
|
||
abort_unless($request->user()->tokenCan('press-releases:write'), 403);
|
||
|
||
// KI-Kennzeichnungspflicht (Art. 50 KI-VO) auch via API: der
|
||
// Submit-Call trägt den deklarierten KI-Grad des Textes und gilt
|
||
// dokumentiert als verbindliche Bestätigung der Deklaration sowie
|
||
// Kenntnisnahme der dauerhaften Archivierung (AGB § 13) —
|
||
// Entscheidung 10.07.2026.
|
||
$validated = $request->validate([
|
||
'ai_text_declaration' => ['required', Rule::enum(AiTextDeclaration::class)],
|
||
]);
|
||
|
||
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);
|
||
abort_unless($pressRelease !== null, 403);
|
||
|
||
if (! in_array($pressRelease->status->value, ['draft', 'rejected'], true)) {
|
||
return response()->json([
|
||
'message' => 'Only draft or rejected press releases may be submitted for review.',
|
||
], 409);
|
||
}
|
||
|
||
// Zustimmungsgate (Block G, O2): ohne Annahme der aktuellen AGB- und
|
||
// Richtlinien-Fassung 428 mit den geforderten Versionen.
|
||
$gate = app(LegalAcceptanceGate::class);
|
||
$requiredDocuments = $gate->requiredFor($request->user());
|
||
|
||
if ($requiredDocuments->isNotEmpty()) {
|
||
return response()->json([
|
||
'message' => 'The current terms and guidelines must be accepted before submitting (POST /api/v1/legal/acceptances).',
|
||
'required_documents' => $requiredDocuments->map(fn ($document) => $gate->describe($document))->values(),
|
||
], 428);
|
||
}
|
||
|
||
$pressRelease->update([
|
||
'ai_text_declaration' => $validated['ai_text_declaration'],
|
||
'ai_declaration_confirmed_at' => now(),
|
||
]);
|
||
|
||
try {
|
||
$service->submitForReview($pressRelease, 'api');
|
||
} catch (BookingRequiredException $exception) {
|
||
return response()->json([
|
||
'message' => $exception->getMessage(),
|
||
], 402);
|
||
} catch (QuotaExceededException $exception) {
|
||
return response()->json([
|
||
'message' => $exception->getMessage(),
|
||
], 422);
|
||
} catch (AiDeclarationRequiredException $exception) {
|
||
return response()->json([
|
||
'message' => $exception->getMessage(),
|
||
], 422);
|
||
} catch (BlacklistViolationException $exception) {
|
||
return response()->json([
|
||
'message' => $exception->getMessage(),
|
||
], 422);
|
||
}
|
||
|
||
return PressReleaseResource::make(
|
||
$pressRelease->fresh()->load($this->apiResourceRelations())
|
||
);
|
||
}
|
||
|
||
public function destroy(Request $request, int $pressRelease): JsonResponse|Response
|
||
{
|
||
abort_unless($request->user()->tokenCan('press-releases:write'), 403);
|
||
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);
|
||
abort_unless($pressRelease !== null, 403);
|
||
|
||
if ($pressRelease->status->value === 'published') {
|
||
return response()->json([
|
||
'message' => 'Published press releases cannot be deleted via API.',
|
||
], 409);
|
||
}
|
||
|
||
$pressRelease->delete();
|
||
|
||
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::withoutGlobalScope(PortalScope::class)
|
||
->whereKey($companyId)
|
||
->accessibleBy($request->user())
|
||
->first();
|
||
}
|
||
|
||
/**
|
||
* Relationen für die API-Resource-Ausgabe. Zentral, damit Index, Show,
|
||
* Create, Update und Reclassify denselben Satz laden.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
private function apiResourceRelations(): array
|
||
{
|
||
return ['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'];
|
||
}
|
||
|
||
private function findOwnedPressRelease(int $pressReleaseId, Request $request): ?PressRelease
|
||
{
|
||
return PressRelease::withoutGlobalScope(PortalScope::class)
|
||
->whereKey($pressReleaseId)
|
||
->ownedByApiUser($request->user())
|
||
->first();
|
||
}
|
||
|
||
/**
|
||
* Kam der Call über die Legacy-Aliasse (/service/…, routes/legacy-api.php)?
|
||
* Dort gelten Kompatibilitäts-Defaults für Felder, die die Altportale
|
||
* nicht kannten (KI-Kennzeichnung).
|
||
*/
|
||
private function isLegacyServiceRequest(Request $request): bool
|
||
{
|
||
return str_starts_with((string) $request->route()?->getName(), 'legacy-service.');
|
||
}
|
||
|
||
/**
|
||
* Säubert einen per API übergebenen HTML-Text vor der Persistenz — analog
|
||
* zum Web-Formular, das immer über den Sanitizer speichert. Nur HTML wird
|
||
* durch HTMLPurifier gejagt; Legacy-Plaintext bleibt unverändert (wird beim
|
||
* Rendern ohnehin escaped), damit `<`/`&` in Klartext-Einreichungen nicht
|
||
* zerstört werden.
|
||
*
|
||
* @param array<string, mixed> $validated
|
||
* @return array<string, mixed>
|
||
*/
|
||
private function sanitizeHtmlText(array $validated): array
|
||
{
|
||
$sanitizer = app(PressReleaseHtmlSanitizer::class);
|
||
|
||
if (array_key_exists('text', $validated) && $sanitizer->isHtml($validated['text'])) {
|
||
$validated['text'] = $sanitizer->clean($validated['text']);
|
||
}
|
||
|
||
return $validated;
|
||
}
|
||
}
|