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 */ 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 $validated * @return array */ 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; } }