From 6a45ebf607e5ef70af4c650abd223c748c62a273 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Fri, 10 Jul 2026 14:09:40 +0000 Subject: [PATCH] Legacy-API-Paritaet S1+S2: connektar-Flow verhaltensgleich + KI-Schalter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Api/Legacy/LegacyCompanyController.php | 60 +++++ .../Api/Legacy/LegacyContactController.php | 65 +++++ .../Legacy/LegacyPressReleaseController.php | 187 +++++++++++++++ .../LegacyPressReleaseImageController.php | 75 ++++++ .../Api/Legacy/RespondsLikeLegacyApi.php | 58 +++++ app/Models/AppSetting.php | 48 ++++ .../PressRelease/PressReleaseService.php | 39 ++- ...07_10_155535_create_app_settings_table.php | 26 ++ .../seeders/RolesAndPermissionsSeeder.php | 5 + dev/release/api-legacy-paritaet.md | 32 ++- dev/release/entwicklungsplan-release.md | 2 +- .../admin/press-releases/index.blade.php | 67 ++++++ routes/legacy-api.php | 20 +- .../Api/LegacyApiCompatibilityTest.php | 24 +- tests/Feature/Api/LegacyServiceParityTest.php | 222 ++++++++++++++++++ 15 files changed, 906 insertions(+), 24 deletions(-) create mode 100644 app/Http/Controllers/Api/Legacy/LegacyCompanyController.php create mode 100644 app/Http/Controllers/Api/Legacy/LegacyContactController.php create mode 100644 app/Http/Controllers/Api/Legacy/LegacyPressReleaseController.php create mode 100644 app/Http/Controllers/Api/Legacy/LegacyPressReleaseImageController.php create mode 100644 app/Http/Controllers/Api/Legacy/RespondsLikeLegacyApi.php create mode 100644 app/Models/AppSetting.php create mode 100644 database/migrations/2026_07_10_155535_create_app_settings_table.php create mode 100644 tests/Feature/Api/LegacyServiceParityTest.php diff --git a/app/Http/Controllers/Api/Legacy/LegacyCompanyController.php b/app/Http/Controllers/Api/Legacy/LegacyCompanyController.php new file mode 100644 index 00000000..5d471ea5 --- /dev/null +++ b/app/Http/Controllers/Api/Legacy/LegacyCompanyController.php @@ -0,0 +1,60 @@ +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}")); + } +} diff --git a/app/Http/Controllers/Api/Legacy/LegacyContactController.php b/app/Http/Controllers/Api/Legacy/LegacyContactController.php new file mode 100644 index 00000000..067ff9cd --- /dev/null +++ b/app/Http/Controllers/Api/Legacy/LegacyContactController.php @@ -0,0 +1,65 @@ +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}")); + } +} diff --git a/app/Http/Controllers/Api/Legacy/LegacyPressReleaseController.php b/app/Http/Controllers/Api/Legacy/LegacyPressReleaseController.php new file mode 100644 index 00000000..6e078a3f --- /dev/null +++ b/app/Http/Controllers/Api/Legacy/LegacyPressReleaseController.php @@ -0,0 +1,187 @@ +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')), '/'); + } +} diff --git a/app/Http/Controllers/Api/Legacy/LegacyPressReleaseImageController.php b/app/Http/Controllers/Api/Legacy/LegacyPressReleaseImageController.php new file mode 100644 index 00000000..733bdaff --- /dev/null +++ b/app/Http/Controllers/Api/Legacy/LegacyPressReleaseImageController.php @@ -0,0 +1,75 @@ +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}")); + } +} diff --git a/app/Http/Controllers/Api/Legacy/RespondsLikeLegacyApi.php b/app/Http/Controllers/Api/Legacy/RespondsLikeLegacyApi.php new file mode 100644 index 00000000..c477e1a5 --- /dev/null +++ b/app/Http/Controllers/Api/Legacy/RespondsLikeLegacyApi.php @@ -0,0 +1,58 @@ +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, + }; + } +} diff --git a/app/Models/AppSetting.php b/app/Models/AppSetting.php new file mode 100644 index 00000000..af45208f --- /dev/null +++ b/app/Models/AppSetting.php @@ -0,0 +1,48 @@ + + */ + protected function casts(): array + { + return ['value' => 'json']; + } + + public static function getValue(string $key, mixed $default = null): mixed + { + return Cache::rememberForever( + "app-setting.{$key}", + fn (): mixed => static::query()->find($key)?->value, + ) ?? $default; + } + + public static function getBool(string $key, bool $default = false): bool + { + return (bool) static::getValue($key, $default); + } + + public static function setValue(string $key, mixed $value): void + { + static::query()->updateOrCreate(['key' => $key], ['value' => $value]); + Cache::forget("app-setting.{$key}"); + } +} diff --git a/app/Services/PressRelease/PressReleaseService.php b/app/Services/PressRelease/PressReleaseService.php index 5d489e4f..f52d7a53 100644 --- a/app/Services/PressRelease/PressReleaseService.php +++ b/app/Services/PressRelease/PressReleaseService.php @@ -2,18 +2,22 @@ namespace App\Services\PressRelease; +use App\Enums\EmailNotificationType; use App\Enums\PressReleaseClassification; use App\Enums\PressReleaseStatus; +use App\Enums\RegistrationType; use App\Enums\SinglePurchaseStatus; use App\Jobs\ClassifyPressRelease; use App\Jobs\ScorePressRelease; use App\Mail\PressReleasePublished; use App\Mail\PressReleaseRejected; use App\Models\AdminPreset; +use App\Models\Company; use App\Models\PressRelease; use App\Models\PressReleaseAiDeclaration; use App\Models\PressReleaseImage; use App\Models\PressReleaseStatusLog; +use App\Models\User; use App\Services\Admin\AdminPerformanceCache; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Auth; @@ -478,12 +482,45 @@ class PressReleaseService private function notifyAuthor(PressRelease $pressRelease, string $event, ?string $reason = null): void { + /** @var User|null $user */ $user = $pressRelease->user; if (! $user || ! $user->email) { return; } + // API-Einlieferer (z. B. connektar) nicht zuspammen (Entscheidung + // 10.07.2026): Statt-Mail an den Pressekontakt der PM, sonst an die + // Firmen-E-Mail; nur ohne beides an den Einlieferer selbst. + $recipient = $user->email; + $isApiSubmitter = $user->registration_type === RegistrationType::ApiUser; + + if ($isApiSubmitter) { + // Scope-frei auflösen: der Mail-Versand läuft auch aus Jobs/ + // Kontexten ohne passenden Portal-Host (PortalScope). + $recipient = $pressRelease->contacts()->withoutGlobalScopes()->whereNotNull('email')->value('email') + ?? Company::withoutGlobalScopes()->find($pressRelease->company_id)?->email + ?: $user->email; + } + + // Opt-out-Gate: Benachrichtigungs-Mails sind je Typ abbestellbar + // (One-Click-Unsubscribe aus der Mail bzw. Präferenzen im Profil). + // Greift nur, wenn der Einlieferer selbst der Empfänger ist — + // Pressekontakt/Firma haben kein Konto mit Präferenzen. + $notificationType = match ($event) { + 'published' => EmailNotificationType::PressReleasePublished, + 'rejected' => EmailNotificationType::PressReleaseRejected, + default => null, + }; + + if (! $notificationType) { + return; + } + + if ($recipient === $user->email && ! $user->wantsEmailNotification($notificationType)) { + return; + } + $mailable = match ($event) { 'published' => new PressReleasePublished($user, $pressRelease), 'rejected' => new PressReleaseRejected($user, $pressRelease, $reason), @@ -491,7 +528,7 @@ class PressReleaseService }; if ($mailable) { - Mail::to($user->email)->queue($mailable); + Mail::to($recipient)->queue($mailable); } } } diff --git a/database/migrations/2026_07_10_155535_create_app_settings_table.php b/database/migrations/2026_07_10_155535_create_app_settings_table.php new file mode 100644 index 00000000..9dc3e2f6 --- /dev/null +++ b/database/migrations/2026_07_10_155535_create_app_settings_table.php @@ -0,0 +1,26 @@ +string('key', 100)->primary(); + $table->json('value')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('app_settings'); + } +}; diff --git a/database/seeders/RolesAndPermissionsSeeder.php b/database/seeders/RolesAndPermissionsSeeder.php index e4c91d3c..ccdb7c54 100644 --- a/database/seeders/RolesAndPermissionsSeeder.php +++ b/database/seeders/RolesAndPermissionsSeeder.php @@ -33,6 +33,11 @@ class RolesAndPermissionsSeeder extends Seeder 'newsletter:subscribe', 'users:manage', 'roles:manage', + // Legacy-API-Parität (10.07.2026): Bestandskunden mit dem alten + // immediatePublication-Recht (connektar) veröffentlichen per + // /service-POST sofort — sofern der Admin-Schalter „KI-Prüfung + // Legacy-API" aus ist. Zuweisung je User (WS-7-Checkliste). + 'api:immediate-publication', ]; foreach ($permissions as $permission) { diff --git a/dev/release/api-legacy-paritaet.md b/dev/release/api-legacy-paritaet.md index 778d2cfb..537beba3 100644 --- a/dev/release/api-legacy-paritaet.md +++ b/dev/release/api-legacy-paritaet.md @@ -108,16 +108,16 @@ Strukturierte Aktivitäts-Logs beider Portale (Mai–Juli 2026, `b24_activity` + Scope nach Log-Auswertung (§3a): **die 5 connektar-Endpoints**, verhaltensgleich. -- [ ] **S1 — Legacy-Compat-Controller `/service/…`** (ersetzt die direkten v1-Aliasse): - - [ ] `POST /service/users/me/companies` — Felder `company[name|address|country_id|phone|fax|email|website]` (+ `logo`-Datei); legacy `country_id` → neuer `country_code`; Response `201` + `Location` + `{}`; Alt-Fehlerformat. - - [ ] `POST /service/users/me/companies/{id}/contacts` — Felder `contact[salutation_id|title|first_name|last_name|responsibility|phone|fax|email]`; Zugriff nur eigene Firma (403-Wortlaut s. §1); Response wie oben. - - [ ] `POST /service/pressreleases` — Felder `press_release[company_id|contacts_list[]|category_id|language|title|text|backlink_url|…]`; Statusverhalten s. S2; KI-Deklarations-Default none (erledigt 10.07.); Response `201` + `Location` + `{}`. - - [ ] `POST /service/pressreleases/{id}/images` — Multipart `press_release_image[image|title|description|copyright]`; Eigentümer-Check (403 „You are not the creator of this press release"); ai_declaration-Default original (erledigt 10.07.); Response `201` + `Location` + `{}`. - - [ ] `GET /service/pressreleases/{id}` — nur `published` (sonst 404 — connektars Polling-Kontrolle!), Hit-Zählung, flaches Alt-Objekt `{id,title,text,teaser_end,language,category_id,view_url,company:{viewUrl,name}}`. - - [ ] Alt-Fehlerformat überall: `{"global_errors":[…],"field_errors":{…}}` (422) bzw. `{"global_errors":[…]}`. - - [ ] Pest-Paritäts-Tests je Endpoint gegen den §1-Vertrag (inkl. connektar-Flow end-to-end: Firma → Kontakt → PM → Bild → GET). +- [x] **S1 — Legacy-Compat-Controller `/service/…`** (ersetzt die direkten v1-Aliasse): + - [x] `POST /service/users/me/companies` — Felder `company[name|address|country_id|phone|fax|email|website]` (+ `logo`-Datei); legacy `country_id` → neuer `country_code`; Response `201` + `Location` + `{}`; Alt-Fehlerformat. + - [x] `POST /service/users/me/companies/{id}/contacts` — Felder `contact[salutation_id|title|first_name|last_name|responsibility|phone|fax|email]`; Zugriff nur eigene Firma (403-Wortlaut s. §1); Response wie oben. + - [x] `POST /service/pressreleases` — Felder `press_release[company_id|contacts_list[]|category_id|language|title|text|backlink_url|…]`; Statusverhalten s. S2; KI-Deklarations-Default none (erledigt 10.07.); Response `201` + `Location` + `{}`. + - [x] `POST /service/pressreleases/{id}/images` — Multipart `press_release_image[image|title|description|copyright]`; Eigentümer-Check (403 „You are not the creator of this press release"); ai_declaration-Default original (erledigt 10.07.); Response `201` + `Location` + `{}`. + - [x] `GET /service/pressreleases/{id}` — nur `published` (sonst 404 — connektars Polling-Kontrolle!), Hit-Zählung, flaches Alt-Objekt `{id,title,text,teaser_end,language,category_id,view_url,company:{viewUrl,name}}`. + - [x] Alt-Fehlerformat überall: `{"global_errors":[…],"field_errors":{…}}` (422) bzw. `{"global_errors":[…]}`. + - [x] Pest-Paritäts-Tests (`LegacyServiceParityTest`) — je Endpoint gegen den §1-Vertrag (inkl. connektar-Flow end-to-end: Firma → Kontakt → PM → Bild → GET). - Nicht nachgebaut (real ungenutzt, bleiben 410): GET-Index-Routen, Einzelbild-GET, Company-GET/Show, Kontakt-GET, `.json`-Suffix-Formen. GET users/me/companies + Show bleiben als vorhandene Aliasse bestehen (real nur von der toten Integration gecallt, schadet nicht). -- [ ] **S2 — Statusverhalten create (Entscheidung 10.07., §3b):** Default **Sofort-Publish** (Alt-Verhalten, per Log belegt); **Admin-Schalter** „KI-Prüfung für Legacy-API" → Status review + KI-Routing (Gelb/Grün auto-publish, Rot Ablehnung + Mail). Umsetzung: Setting (config + Admin-UI-Toggle), Legacy-Create wertet es aus. Blacklist/Slot-Verbrauch laufen in beiden Wegen über `PressReleaseService::publish()`. +- [x] **S2 — Statusverhalten create (Entscheidung 10.07., §3b):** Default **Sofort-Publish** (Alt-Verhalten, per Log belegt); **Admin-Schalter** „KI-Prüfung für Legacy-API" → Status review + KI-Routing (Gelb/Grün auto-publish, Rot Ablehnung + Mail). Umsetzung: Setting (config + Admin-UI-Toggle), Legacy-Create wertet es aus. Blacklist/Slot-Verbrauch laufen in beiden Wegen über `PressReleaseService::publish()`. - [x] **S3 — Endpoint-Scope entschieden (10.07.):** per Log-Auswertung (§3a) — nur die 5 echten connektar-Endpoints nachbauen, Rest bleibt 410. (User hat Logs in `dev/api/` geliefert; ersetzt das frühere [EXTERN] Log-Beschaffen.) - [ ] **S4 — Testdaten** (User liefert Vorgaben, s. Abschnitt unten): Vorschlag: Artisan-Command `legacy:seed-api-testdata` — legt API-Testkunden nach connektar-Muster an (aktiv, grandfathered, Legacy-Key) inkl. Firma, Kategorie, Beispiel-PMs und gibt Key + curl-Sammlung aus; nur `local`/`staging`. - [ ] **S5 — Paritäts-Smoke auf Staging:** curl-Sammlung des connektar-Flows (X-ApiKey, verschachtelte Params) gegen Staging; Vergleich gegen Alt-System-Responses. @@ -143,3 +143,17 @@ Scope nach Log-Auswertung (§3a): **die 5 connektar-Endpoints**, verhaltensgleic (Sofort-Publish + Admin-Schalter KI-Prüfung, zwei getrennte APIs, Rückkanal später). Schrittplan S1–S7 auf den echten Scope zugeschnitten; S3 damit entschieden. **Nächster Schritt: S1 + S2 implementieren.** +- **10.07.2026 (3): S1 + S2 UMGESETZT** — eigene Legacy-Controller + (`app/Http/Controllers/Api/Legacy/`) für die 5 connektar-Endpoints, + verhaltensgleich (verschachtelte Params, 201+Location+{}, Alt-Fehlerformat, + published-only-GET mit Hit-Zählung, Legacy-Land-Mapping). Anlegen = + Einreichen; Sofort-Publish über neue Permission `api:immediate-publication` + (Seeder; **connektar-User beim Go-Live zuweisen, WS-7!**) — übersteuerbar + durch den neuen Admin-Schalter „KI-Prüfung Legacy-API" (`app_settings`, + Toggle auf /admin/press-releases). Dazu: KI-Spalte + manueller Prüf-Button + (ohne Routing) in der Admin-PM-Liste; Benachrichtigungs-Mails bei + API-Einlieferern an Pressekontakt, sonst Firmen-Mail, Fallback Einlieferer + (Entscheidung 10.07.). Tests: `LegacyServiceParityTest` (5, inkl. + connektar-Flow end-to-end) + Bestands-Tests nachgezogen. +- **Offen:** S4 Testdaten (User-Vorgaben ausstehend) · S5 Staging-Smoke · + S6 Rückkanal (v1, nachgelagert) · S7 Mailing · WS-7 Permission connektar. diff --git a/dev/release/entwicklungsplan-release.md b/dev/release/entwicklungsplan-release.md index 284a73dd..04eb6410 100644 --- a/dev/release/entwicklungsplan-release.md +++ b/dev/release/entwicklungsplan-release.md @@ -30,7 +30,7 @@ R2.2+R2.3 erledigt: Hub-Landing komplett ueberarbeitet (datengetriebenes Tarif-G **10.07. nachmittags — R2.5 + QA-Nachpruefung + R4.3 + KI-Queue-Fix + API-KI-Deklaration (Suite: 1165 gruen):** R2.5 erledigt (legal-request-Texte aus freigegebenem Meldeverfahren, s. R2.5) · QA-Checkliste B–G vertieft (s. R6.1) · R4.3 erledigt (Karten-Helper am Model, s. R4.3). **Befund KI-Pruefung:** Auto-Routing (Rot → Ablehnung, Gelb/Gruen → Auto-Publish) war laengst gebaut, lief lokal aber nie — `composer run dev` startete den Worker ohne `--queue=default,classification`; gefixt, Backlog abgearbeitet (Pipeline live verifiziert; Prod-Runbook war korrekt). **Befund + Fix API (Entscheidung User 10.07.):** v1-API hatte keine KI-Deklarationsfelder → Submit lief seit 09.07. immer auf 422 (haette alle 406 Legacy-API-Kunden blockiert; Suite uebersah es, weil die Factory deklariert). Jetzt: `ai_text_declaration` Pflicht im v1-Submit (Call gilt dokumentiert als verbindliche Bestaetigung + Archiv-Kenntnisnahme AGB § 13), `ai_declaration` Pflicht je Bild-Upload; Legacy-/service-Pfade defaulten auf none/original (Mailing R6.4 weist darauf hin, dass die weitere Nutzung diese Erklaerung darstellt); OpenAPI + /api-Seite aktualisiert. Tests: Submit-/Image-/Legacy-API-Tests erweitert. **Beobachtung fuer QA/Launch:** Test-Kauderwelsch („asdas dsada…") wurde Gruen klassifiziert und auto-publiziert — vor Go-Live Prompt/Schwelle pruefen bzw. `CLASSIFICATION_GREEN_DELAY` als Sicherheitsfenster setzen [USER-ENTSCHEIDUNG]. -**10.07. abends — API-Legacy-Paritaet (neues Arbeitsdokument `dev/release/api-legacy-paritaet.md`):** User-Vorgabe: /service-API muss beim Cutover verhaltensgleich zum Altsystem laufen. Alt-Vertrag vollstaendig aus dem Symfony-Code (`_businessportal24.com/`) extrahiert; Prod-Aktivitaets-Logs (User, `dev/api/` — enttrackt via .gitignore, Kundendaten!) ausgewertet: **ein echter Client (connektar), 5 Endpoints, Sofort-Publish beim POST belegt; 2 der 5 Endpoints (Company-/Kontakt-Create) laufen heute auf 410.** Entscheidungen: Sofort-Publish nachbilden + Admin-Schalter fuer KI-Pruefung der Legacy-Einlieferungen; Legacy-API und v1 bleiben getrennt; Ablehnungs-Rueckkanal spaeter in v1. **Naechster Schritt: S1+S2 aus der Paritaets-Doku implementieren (Compat-Controller + Statusverhalten), dann S4 Testdaten (User-Vorgaben folgen).** +**10.07. abends — API-Legacy-Paritaet (neues Arbeitsdokument `dev/release/api-legacy-paritaet.md`):** User-Vorgabe: /service-API muss beim Cutover verhaltensgleich zum Altsystem laufen. Alt-Vertrag vollstaendig aus dem Symfony-Code (`_businessportal24.com/`) extrahiert; Prod-Aktivitaets-Logs (User, `dev/api/` — enttrackt via .gitignore, Kundendaten!) ausgewertet: **ein echter Client (connektar), 5 Endpoints, Sofort-Publish beim POST belegt; 2 der 5 Endpoints (Company-/Kontakt-Create) laufen heute auf 410.** Entscheidungen: Sofort-Publish nachbilden + Admin-Schalter fuer KI-Pruefung der Legacy-Einlieferungen; Legacy-API und v1 bleiben getrennt; Ablehnungs-Rueckkanal spaeter in v1. S1+S2 noch am 10.07. umgesetzt: Legacy-Compat-Controller fuer die 5 connektar-Endpoints (inkl. Company-/Kontakt-Create, die vorher 410 liefen), Anlegen = Einreichen mit Sofort-Publish via Permission `api:immediate-publication` (WS-7: connektar zuweisen!), Admin-Schalter "KI-Pruefung Legacy-API" (neue app_settings-Tabelle, Toggle auf /admin/press-releases) + KI-Spalte/Pruef-Button in der Admin-PM-Liste; API-Einlieferer-Mails gehen an Pressekontakt/Firmen-Mail statt an den Einlieferer (User-Entscheidung). Tests: LegacyServiceParityTest. **Offen: S4 Testdaten (User-Vorgaben ausstehend), S5 Staging-Smoke, S7 Mailing.** **Wiedereinstieg / naechste Schritte:** 1. ~~Klaerungsliste Rechtstexte~~ **erledigt 06.07.**; ~~R3.1-Restbacklog~~ **erledigt 06.07.** (siehe oben). diff --git a/resources/views/livewire/admin/press-releases/index.blade.php b/resources/views/livewire/admin/press-releases/index.blade.php index 8e40a54e..f9c96a76 100644 --- a/resources/views/livewire/admin/press-releases/index.blade.php +++ b/resources/views/livewire/admin/press-releases/index.blade.php @@ -1,6 +1,10 @@ resetPage(); } + /** + * Admin-Schalter (Entscheidung 10.07.2026): KI-Prüfung für Legacy-API- + * Einlieferungen. Aus = Alt-Verhalten Sofort-Publish (connektar). + */ + public bool $legacyApiAiReview = false; + + public function mount(): void + { + $this->legacyApiAiReview = AppSetting::getBool(LegacyPressReleaseController::SETTING_AI_REVIEW); + } + + public function updatedLegacyApiAiReview(bool $value): void + { + AppSetting::setValue(LegacyPressReleaseController::SETTING_AI_REVIEW, $value); + + Flux::toast( + $value + ? __('KI-Prüfung für Legacy-API-Einlieferungen aktiviert.') + : __('Legacy-API veröffentlicht wieder sofort (Alt-Verhalten).'), + variant: 'success', + ); + } + + /** + * Manuelle KI-Prüfung aus der Liste — bewusst ohne Routing: bewertet + * und protokolliert, ändert aber nie den Status (Admin entscheidet). + */ + public function runAiCheck(int $id): void + { + $pr = $this->findAdminPR($id); + + ClassifyPressRelease::dispatch($pr->id, route: false)->onQueue('classification'); + ScorePressRelease::dispatch($pr->id)->onQueue('classification'); + + Flux::toast(__('KI-Prüfung angestoßen — Ergebnis erscheint nach dem nächsten Queue-Lauf.'), variant: 'success'); + } + public function resetFilters(): void { $this->search = ''; @@ -468,6 +509,13 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten @endforeach + {{-- Entscheidung 10.07.2026: KI-Prüfung für Legacy-API-Einlieferungen; + aus = Alt-Verhalten (Sofort-Publish für berechtigte Bestandskunden). --}} + + + {{ __('KI-Prüfung Legacy-API') }} + + @foreach ($portalOptions as $p) @@ -809,6 +857,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten :direction="$sortDir" wire:click="sort('created_at')">{{ __('Datum') }} {{ __('Hits') }} + {{ __('KI-Prüfung') }} {{ __('Aktionen') }} @@ -945,6 +994,24 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten class="font-mono text-[12px] text-[color:var(--color-ink-2)] tabular-nums">{{ number_format($pr->hits) }} + + @if ($pr->classification) + @php + $kiBadgeClass = match ($pr->classification) { + \App\Enums\PressReleaseClassification::Green => 'published', + \App\Enums\PressReleaseClassification::Yellow => 'review', + \App\Enums\PressReleaseClassification::Red => 'rejected', + }; + @endphp + {{ $pr->classification->label() }} + @else + + {{ __('Prüfen') }} + + @endif + +
name('legacy-service.') ->middleware(['auth:sanctum', EnsureApiUserIsActive::class, EnsureApiTokenRateLimit::class]) ->group(function (): void { - Route::get('pressreleases', [PressReleaseController::class, 'index'])->name('pressreleases.index'); - Route::post('pressreleases', [PressReleaseController::class, 'store'])->name('pressreleases.store'); - Route::get('pressreleases/{pressRelease}', [PressReleaseController::class, 'show'])->name('pressreleases.show'); - Route::get('pressreleases/{pressRelease}/images', [PressReleaseImageController::class, 'index'])->name('pressreleases.images.index'); - Route::post('pressreleases/{pressRelease}/images', [PressReleaseImageController::class, 'store'])->name('pressreleases.images.store'); + // Paritäts-Controller (10.07.2026, dev/release/api-legacy-paritaet.md): + // die 5 real genutzten Alt-Calls (connektar-Flow) verhaltensgleich — + // verschachtelte Alt-Params, Alt-Responses, Anlegen = Einreichen. + Route::post('pressreleases', [LegacyPressReleaseController::class, 'store'])->name('pressreleases.store'); + Route::get('pressreleases/{pressRelease}', [LegacyPressReleaseController::class, 'show'])->name('pressreleases.show'); + Route::post('pressreleases/{pressRelease}/images', [LegacyPressReleaseImageController::class, 'store'])->name('pressreleases.images.store'); + Route::post('users/me/companies', [LegacyCompanyController::class, 'store'])->name('companies.store'); + Route::post('users/me/companies/{company}/contacts', [LegacyContactController::class, 'store'])->name('contacts.store'); + // Real ungenutzte, aber harmlose Lese-Aliasse (v1-Format). + Route::get('pressreleases', [PressReleaseController::class, 'index'])->name('pressreleases.index'); + Route::get('pressreleases/{pressRelease}/images', [PressReleaseImageController::class, 'index'])->name('pressreleases.images.index'); Route::get('users/me/companies', [CompanyController::class, 'index'])->name('companies.index'); Route::get('users/me/companies/{company}', [CompanyController::class, 'show'])->name('companies.show'); }); diff --git a/tests/Feature/Api/LegacyApiCompatibilityTest.php b/tests/Feature/Api/LegacyApiCompatibilityTest.php index e97060a6..44ee235d 100644 --- a/tests/Feature/Api/LegacyApiCompatibilityTest.php +++ b/tests/Feature/Api/LegacyApiCompatibilityTest.php @@ -10,6 +10,7 @@ use App\Models\User; use App\Models\UserPaymentOption; use App\Services\Api\LegacyApiKeyImporter; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Storage; use Laravel\Sanctum\PersonalAccessToken; use Tests\TestCase; @@ -125,7 +126,9 @@ test('the legacy service paths require authentication', function () { test('retired legacy service endpoints answer with a helpful 410 instead of 404', function () { /** @var TestCase $this */ - $this->postJson('/service/users/me/companies', ['company' => ['name' => 'Test']]) + // Company-Create ist seit 10.07. ein echter Paritaets-Endpoint (S1); + // als weiterhin abgeschalteter Alt-Pfad dient das Einzelbild-GET. + $this->getJson('/service/pressreleases/1/images/1') ->assertStatus(410) ->assertJsonStructure(['message', 'account_url', 'docs_url']); @@ -147,21 +150,26 @@ test('legacy service calls default the ai declaration instead of breaking old cl // Store ohne KI-Felder (Alt-Client): Default "ohne generative KI erstellt" // (Entscheidung 10.07.2026 — das API-Kunden-Mailing weist darauf hin). + Queue::fake(); $response = $this->postJson('/service/pressreleases', [ - 'company_id' => $company->id, - 'category_id' => $category->id, - 'language' => 'de', - 'title' => 'Alt-Client-Meldung', - 'text' => 'Inhalt aus einer bestehenden Integration.', + 'press_release' => [ + 'company_id' => $company->id, + 'category_id' => $category->id, + 'language' => 'de', + 'title' => 'Alt-Client-Meldung', + 'text' => 'Inhalt aus einer bestehenden Integration.', + ], ], ['X-ApiKey' => 'legacy-key-abc123'])->assertCreated(); - $pressRelease = PressRelease::withoutGlobalScopes()->find($response->json('data.id')); + $pressRelease = PressRelease::withoutGlobalScopes()->find((int) basename((string) $response->headers->get('Location'))); expect($pressRelease->ai_text_declaration?->value)->toBe('none') ->and($pressRelease->ai_declaration_confirmed_at)->not->toBeNull(); // Bild-Upload ohne KI-Feld: Default Originalfoto. $this->postJson("/service/pressreleases/{$pressRelease->id}/images", [ - 'image' => UploadedFile::fake()->image('alt-client.jpg'), + 'press_release_image' => [ + 'image' => UploadedFile::fake()->image('alt-client.jpg'), + ], ], ['X-ApiKey' => 'legacy-key-abc123'])->assertCreated(); expect($pressRelease->images()->first()->ai_declaration?->value)->toBe('original'); diff --git a/tests/Feature/Api/LegacyServiceParityTest.php b/tests/Feature/Api/LegacyServiceParityTest.php new file mode 100644 index 00000000..da2a5bf2 --- /dev/null +++ b/tests/Feature/Api/LegacyServiceParityTest.php @@ -0,0 +1,222 @@ +seed(RolesAndPermissionsSeeder::class); + config()->set('billing.enforce_booking', false); + + $user = User::factory()->create([ + 'is_active' => true, + 'portal' => Portal::Businessportal24, + ]); + $user->forceFill(['registration_type' => 'apiuser'])->save(); + + if ($immediatePublication) { + $user->givePermissionTo('api:immediate-publication'); + } + + Sanctum::actingAs($user); + + return $user; +} + +test('the full connektar flow works like the old api with immediate publishing', function () { + /** @var TestCase $this */ + Queue::fake(); + Storage::fake('public'); + $user = parityApiUser(); + + // 1. Firma anlegen — verschachtelte Alt-Params, 201 + Location + {}. + $response = $this->postJson('/service/users/me/companies', [ + 'company' => [ + 'name' => 'Muster Energie GmbH', + 'address' => "Musterstraße 1\n33602 Bielefeld", + 'country_id' => 177, + 'email' => 'info@muster-energie.de', + 'website' => 'https://muster-energie.de', + ], + ])->assertCreated(); + + expect($response->headers->get('Location'))->toContain('/service/users/me/companies/') + ->and($response->json())->toBe([]); + + $company = Company::withoutGlobalScopes()->latest('id')->first(); + expect($company->country_code)->toBe('DE') + ->and($company->is_active)->toBeTrue() + ->and($company->owner_user_id)->toBe($user->id); + + // 2. Pressekontakt anlegen — salutation_id 1 → mr. + $this->postJson("/service/users/me/companies/{$company->id}/contacts", [ + 'contact' => [ + 'salutation_id' => 1, + 'first_name' => 'Max', + 'last_name' => 'Mustermann', + 'email' => 'presse@muster-energie.de', + ], + ])->assertCreated(); + + $contact = $company->contacts()->first(); + expect($contact->salutation_key)->toBe('mr'); + + // 3. PM anlegen — Anlegen IST Einreichen; mit Sofort-Publish-Recht + // und Schalter aus geht sie sofort live (Alt-Verhalten, Log-Beleg). + $category = Category::factory()->create(['portal' => Portal::Businessportal24, 'legacy_id' => 4242]); + + $response = $this->postJson('/service/pressreleases', [ + 'press_release' => [ + 'company_id' => $company->id, + 'category_id' => 4242, + 'language' => 'de', + 'title' => 'Muster Energie eröffnet neuen Standort', + 'text' => 'Vollkommen unauffälliger Inhalt aus einer Alt-Integration.', + 'contacts_list' => [$contact->id], + ], + ])->assertCreated(); + + expect($response->json())->toBe([]); + + $pressRelease = PressRelease::withoutGlobalScopes()->latest('id')->first(); + expect($pressRelease->status)->toBe(PressReleaseStatus::Published) + ->and($pressRelease->published_at)->not->toBeNull() + ->and($pressRelease->category_id)->toBe($category->id) + ->and($pressRelease->ai_text_declaration?->value)->toBe('none') + ->and($pressRelease->ai_declaration_confirmed_at)->not->toBeNull(); + + // 4. Bild hochladen — verschachtelt, KI-Default Originalfoto. + $this->postJson("/service/pressreleases/{$pressRelease->id}/images", [ + 'press_release_image' => [ + 'image' => UploadedFile::fake()->image('pressefoto.jpg', 1200, 800), + 'copyright' => 'Muster Energie GmbH', + ], + ])->assertCreated(); + + expect($pressRelease->images()->first()->ai_declaration?->value)->toBe('original'); + + // 5. GET als Veröffentlichungs-Kontrolle: flaches Alt-Objekt, Hits zählen. + $this->getJson("/service/pressreleases/{$pressRelease->id}") + ->assertOk() + ->assertJsonStructure(['id', 'title', 'text', 'teaser_end', 'language', 'category_id', 'view_url', 'company' => ['viewUrl', 'name']]) + ->assertJsonPath('company.name', 'Muster Energie GmbH'); + + expect(PressRelease::withoutGlobalScopes()->find($pressRelease->id)->hits)->toBe(1); +}); + +test('the admin switch routes legacy submissions through the ai review instead', function () { + /** @var TestCase $this */ + Queue::fake(); + $user = parityApiUser(); + AppSetting::setValue(LegacyPressReleaseController::SETTING_AI_REVIEW, true); + + $company = Company::factory()->create(['portal' => Portal::Businessportal24, 'is_active' => true]); + $user->companies()->attach($company->id, ['role' => 'owner']); + $category = Category::factory()->create(['portal' => Portal::Businessportal24]); + + $this->postJson('/service/pressreleases', [ + 'press_release' => [ + 'company_id' => $company->id, + 'category_id' => $category->id, + 'title' => 'Wird erst KI-geprüft', + 'text' => 'Inhalt, der durch die KI-Prüfung läuft.', + ], + ])->assertCreated(); + + $pressRelease = PressRelease::withoutGlobalScopes()->latest('id')->first(); + expect($pressRelease->status)->toBe(PressReleaseStatus::Review); + Queue::assertPushed(ClassifyPressRelease::class); + + // Polling-Kontrolle des Alt-Clients: unveröffentlicht = 404. + $this->getJson("/service/pressreleases/{$pressRelease->id}")->assertNotFound(); +}); + +test('without the immediate publication permission legacy posts stay in review', function () { + /** @var TestCase $this */ + Queue::fake(); + $user = parityApiUser(immediatePublication: false); + + $company = Company::factory()->create(['portal' => Portal::Businessportal24, 'is_active' => true]); + $user->companies()->attach($company->id, ['role' => 'owner']); + $category = Category::factory()->create(['portal' => Portal::Businessportal24]); + + $this->postJson('/service/pressreleases', [ + 'press_release' => [ + 'company_id' => $company->id, + 'category_id' => $category->id, + 'title' => 'Ohne Sonderrecht', + 'text' => 'Inhalt ohne Sofort-Publish-Privileg.', + ], + ])->assertCreated(); + + expect(PressRelease::withoutGlobalScopes()->latest('id')->first()->status)->toBe(PressReleaseStatus::Review); +}); + +test('legacy errors use the old global and field error format', function () { + /** @var TestCase $this */ + parityApiUser(); + + $this->postJson('/service/pressreleases', [ + 'press_release' => ['title' => 'Ohne Pflichtfelder'], + ])->assertUnprocessable() + ->assertJsonStructure(['global_errors', 'field_errors']); + + // Fremde Firma → Alt-Wortlaut als global_error. + $foreign = Company::factory()->create(['portal' => Portal::Businessportal24]); + $this->postJson("/service/users/me/companies/{$foreign->id}/contacts", [ + 'contact' => ['first_name' => 'Max', 'last_name' => 'Muster'], + ])->assertForbidden() + ->assertJsonPath('global_errors.0', 'You may only access companies which are related to your user-account'); +}); + +test('api submissions notify the press contact instead of the api submitter', function () { + /** @var TestCase $this */ + Mail::fake(); + Queue::fake(); + $user = parityApiUser(); + + $company = Company::factory()->create([ + 'portal' => Portal::Businessportal24, + 'is_active' => true, + 'email' => 'firma@muster.de', + ]); + $user->companies()->attach($company->id, ['role' => 'owner']); + $category = Category::factory()->create(['portal' => Portal::Businessportal24]); + $contact = $company->contacts()->create([ + 'portal' => Portal::Businessportal24->value, + 'first_name' => 'Max', + 'last_name' => 'Mustermann', + 'email' => 'presse@muster.de', + ]); + + $this->postJson('/service/pressreleases', [ + 'press_release' => [ + 'company_id' => $company->id, + 'category_id' => $category->id, + 'title' => 'Mail geht an den Pressekontakt', + 'text' => 'Inhalt für den Mail-Routing-Test.', + 'contacts_list' => [$contact->id], + ], + ])->assertCreated(); + + Mail::assertQueued(PressReleasePublished::class, fn ($mail) => $mail->hasTo('presse@muster.de')); +});