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>
209 lines
8.8 KiB
PHP
209 lines
8.8 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,
|
|
'view_url' => "{$spokeUrl}/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')), '/');
|
|
}
|
|
}
|