Legacy-API-Paritaet S1+S2: connektar-Flow verhaltensgleich + KI-Schalter

Eigene Compat-Controller (Api/Legacy) fuer die 5 real genutzten
/service-Endpoints (Prod-Log-Beleg): Company-/Kontakt-Create (liefen
410!), PM-Create, Bild-Upload, published-only-GET — verschachtelte
Alt-Params, 201+Location+{}, Alt-Fehlerformat, Hit-Zaehlung,
Legacy-Land-Mapping.

- Anlegen = Einreichen (Alt-Verhalten); Sofort-Publish via neuer
  Permission api:immediate-publication (WS-7: connektar zuweisen)
- Admin-Schalter 'KI-Pruefung Legacy-API' (app_settings-Tabelle,
  Toggle auf /admin/press-releases): an = review + KI-Routing
- Admin-PM-Liste: KI-Spalte (Ampel-Badge) + manueller Pruef-Button
  ohne Routing fuer ungepruefte PMs
- Mails bei API-Einlieferern an Pressekontakt, sonst Firmen-Mail,
  Fallback Einlieferer (User-Entscheidung 10.07.)
- Tests: LegacyServiceParityTest (connektar-Flow end-to-end);
  Suite 1182 gruen

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-10 14:09:40 +00:00
parent 5a352425d7
commit 6a45ebf607
15 changed files with 906 additions and 24 deletions

View file

@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers\Api\Legacy;
use App\Enums\CompanyType;
use App\Http\Controllers\Controller;
use App\Models\Company;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
/**
* Alt-Vertrag POST /service/users/me/companies (meistgenutzter Legacy-Call,
* 5.935x in den Prod-Logs): Felder verschachtelt unter company[...],
* Response 201 + Location + {}. Paritäts-Doku §1/§4 S1.
*/
class LegacyCompanyController extends Controller
{
use RespondsLikeLegacyApi;
public function store(Request $request): JsonResponse
{
try {
$validated = $request->validate([
'company' => ['required', 'array'],
'company.name' => ['required', 'string', 'max:255'],
'company.address' => ['nullable', 'string', 'max:1000'],
'company.country_id' => ['nullable', 'integer'],
'company.phone' => ['nullable', 'string', 'max:100'],
'company.fax' => ['nullable', 'string', 'max:100'],
'company.email' => ['nullable', 'email', 'max:255'],
'company.website' => ['nullable', 'string', 'max:255'],
])['company'];
} catch (ValidationException $exception) {
return $this->legacyValidationError($exception);
}
$user = $request->user();
$company = new Company([
'portal' => $user->portal?->value,
'type' => CompanyType::Company->value,
'name' => $validated['name'],
'address' => $validated['address'] ?? null,
'country_code' => $this->legacyCountryCode($validated['country_id'] ?? null),
'phone' => $validated['phone'] ?? null,
'fax' => $validated['fax'] ?? null,
'email' => $validated['email'] ?? null,
'website' => $validated['website'] ?? null,
// Alt-Verhalten: per API angelegte Firmen waren sofort nutzbar.
'is_active' => true,
]);
$company->owner_user_id = $user->id;
$company->slug = $company->generateUniqueSlug($company->name);
$company->save();
$company->users()->attach($user->id, ['role' => 'owner']);
return $this->legacyCreated(url("/service/users/me/companies/{$company->id}"));
}
}

View file

@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers\Api\Legacy;
use App\Http\Controllers\Controller;
use App\Models\Company;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
/**
* Alt-Vertrag POST /service/users/me/companies/{id}/contacts:
* Felder unter contact[...], salutation_id 1/2 → mr/mrs (wie ContactImporter),
* nur eigene Firmen. Paritäts-Doku §1/§4 S1.
*/
class LegacyContactController extends Controller
{
use RespondsLikeLegacyApi;
public function store(Request $request, int $company): JsonResponse
{
$company = Company::withoutGlobalScopes()
->whereKey($company)
->accessibleBy($request->user())
->first();
if (! $company) {
return $this->legacyError(403, ['You may only access companies which are related to your user-account']);
}
try {
$validated = $request->validate([
'contact' => ['required', 'array'],
'contact.salutation_id' => ['nullable', 'integer'],
'contact.title' => ['nullable', 'string', 'max:100'],
'contact.first_name' => ['required', 'string', 'max:255'],
'contact.last_name' => ['required', 'string', 'max:255'],
'contact.responsibility' => ['nullable', 'string', 'max:255'],
'contact.phone' => ['nullable', 'string', 'max:100'],
'contact.fax' => ['nullable', 'string', 'max:100'],
'contact.email' => ['nullable', 'email', 'max:255'],
])['contact'];
} catch (ValidationException $exception) {
return $this->legacyValidationError($exception);
}
$contact = $company->contacts()->create([
'portal' => $company->portal?->value,
'salutation_key' => match ((int) ($validated['salutation_id'] ?? 0)) {
1 => 'mr',
2 => 'mrs',
default => null,
},
'title' => $validated['title'] ?? null,
'first_name' => $validated['first_name'],
'last_name' => $validated['last_name'],
'responsibility' => $validated['responsibility'] ?? null,
'phone' => $validated['phone'] ?? null,
'fax' => $validated['fax'] ?? null,
'email' => $validated['email'] ?? null,
]);
return $this->legacyCreated(url("/service/users/me/companies/{$company->id}/contacts/{$contact->id}"));
}
}

View file

@ -0,0 +1,187 @@
<?php
namespace App\Http\Controllers\Api\Legacy;
use App\Enums\AiTextDeclaration;
use App\Enums\PressReleaseEventType;
use App\Enums\PressReleaseStatus;
use App\Http\Controllers\Controller;
use App\Models\AppSetting;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\BookingRequiredException;
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\ValidationException;
/**
* 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 RespondsLikeLegacyApi;
public const SETTING_AI_REVIEW = 'legacy_api_ai_review';
public function store(Request $request, PressReleaseService $service): JsonResponse
{
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.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::withoutGlobalScopes()
->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::withoutGlobalScopes()->where('legacy_id', (int) $validated['category_id'])->first()
?? Category::withoutGlobalScopes()->find((int) $validated['category_id']);
if (! $category) {
return $this->legacyError(422, ['Unknown category.']);
}
$language = strtolower(substr((string) ($validated['language'] ?? 'de'), 0, 2)) ?: 'de';
$pressRelease = new PressRelease([
'company_id' => $company->id,
'category_id' => $category->id,
'language' => $language,
'title' => $validated['title'],
'text' => $validated['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;
$pressRelease->portal = $company->portal?->value;
$pressRelease->ai_declaration_confirmed_at = now();
$pressRelease->slug = $pressRelease->generateUniqueSlug($pressRelease->title, [
'portal' => $pressRelease->portal,
'language' => $language,
]);
$pressRelease->save();
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);
} 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::withoutGlobalScopes()
->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).
PressRelease::withoutGlobalScopes()
->whereKey($pressRelease->getKey())
->update(['hits' => ($pressRelease->hits ?? 0) + 1]);
$pressRelease->events()->create(['type' => PressReleaseEventType::View]);
$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')), '/');
}
}

View file

@ -0,0 +1,75 @@
<?php
namespace App\Http\Controllers\Api\Legacy;
use App\Enums\AiImageDeclaration;
use App\Http\Controllers\Controller;
use App\Models\PressRelease;
use App\Services\Image\ImageService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use RuntimeException;
/**
* Alt-Vertrag POST /service/pressreleases/{id}/images: Multipart mit Feldern
* unter press_release_image[...], nur Eigentümer (403-Wortlaut wie alt),
* Response 201 + Location + {}. KI-Kennzeichnung defaulted auf Originalfoto.
*/
class LegacyPressReleaseImageController extends Controller
{
public function __construct(private readonly ImageService $imageService) {}
use RespondsLikeLegacyApi;
public function store(Request $request, int $pressRelease): JsonResponse
{
$pressRelease = PressRelease::withoutGlobalScopes()->find($pressRelease);
if (! $pressRelease) {
return $this->legacyError(404, [sprintf('Object press_release does not exist (%s).', $request->route('pressRelease'))]);
}
if ($pressRelease->user_id !== $request->user()->id) {
return $this->legacyError(403, ['You are not the creator of this press release']);
}
try {
$validated = $request->validate([
'press_release_image' => ['required', 'array'],
'press_release_image.image' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:8192'],
'press_release_image.title' => ['nullable', 'string', 'max:120'],
'press_release_image.description' => ['nullable', 'string', 'max:500'],
'press_release_image.copyright' => ['nullable', 'string', 'max:255'],
])['press_release_image'];
} catch (ValidationException $exception) {
return $this->legacyValidationError($exception);
}
try {
$stored = $this->imageService->storePressReleaseImage(
$request->file('press_release_image.image'),
$pressRelease->id,
);
} catch (RuntimeException $exception) {
return $this->legacyError(422, [$exception->getMessage()]);
}
$image = $pressRelease->images()->create([
'disk' => 'public',
'path' => $stored['path'],
'variants' => $stored['variants'],
'ai_declaration' => AiImageDeclaration::Original->value,
'title' => $validated['title'] ?? null,
'description' => $validated['description'] ?? null,
'copyright' => $validated['copyright'] ?? null,
'is_preview' => false,
'sort_order' => ((int) $pressRelease->images()->max('sort_order')) + 1,
'width' => $stored['width'],
'height' => $stored['height'],
'mime' => $stored['mime'],
]);
return $this->legacyCreated(url("/service/pressreleases/{$pressRelease->id}/images/{$image->id}"));
}
}

View file

@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Api\Legacy;
use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException;
/**
* Response-Konventionen der Alt-API (Symfony, tnHttpError-Templates):
* Erfolg beim Anlegen = 201 + Location-Header + Body {};
* Fehler = {"global_errors":[…]} bzw. 422 zusätzlich mit "field_errors".
* Vertrag dokumentiert in dev/release/api-legacy-paritaet.md §1.
*/
trait RespondsLikeLegacyApi
{
protected function legacyCreated(string $locationUrl): JsonResponse
{
return response()->json((object) [], 201)->header('Location', $locationUrl);
}
protected function legacyError(int $status, array $globalErrors): JsonResponse
{
return response()->json(['global_errors' => $globalErrors], $status);
}
protected function legacyValidationError(ValidationException $exception): JsonResponse
{
return response()->json([
'global_errors' => [],
'field_errors' => $exception->errors(),
], 422);
}
/**
* Legacy-Land (country.id der Altportale) → ISO-Code des neuen Systems.
* Häufigste IDs aus dem Prod-Snapshot; unbekannte → null (Feld leer).
*/
protected function legacyCountryCode(mixed $legacyCountryId): ?string
{
return match ((int) $legacyCountryId) {
177 => 'DE',
80 => 'AT',
196 => 'CH',
229 => 'US',
30 => 'GB',
165 => 'FR',
78 => 'IT',
2 => 'NL',
47 => 'ES',
108 => 'CY',
115 => 'LI',
117 => 'LU',
15 => 'AU',
89 => 'CA',
default => null,
};
}
}