presseportale/app/Console/Commands/FixApiPortalDuplicates.php
Kevin Adametz 48d401e68f 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>
2026-09-01 11:06:58 +00:00

208 lines
7.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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;
}
}