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')), '/'); } }