api: Ziel-Portal einer Einreichung explizit auflösen statt vom Konto erben

Legacy-API-Kunden mit Keys beider Altportale — vor allem connektar — senden
jede Meldung an presseecho.de UND businessportal24.de. Weil die Firma ihr
Portal vom User und die Meldung es von der Firma erbte, landeten beide Kopien
im selben Portal, die zweite mit Slug-Suffix "-2". Verifiziert wurde das über
die Token-Hashes gegen die Legacy-Datenbanken.

Das Portal wird jetzt in fester Reihenfolge aufgelöst: expliziter
portal-Parameter, sonst die aufgerufene Domain, sonst das Portal des
Firmenprofils, zuletzt Portal::Both — genau der Default, den beide Spalten
ohnehin tragen. Die Auflösung ist damit nicht-nullbar; vorher konnte sie null
liefern, was als SQL-Fehler und damit als 500er beim Alt-Client gelandet wäre.
Ein Update über die v1-API verschiebt eine bestehende Meldung ausschließlich
über den expliziten Parameter, nie über die aufgerufene Domain.

press-releases:fix-api-portal-duplicates repariert die Altdaten: Es findet
Paare (gleicher User, Titel, Sprache und Portal, beide per API eingereicht,
kurz nacheinander) und verschiebt die zweite Kopie samt ihrer exklusiven Firma
ins jeweils andere Portal, mit neu vergebenem Slug. Web-Doppel-Submits ohne
Status-Log source=api und Legacy-Sync-Daten mit gesetzter legacy_id werden
geschont. Auf Live ausgeführt und verifiziert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-01 11:06:58 +00:00
parent 0c8e94a6ff
commit 982de2aba2
15 changed files with 720 additions and 13 deletions

View file

@ -0,0 +1,208 @@
<?php
namespace App\Console\Commands;
use App\Enums\Portal;
use App\Models\Company;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* Verteilt per API doppelt eingegangene Pressemitteilungen auf die Portale.
*
* Hintergrund (Diagnose 13.08.2026): Legacy-API-Kunden mit Keys beider
* Altportale (z. B. connektar) senden jede PM an presseecho.de UND
* businessportal24.de. Da Firmen- und PM-Portal bisher vom User geerbt
* wurden, landeten beide Kopien im selben Portal — die zweite mit
* Slug-Suffix "-2". Dieses Command findet solche Paare (gleicher User,
* Titel und Sprache, gleiches Portal, kurz nacheinander per API
* eingereicht) und verschiebt die jeweils zweite Kopie samt ihrer
* exklusiven Firma ins jeweils andere Portal; Slugs werden dabei im
* Zielportal neu (ohne Suffix, sofern frei) vergeben.
*
* Web-Doppel-Submits (kein API-Status-Log) und Legacy-Sync-Importe
* (legacy_id gesetzt) werden nicht angefasst.
*
* Verwendung:
* php artisan press-releases:fix-api-portal-duplicates --dry-run
* php artisan press-releases:fix-api-portal-duplicates
* php artisan press-releases:fix-api-portal-duplicates --user=13993 --window=120
*/
class FixApiPortalDuplicates extends Command
{
protected $signature = 'press-releases:fix-api-portal-duplicates
{--since=2026-07-20 : Nur PMs ab diesem Datum (API-Go-Live) berücksichtigen}
{--window=60 : Max. Minuten zwischen den beiden Einreichungen eines Paars}
{--user= : Optional auf eine User-ID beschränken}
{--dry-run : Nur anzeigen, nichts schreiben}';
protected $description = 'Verschiebt per API doppelt eingereichte PMs (Slug-Suffix "-2") samt Firma ins jeweils andere Portal.';
public function handle(): int
{
$since = (string) $this->option('since');
$windowMinutes = max(1, (int) $this->option('window'));
$userId = $this->option('user') !== null ? (int) $this->option('user') : null;
$isDryRun = (bool) $this->option('dry-run');
if ($isDryRun) {
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
}
$pairs = $this->findDuplicatePairs($since, $windowMinutes, $userId);
if ($pairs->isEmpty()) {
$this->info('Keine API-Duplikat-Paare gefunden.');
return self::SUCCESS;
}
$movedPressReleases = 0;
$movedCompanies = 0;
$rows = [];
foreach ($pairs as [$keep, $move]) {
$target = $move->portal === Portal::Businessportal24
? Portal::Presseecho
: Portal::Businessportal24;
$newSlug = $move->generateUniqueSlug($move->title, [
'portal' => $target->value,
'language' => $move->language,
]);
$company = $this->exclusiveCompanyOf($move);
$rows[] = [
$move->id,
mb_strimwidth($move->title, 0, 50, '…'),
"{$move->portal->value} → {$target->value}",
"{$move->slug} → {$newSlug}",
$company ? "#{$company->id} {$company->slug}" : '–',
$keep->id,
];
if (! $isDryRun) {
DB::transaction(function () use ($move, $company, $target, $newSlug, &$movedCompanies): void {
$move->portal = $target;
$move->slug = $newSlug;
$move->saveWithUniqueSlugRetry($move->title, [
'portal' => $target->value,
'language' => $move->language,
]);
if ($company) {
$company->portal = $target;
$company->slug = $company->generateUniqueSlug($company->name, [
'portal' => $target->value,
]);
$company->saveWithUniqueSlugRetry($company->name, [
'portal' => $target->value,
]);
$movedCompanies++;
}
});
} elseif ($company) {
$movedCompanies++;
}
$movedPressReleases++;
}
$this->table(
['PM', 'Titel', 'Portal', 'Slug', 'Firma zieht mit', 'Original bleibt'],
$rows,
);
$verb = $isDryRun ? 'würden verschoben werden' : 'verschoben';
$this->info("{$movedPressReleases} Pressemitteilungen und {$movedCompanies} Firmen {$verb}.");
return self::SUCCESS;
}
/**
* Findet Duplikat-Paare: gleicher User, Titel, Sprache und gleiches
* Portal, beide per API eingereicht (Status-Log source=api), innerhalb
* des Zeitfensters nacheinander angelegt. Bei mehr als zwei Treffern
* pro Gruppe (erneute Einreichung derselben PM) werden die Einträge
* chronologisch zu Paaren zusammengefasst.
*
* @return Collection<int, array{0: PressRelease, 1: PressRelease}>
*/
private function findDuplicatePairs(string $since, int $windowMinutes, ?int $userId): Collection
{
$candidates = PressRelease::query()
->withoutGlobalScopes([PortalScope::class])
->whereNull('legacy_id')
->whereIn('portal', [Portal::Presseecho->value, Portal::Businessportal24->value])
->where('created_at', '>=', $since)
->when($userId !== null, fn (Builder $query) => $query->where('user_id', $userId))
->whereExists(function ($query): void {
$query->select(DB::raw(1))
->from('press_release_status_logs')
->whereColumn('press_release_status_logs.press_release_id', 'press_releases.id')
->where('press_release_status_logs.source', 'api');
})
->orderBy('created_at')
->orderBy('id')
->get();
return $candidates
->groupBy(fn (PressRelease $pressRelease): string => implode('|', [
$pressRelease->user_id,
$pressRelease->portal->value,
$pressRelease->language,
$pressRelease->title,
]))
->filter(fn (Collection $group): bool => $group->count() >= 2)
->flatMap(function (Collection $group) use ($windowMinutes): array {
$pairs = [];
$items = $group->values();
for ($i = 0; $i < $items->count() - 1; $i++) {
$first = $items[$i];
$second = $items[$i + 1];
if ($first->created_at->diffInMinutes($second->created_at) <= $windowMinutes) {
$pairs[] = [$first, $second];
$i++;
}
}
return $pairs;
})
->values();
}
/**
* Firma der verschobenen PM, sofern sie exklusiv zu dieser PM gehört
* (Legacy-API-Muster: pro Einreichung wird eine frische Firma angelegt).
* Firmen mit weiteren PMs oder aus dem Legacy-Sync bleiben unangetastet.
*/
private function exclusiveCompanyOf(PressRelease $pressRelease): ?Company
{
if ($pressRelease->company_id === null) {
return null;
}
$company = Company::query()
->withoutGlobalScopes([PortalScope::class])
->find($pressRelease->company_id);
if (! $company || $company->legacy_id !== null) {
return null;
}
$hasOtherPressReleases = PressRelease::query()
->withoutGlobalScopes([PortalScope::class])
->where('company_id', $company->id)
->whereKeyNot($pressRelease->id)
->exists();
return $hasOtherPressReleases ? null : $company;
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers\Api\Concerns;
use App\Enums\Portal;
use App\Services\CurrentPortalContext;
/**
* Löst das Ziel-Portal eines API-Schreibzugriffs auf.
*
* Reihenfolge (Entscheidung 13.08.2026, API-Portal-Fix):
* 1. Explizit mitgesendeter portal-Parameter (überstimmt alles).
* 2. Domain des Requests (CurrentPortalContext, gesetzt via SetCurrentPortal:
* presseecho.de → presseecho, businessportal24.de → businessportal24).
* 3. Aufrufer-spezifischer Fallback (Portal der Firma bzw. des Users) —
* greift z. B. bei Calls über die Portal-/Admin-Domain ohne Parameter.
* 4. `Portal::Both` als letzter Ausweg.
*
* Stufe 4 macht die Rückgabe bewusst nicht-nullbar: `press_releases.portal` und
* `companies.portal` sind NOT-NULL-ENUMs mit genau diesem Default. Ein `null`
* aus dieser Methode landete also als SQL-Fehler und damit als 500er beim
* Alt-Client, statt als verwertbare Antwort — und die Aufrufer, die das
* Ergebnis direkt mit `->value` weiterreichen, wären ein Fatal Error.
*/
trait ResolvesRequestedPortal
{
private function resolveRequestedPortal(?string $requestedPortal, ?Portal $fallback): Portal
{
return Portal::tryFrom((string) $requestedPortal)
?? CurrentPortalContext::get()
?? $fallback
?? Portal::Both;
}
}

View file

@ -3,10 +3,13 @@
namespace App\Http\Controllers\Api\Legacy; namespace App\Http\Controllers\Api\Legacy;
use App\Enums\CompanyType; use App\Enums\CompanyType;
use App\Enums\Portal;
use App\Http\Controllers\Api\Concerns\ResolvesRequestedPortal;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Company; use App\Models\Company;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
/** /**
@ -16,7 +19,7 @@ use Illuminate\Validation\ValidationException;
*/ */
class LegacyCompanyController extends Controller class LegacyCompanyController extends Controller
{ {
use RespondsLikeLegacyApi; use ResolvesRequestedPortal, RespondsLikeLegacyApi;
public function store(Request $request): JsonResponse public function store(Request $request): JsonResponse
{ {
@ -26,6 +29,7 @@ class LegacyCompanyController extends Controller
$validated = $request->validate([ $validated = $request->validate([
'company' => ['required', 'array'], 'company' => ['required', 'array'],
'company.name' => ['required', 'string', 'max:255'], 'company.name' => ['required', 'string', 'max:255'],
'company.portal' => ['nullable', Rule::enum(Portal::class)->only([Portal::Presseecho, Portal::Businessportal24])],
'company.address' => ['nullable', 'string', 'max:1000'], 'company.address' => ['nullable', 'string', 'max:1000'],
'company.country_id' => ['nullable', 'integer'], 'company.country_id' => ['nullable', 'integer'],
'company.phone' => ['nullable', 'string', 'max:100'], 'company.phone' => ['nullable', 'string', 'max:100'],
@ -39,8 +43,14 @@ class LegacyCompanyController extends Controller
$user = $request->user(); $user = $request->user();
// Portal aus Parameter/Domain, sonst wie bisher vom User — der
// User-Fallback allein ließ alle Firmen (und damit deren PMs) von
// Kunden mit Keys beider Altportale im selben Portal landen.
$company = new Company([ $company = new Company([
'portal' => $user->portal?->value, 'portal' => $this->resolveRequestedPortal(
$validated['portal'] ?? null,
$user->portal,
)->value,
'type' => CompanyType::Company->value, 'type' => CompanyType::Company->value,
'name' => $validated['name'], 'name' => $validated['name'],
'address' => $validated['address'] ?? null, 'address' => $validated['address'] ?? null,

View file

@ -3,7 +3,9 @@
namespace App\Http\Controllers\Api\Legacy; namespace App\Http\Controllers\Api\Legacy;
use App\Enums\AiTextDeclaration; use App\Enums\AiTextDeclaration;
use App\Enums\Portal;
use App\Enums\PressReleaseStatus; use App\Enums\PressReleaseStatus;
use App\Http\Controllers\Api\Concerns\ResolvesRequestedPortal;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\AppSetting; use App\Models\AppSetting;
use App\Models\Category; use App\Models\Category;
@ -18,6 +20,7 @@ use App\Services\PressRelease\QuotaExceededException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use function Illuminate\Support\defer; use function Illuminate\Support\defer;
@ -36,7 +39,7 @@ use function Illuminate\Support\defer;
*/ */
class LegacyPressReleaseController extends Controller class LegacyPressReleaseController extends Controller
{ {
use RespondsLikeLegacyApi; use ResolvesRequestedPortal, RespondsLikeLegacyApi;
public const SETTING_AI_REVIEW = 'legacy_api_ai_review'; public const SETTING_AI_REVIEW = 'legacy_api_ai_review';
@ -53,6 +56,7 @@ class LegacyPressReleaseController extends Controller
'press_release.company_id' => ['required', 'integer'], 'press_release.company_id' => ['required', 'integer'],
'press_release.category_id' => ['required', 'integer'], 'press_release.category_id' => ['required', 'integer'],
'press_release.language' => ['nullable', 'string', 'max:5'], '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.title' => ['required', 'string', 'max:255'],
'press_release.text' => ['required', 'string'], 'press_release.text' => ['required', 'string'],
'press_release.backlink_url' => ['nullable', 'url', 'max:255'], 'press_release.backlink_url' => ['nullable', 'url', 'max:255'],
@ -104,7 +108,10 @@ class LegacyPressReleaseController extends Controller
]); ]);
$pressRelease->uuid = (string) Str::uuid(); $pressRelease->uuid = (string) Str::uuid();
$pressRelease->user_id = $user->id; $pressRelease->user_id = $user->id;
$pressRelease->portal = $company->portal?->value; $pressRelease->portal = $this->resolveRequestedPortal(
$validated['portal'] ?? null,
$company->portal,
);
$pressRelease->ai_declaration_confirmed_at = now(); $pressRelease->ai_declaration_confirmed_at = now();
$pressRelease->slug = $pressRelease->generateUniqueSlug($pressRelease->title, [ $pressRelease->slug = $pressRelease->generateUniqueSlug($pressRelease->title, [
'portal' => $pressRelease->portal, 'portal' => $pressRelease->portal,

View file

@ -3,7 +3,9 @@
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Enums\AiTextDeclaration; use App\Enums\AiTextDeclaration;
use App\Enums\Portal;
use App\Enums\PressReleaseStatus; use App\Enums\PressReleaseStatus;
use App\Http\Controllers\Api\Concerns\ResolvesRequestedPortal;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\StorePressReleaseRequest; use App\Http\Requests\Api\V1\StorePressReleaseRequest;
use App\Http\Requests\Api\V1\UpdatePressReleaseRequest; use App\Http\Requests\Api\V1\UpdatePressReleaseRequest;
@ -25,6 +27,8 @@ use Illuminate\Validation\Rule;
class PressReleaseController extends Controller class PressReleaseController extends Controller
{ {
use ResolvesRequestedPortal;
public function index(Request $request): AnonymousResourceCollection public function index(Request $request): AnonymousResourceCollection
{ {
abort_unless($request->user()->tokenCan('press-releases:read'), 403); abort_unless($request->user()->tokenCan('press-releases:read'), 403);
@ -45,11 +49,14 @@ class PressReleaseController extends Controller
$company = $this->findOwnedCompany((int) $validated['company_id'], $request); $company = $this->findOwnedCompany((int) $validated['company_id'], $request);
abort_unless($company !== null, 403); abort_unless($company !== null, 403);
// Ziel-Portal: expliziter Parameter > Request-Domain > Firmen-Portal.
$portal = $this->resolveRequestedPortal($validated['portal'] ?? null, $company->portal)->value;
$pressRelease = new PressRelease([ $pressRelease = new PressRelease([
...$validated, ...$validated,
'uuid' => (string) Str::uuid(), 'uuid' => (string) Str::uuid(),
'user_id' => $request->user()->id, 'user_id' => $request->user()->id,
'portal' => $company->portal->value, 'portal' => $portal,
// Über die API angelegte PMs sind immer Entwürfe. Der Übergang nach // Über die API angelegte PMs sind immer Entwürfe. Der Übergang nach
// `review` erfolgt ausschließlich über die explizite submit-Route, // `review` erfolgt ausschließlich über die explizite submit-Route,
// damit Blacklist-/Quota-/Log-Prüfung garantiert durchlaufen werden. // damit Blacklist-/Quota-/Log-Prüfung garantiert durchlaufen werden.
@ -65,11 +72,11 @@ class PressReleaseController extends Controller
] : []), ] : []),
]); ]);
$pressRelease->slug = $pressRelease->generateUniqueSlug($validated['title'], [ $pressRelease->slug = $pressRelease->generateUniqueSlug($validated['title'], [
'portal' => $company->portal->value, 'portal' => $portal,
'language' => $validated['language'], 'language' => $validated['language'],
]); ]);
$pressRelease->saveWithUniqueSlugRetry($validated['title'], [ $pressRelease->saveWithUniqueSlugRetry($validated['title'], [
'portal' => $company->portal->value, 'portal' => $portal,
'language' => $validated['language'], 'language' => $validated['language'],
]); ]);
@ -108,7 +115,12 @@ class PressReleaseController extends Controller
$language = $validated['language'] ?? $pressRelease->language; $language = $validated['language'] ?? $pressRelease->language;
$title = $validated['title'] ?? $pressRelease->title; $title = $validated['title'] ?? $pressRelease->title;
$portal = $company->portal->value; // 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;
$pressRelease->fill([ $pressRelease->fill([
...$validated, ...$validated,

View file

@ -2,8 +2,10 @@
namespace App\Http\Requests\Api\V1; namespace App\Http\Requests\Api\V1;
use App\Enums\Portal;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StorePressReleaseRequest extends FormRequest class StorePressReleaseRequest extends FormRequest
{ {
@ -23,6 +25,7 @@ class StorePressReleaseRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'portal' => ['nullable', Rule::enum(Portal::class)->only([Portal::Presseecho, Portal::Businessportal24])],
'company_id' => ['required', 'integer', 'exists:companies,id'], 'company_id' => ['required', 'integer', 'exists:companies,id'],
'category_id' => ['required', 'integer', 'exists:categories,id'], 'category_id' => ['required', 'integer', 'exists:categories,id'],
'language' => ['required', 'string', 'size:2'], 'language' => ['required', 'string', 'size:2'],

View file

@ -2,8 +2,10 @@
namespace App\Http\Requests\Api\V1; namespace App\Http\Requests\Api\V1;
use App\Enums\Portal;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdatePressReleaseRequest extends FormRequest class UpdatePressReleaseRequest extends FormRequest
{ {
@ -23,6 +25,7 @@ class UpdatePressReleaseRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'portal' => ['nullable', Rule::enum(Portal::class)->only([Portal::Presseecho, Portal::Businessportal24])],
'company_id' => ['sometimes', 'required', 'integer', 'exists:companies,id'], 'company_id' => ['sometimes', 'required', 'integer', 'exists:companies,id'],
'category_id' => ['sometimes', 'required', 'integer', 'exists:categories,id'], 'category_id' => ['sometimes', 'required', 'integer', 'exists:categories,id'],
'language' => ['sometimes', 'required', 'string', 'size:2'], 'language' => ['sometimes', 'required', 'string', 'size:2'],

View file

@ -414,7 +414,6 @@ class PressRelease extends Model
$portals = match ($this->portal) { $portals = match ($this->portal) {
Portal::Both => [Portal::Presseecho, Portal::Businessportal24], Portal::Both => [Portal::Presseecho, Portal::Businessportal24],
null => [],
default => [$this->portal], default => [$this->portal],
}; };

View file

@ -280,7 +280,8 @@ return [
'th_description' => 'Beschreibung', 'th_description' => 'Beschreibung',
'required_yes' => 'ja', 'required_yes' => 'ja',
'field_company_id' => 'ID eines eigenen Firmenprofils — bestimmt Newsroom und Portal.', 'field_portal' => 'Ziel-Portal (presseecho oder businessportal24). Ohne Angabe entscheidet die aufgerufene Domain, sonst das Portal des Firmenprofils; ein gesetzter Wert hat Vorrang.',
'field_company_id' => 'ID eines eigenen Firmenprofils — bestimmt den Newsroom.',
'field_category_id' => 'Rubrik (siehe GET /categories).', 'field_category_id' => 'Rubrik (siehe GET /categories).',
'field_language' => 'Sprachcode, zwei Zeichen: de oder en.', 'field_language' => 'Sprachcode, zwei Zeichen: de oder en.',
'field_title' => 'Überschrift, max. 255 Zeichen.', 'field_title' => 'Überschrift, max. 255 Zeichen.',

View file

@ -279,7 +279,8 @@ return [
'th_description' => 'Description', 'th_description' => 'Description',
'required_yes' => 'yes', 'required_yes' => 'yes',
'field_company_id' => 'ID of one of your company profiles — determines newsroom and portal.', 'field_portal' => 'Target portal (presseecho or businessportal24). If omitted, the requested domain decides, otherwise the company profile\'s portal; an explicit value takes precedence.',
'field_company_id' => 'ID of one of your company profiles — determines the newsroom.',
'field_category_id' => 'Category (see GET /categories).', 'field_category_id' => 'Category (see GET /categories).',
'field_language' => 'Language code, two characters: de or en.', 'field_language' => 'Language code, two characters: de or en.',
'field_title' => 'Headline, max. 255 characters.', 'field_title' => 'Headline, max. 255 characters.',

View file

@ -281,9 +281,20 @@ components:
type: object type: object
required: [company_id, category_id, language, title, text] required: [company_id, category_id, language, title, text]
properties: properties:
portal:
type: string
enum: [presseecho, businessportal24]
description: >
Ziel-Portal der Veröffentlichung. Optional: Ohne Angabe wird das
Portal aus der aufgerufenen Domain bestimmt (presseecho.de bzw.
businessportal24.com); ein explizit gesetzter Wert hat Vorrang
vor der Domain. Fehlen beide (z. B. Aufruf über pressekonto.de),
gilt das Portal des Firmenprofils. Beim PATCH wechselt das Portal
ausschließlich über diesen Parameter — die aufgerufene Domain
verschiebt eine bestehende Mitteilung nicht.
company_id: company_id:
type: integer type: integer
description: ID eines eigenen Firmenprofils — bestimmt Newsroom und Portal description: ID eines eigenen Firmenprofils — bestimmt den Newsroom
category_id: { type: integer } category_id: { type: integer }
language: language:
type: string type: string

View file

@ -44,6 +44,7 @@
]; ];
$pressReleaseFields = [ $pressReleaseFields = [
['name' => 'portal', 'type' => 'string', 'required' => false, 'desc' => __('static.api.field_portal')],
['name' => 'company_id', 'type' => 'integer', 'required' => true, 'desc' => __('static.api.field_company_id')], ['name' => 'company_id', 'type' => 'integer', 'required' => true, 'desc' => __('static.api.field_company_id')],
['name' => 'category_id', 'type' => 'integer', 'required' => true, 'desc' => __('static.api.field_category_id')], ['name' => 'category_id', 'type' => 'integer', 'required' => true, 'desc' => __('static.api.field_category_id')],
['name' => 'language', 'type' => 'string', 'required' => true, 'desc' => __('static.api.field_language')], ['name' => 'language', 'type' => 'string', 'required' => true, 'desc' => __('static.api.field_language')],

View file

@ -0,0 +1,216 @@
<?php
use App\Enums\Portal;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Queue;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
/**
* Regression zum API-Portal-Fix (13.08.2026): Kunden mit Keys beider
* Altportale senden an presseecho.de UND businessportal24.de — bisher
* erbten Firma und PM immer das User-/Firmen-Portal, sodass alles im
* selben Portal landete (zweite Kopie mit Slug "-2"). Das Ziel-Portal
* kommt jetzt aus explizitem Parameter > Request-Domain > Fallback.
*/
function portalResolutionApiUser(Portal $portal = Portal::Businessportal24): User
{
test()->seed(RolesAndPermissionsSeeder::class);
config()->set('billing.enforce_booking', false);
$user = User::factory()->create([
'is_active' => true,
'portal' => $portal,
]);
$user->forceFill(['registration_type' => 'apiuser'])->save();
Sanctum::actingAs($user, [
'press-releases:read',
'press-releases:write',
'companies:read',
]);
return $user;
}
function legacyPressReleasePayload(Company $company, Category $category, array $overrides = []): array
{
return [
'press_release' => array_merge([
'company_id' => $company->id,
'category_id' => $category->legacy_id ?? $category->id,
'language' => 'de',
'title' => 'Portal-Auflösung Test-PM',
'text' => 'Inhalt der Test-Pressemitteilung.',
], $overrides),
];
}
test('legacy company creation uses the request domain portal over the user portal', function () {
/** @var TestCase $this */
portalResolutionApiUser(Portal::Businessportal24);
config()->set('app.theme', 'presseecho');
$this->postJson('/service/users/me/companies', [
'company' => ['name' => 'Domain GmbH'],
])->assertCreated();
$company = Company::withoutGlobalScopes()->where('name', 'Domain GmbH')->firstOrFail();
expect($company->portal)->toBe(Portal::Presseecho);
});
test('legacy company creation falls back to the user portal without domain context', function () {
/** @var TestCase $this */
portalResolutionApiUser(Portal::Businessportal24);
config()->set('app.theme', 'portal');
$this->postJson('/service/users/me/companies', [
'company' => ['name' => 'Fallback GmbH'],
])->assertCreated();
$company = Company::withoutGlobalScopes()->where('name', 'Fallback GmbH')->firstOrFail();
expect($company->portal)->toBe(Portal::Businessportal24);
});
test('legacy company creation accepts an explicit portal parameter over the domain', function () {
/** @var TestCase $this */
portalResolutionApiUser(Portal::Businessportal24);
config()->set('app.theme', 'businessportal24');
$this->postJson('/service/users/me/companies', [
'company' => ['name' => 'Parameter GmbH', 'portal' => 'presseecho'],
])->assertCreated();
$company = Company::withoutGlobalScopes()->where('name', 'Parameter GmbH')->firstOrFail();
expect($company->portal)->toBe(Portal::Presseecho);
});
test('legacy press release creation uses the request domain portal over the company portal', function () {
/** @var TestCase $this */
Queue::fake();
$user = portalResolutionApiUser(Portal::Businessportal24);
$company = Company::factory()->businessportal24()->create(['owner_user_id' => $user->id]);
$user->companies()->attach($company->id, ['role' => 'owner']);
$category = Category::factory()->create(['legacy_id' => 4711]);
config()->set('app.theme', 'presseecho');
$this->postJson('/service/pressreleases', legacyPressReleasePayload($company, $category))
->assertCreated();
$pressRelease = PressRelease::withoutGlobalScopes()->latest('id')->firstOrFail();
expect($pressRelease->portal)->toBe(Portal::Presseecho)
->and($pressRelease->slug)->toBe('portal-auflosung-test-pm');
});
test('legacy press release creation accepts an explicit portal parameter over the domain', function () {
/** @var TestCase $this */
Queue::fake();
$user = portalResolutionApiUser(Portal::Businessportal24);
$company = Company::factory()->businessportal24()->create(['owner_user_id' => $user->id]);
$user->companies()->attach($company->id, ['role' => 'owner']);
$category = Category::factory()->create(['legacy_id' => 4712]);
config()->set('app.theme', 'businessportal24');
$this->postJson(
'/service/pressreleases',
legacyPressReleasePayload($company, $category, ['portal' => 'presseecho']),
)->assertCreated();
$pressRelease = PressRelease::withoutGlobalScopes()->latest('id')->firstOrFail();
expect($pressRelease->portal)->toBe(Portal::Presseecho);
});
test('legacy press release creation falls back to the company portal without domain context', function () {
/** @var TestCase $this */
Queue::fake();
$user = portalResolutionApiUser(Portal::Businessportal24);
$company = Company::factory()->businessportal24()->create(['owner_user_id' => $user->id]);
$user->companies()->attach($company->id, ['role' => 'owner']);
$category = Category::factory()->create(['legacy_id' => 4713]);
config()->set('app.theme', 'portal');
$this->postJson('/service/pressreleases', legacyPressReleasePayload($company, $category))
->assertCreated();
$pressRelease = PressRelease::withoutGlobalScopes()->latest('id')->firstOrFail();
expect($pressRelease->portal)->toBe(Portal::Businessportal24);
});
test('v1 press release creation resolves the portal from parameter and domain', function () {
/** @var TestCase $this */
$user = User::factory()->create();
$company = Company::factory()->businessportal24()->create();
$category = Category::factory()->withTranslations()->create();
$user->companies()->attach($company->id, ['role' => 'owner']);
Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']);
config()->set('app.theme', 'presseecho');
$this->postJson('/api/v1/press-releases', [
'company_id' => $company->id,
'category_id' => $category->id,
'language' => 'de',
'title' => 'Domain bestimmt das Portal',
'text' => 'Inhalt',
])->assertCreated()->assertJsonPath('data.portal', 'presseecho');
$this->postJson('/api/v1/press-releases', [
'portal' => 'businessportal24',
'company_id' => $company->id,
'category_id' => $category->id,
'language' => 'de',
'title' => 'Parameter schlägt Domain',
'text' => 'Inhalt',
])->assertCreated()->assertJsonPath('data.portal', 'businessportal24');
});
test('v1 press release creation rejects the portal value both', function () {
/** @var TestCase $this */
$user = User::factory()->create();
$company = Company::factory()->businessportal24()->create();
$category = Category::factory()->withTranslations()->create();
$user->companies()->attach($company->id, ['role' => 'owner']);
Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']);
$this->postJson('/api/v1/press-releases', [
'portal' => 'both',
'company_id' => $company->id,
'category_id' => $category->id,
'language' => 'de',
'title' => 'Beide Portale sind nicht erlaubt',
'text' => 'Inhalt',
])->assertUnprocessable()->assertJsonValidationErrors(['portal']);
});
test('v1 update keeps the portal unless an explicit portal parameter is sent', function () {
/** @var TestCase $this */
$user = User::factory()->create();
$company = Company::factory()->businessportal24()->create();
$user->companies()->attach($company->id, ['role' => 'owner']);
$pressRelease = PressRelease::factory()
->forPortal(Portal::Businessportal24)
->create(['user_id' => $user->id, 'company_id' => $company->id]);
Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']);
// Update über die "falsche" Domain verschiebt die PM nicht …
config()->set('app.theme', 'presseecho');
$this->patchJson("/api/v1/press-releases/{$pressRelease->id}", [
'title' => 'Titel bleibt im Portal',
])->assertOk()->assertJsonPath('data.portal', 'businessportal24');
// … ein expliziter Parameter schon.
$this->patchJson("/api/v1/press-releases/{$pressRelease->id}", [
'portal' => 'presseecho',
])->assertOk()->assertJsonPath('data.portal', 'presseecho');
});

View file

@ -125,6 +125,10 @@ test('the full connektar flow works like the old api with immediate publishing',
->assertJsonStructure(['id', 'title', 'text', 'teaser_end', 'language', 'category_id', 'view_url', 'company' => ['viewUrl', 'name']]) ->assertJsonStructure(['id', 'title', 'text', 'teaser_end', 'language', 'category_id', 'view_url', 'company' => ['viewUrl', 'name']])
->assertJsonPath('company.name', 'Muster Energie GmbH'); ->assertJsonPath('company.name', 'Muster Energie GmbH');
// Aufruf-Zähler: der Request protokolliert nur das Ereignis, verbucht
// wird es gebündelt durch den Scheduler.
$this->artisan('press-releases:aggregate-views')->assertSuccessful();
$trackedRelease = PressRelease::withoutGlobalScopes()->findOrFail($pressRelease->id); $trackedRelease = PressRelease::withoutGlobalScopes()->findOrFail($pressRelease->id);
expect($trackedRelease->hits)->toBe(1) expect($trackedRelease->hits)->toBe(1)
@ -227,6 +231,7 @@ test('api submissions notify the press contact instead of the api submitter', fu
Mail::fake(); Mail::fake();
Queue::fake(); Queue::fake();
$user = parityApiUser(); $user = parityApiUser();
$user->forceFill(['name' => 'Sebastian Einbock'])->save();
$company = Company::factory()->create([ $company = Company::factory()->create([
'portal' => Portal::Businessportal24, 'portal' => Portal::Businessportal24,
@ -252,5 +257,47 @@ test('api submissions notify the press contact instead of the api submitter', fu
], ],
])->assertCreated(); ])->assertCreated();
Mail::assertQueued(PressReleasePublished::class, fn ($mail) => $mail->hasTo('presse@muster.de')); // Anrede folgt dem Empfänger (Pressekontakt), nicht dem API-Konto:
// gegen das gerenderte HTML geprüft, weil dort der Fehler auftrat.
Mail::assertQueued(PressReleasePublished::class, function ($mail) {
$html = $mail->render();
return $mail->hasTo('presse@muster.de')
&& str_contains($html, 'Hallo Max Mustermann,')
&& ! str_contains($html, 'Sebastian Einbock');
});
});
test('api submissions without press contact address the company by name', function () {
/** @var TestCase $this */
Mail::fake();
Queue::fake();
$user = parityApiUser();
$user->forceFill(['name' => 'Sebastian Einbock'])->save();
$company = Company::factory()->create([
'portal' => Portal::Businessportal24,
'is_active' => true,
'name' => 'Muster GmbH',
'email' => 'firma@muster.de',
]);
$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' => 'Mail geht an die Firma',
'text' => 'Inhalt für den Firmen-Fallback-Test.',
],
])->assertCreated();
Mail::assertQueued(PressReleasePublished::class, function ($mail) {
$html = $mail->render();
return $mail->hasTo('firma@muster.de')
&& str_contains($html, 'Hallo Muster GmbH,')
&& ! str_contains($html, 'Sebastian Einbock');
});
}); });

View file

@ -0,0 +1,154 @@
<?php
use App\Enums\Portal;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\User;
use Tests\TestCase;
/**
* Tests zum Reparatur-Command für den API-Portal-Bug (13.08.2026):
* Duplikat-Paare (gleicher User/Titel, gleiches Portal, per API kurz
* nacheinander eingereicht) — die zweite Kopie wandert samt exklusiver
* Firma ins andere Portal, Slugs werden dort suffixfrei neu vergeben.
*/
function apiDuplicatePair(User $user, string $title, string $slugBase): array
{
$createdAt = now()->subHour();
$pair = [];
foreach ([0, 1] as $index) {
$company = Company::factory()->businessportal24()->create([
'owner_user_id' => $user->id,
'name' => 'Firma '.$title,
'slug' => $index === 0 ? 'firma-'.$slugBase : 'firma-'.$slugBase.'-2',
]);
$pressRelease = PressRelease::factory()
->forPortal(Portal::Businessportal24)
->published()
->create([
'user_id' => $user->id,
'company_id' => $company->id,
'title' => $title,
'slug' => $index === 0 ? $slugBase : $slugBase.'-2',
'created_at' => $createdAt->copy()->addSeconds($index * 20),
]);
$pressRelease->statusLogs()->create([
'from_status' => 'draft',
'to_status' => 'review',
'source' => 'api',
'created_at' => $pressRelease->created_at,
]);
$pair[] = $pressRelease;
}
return $pair;
}
test('moves the second api duplicate with its company to the other portal', function () {
/** @var TestCase $this */
$user = User::factory()->create(['portal' => Portal::Businessportal24]);
[$original, $duplicate] = apiDuplicatePair($user, 'Doppelt gesendete Meldung', 'doppelt-gesendete-meldung');
$this->artisan('press-releases:fix-api-portal-duplicates')
->assertSuccessful();
$original->refresh();
$duplicate->refresh();
expect($original->portal)->toBe(Portal::Businessportal24)
->and($original->slug)->toBe('doppelt-gesendete-meldung')
->and($duplicate->portal)->toBe(Portal::Presseecho)
->and($duplicate->slug)->toBe('doppelt-gesendete-meldung');
expect($original->company()->withoutGlobalScopes()->first()->portal)->toBe(Portal::Businessportal24)
->and($duplicate->company()->withoutGlobalScopes()->first()->portal)->toBe(Portal::Presseecho)
->and($duplicate->company()->withoutGlobalScopes()->first()->slug)->toBe('firma-doppelt-gesendete-meldung');
});
test('dry run reports but changes nothing', function () {
/** @var TestCase $this */
$user = User::factory()->create(['portal' => Portal::Businessportal24]);
[, $duplicate] = apiDuplicatePair($user, 'Trockenlauf Meldung', 'trockenlauf-meldung');
$this->artisan('press-releases:fix-api-portal-duplicates', ['--dry-run' => true])
->assertSuccessful();
expect($duplicate->refresh()->portal)->toBe(Portal::Businessportal24)
->and($duplicate->slug)->toBe('trockenlauf-meldung-2');
});
test('ignores web double submits without api status log', function () {
/** @var TestCase $this */
$user = User::factory()->create(['portal' => Portal::Presseecho]);
$company = Company::factory()->presseecho()->create(['owner_user_id' => $user->id]);
$pressReleases = collect([0, 1])->map(fn (int $index) => PressRelease::factory()
->forPortal(Portal::Presseecho)
->create([
'user_id' => $user->id,
'company_id' => $company->id,
'title' => 'Doppelklick im Formular',
'slug' => $index === 0 ? 'doppelklick-im-formular' : 'doppelklick-im-formular-2',
'created_at' => now()->subMinutes(10)->addSeconds($index * 3),
]));
$this->artisan('press-releases:fix-api-portal-duplicates')
->assertSuccessful();
expect($pressReleases[1]->refresh()->portal)->toBe(Portal::Presseecho)
->and($pressReleases[1]->slug)->toBe('doppelklick-im-formular-2');
});
test('ignores legacy synced press releases and pairs outside the time window', function () {
/** @var TestCase $this */
$user = User::factory()->create(['portal' => Portal::Businessportal24]);
[, $duplicate] = apiDuplicatePair($user, 'Legacy Sync Meldung', 'legacy-sync-meldung');
$duplicate->forceFill(['legacy_id' => 987654, 'legacy_portal' => 'businessportal24'])->save();
[, $lateDuplicate] = apiDuplicatePair($user, 'Späte zweite Meldung', 'spate-zweite-meldung');
$lateDuplicate->forceFill(['created_at' => now()->subHour()->addHours(3)])->save();
$this->artisan('press-releases:fix-api-portal-duplicates')
->assertSuccessful();
expect($duplicate->refresh()->portal)->toBe(Portal::Businessportal24)
->and($lateDuplicate->refresh()->portal)->toBe(Portal::Businessportal24);
});
test('shared companies stay untouched when the duplicate moves', function () {
/** @var TestCase $this */
$user = User::factory()->create(['portal' => Portal::Businessportal24]);
$sharedCompany = Company::factory()->businessportal24()->create(['owner_user_id' => $user->id]);
$pressReleases = collect([0, 1])->map(function (int $index) use ($user, $sharedCompany) {
$pressRelease = PressRelease::factory()
->forPortal(Portal::Businessportal24)
->create([
'user_id' => $user->id,
'company_id' => $sharedCompany->id,
'title' => 'Meldung mit Stammfirma',
'slug' => $index === 0 ? 'meldung-mit-stammfirma' : 'meldung-mit-stammfirma-2',
'created_at' => now()->subHour()->addSeconds($index * 20),
]);
$pressRelease->statusLogs()->create([
'from_status' => 'draft',
'to_status' => 'review',
'source' => 'api',
'created_at' => $pressRelease->created_at,
]);
return $pressRelease;
});
$this->artisan('press-releases:fix-api-portal-duplicates')
->assertSuccessful();
expect($pressReleases[1]->refresh()->portal)->toBe(Portal::Presseecho)
->and($sharedCompany->refresh()->portal)->toBe(Portal::Businessportal24);
});