api: neue Firmen dem bestehenden Newsroom zuordnen (Roadmap 1a.4)

connektar legt je Meldung eine Firma an. ApiNewsroomAssignment prüft vor
dem Anlegen, ob unter derselben Firmen-E-Mail schon dieselbe Firma steht
(Namensregel der Klassifizierung), und gibt dann deren ID zurück — der
Alt-Vertrag (201 + Location) bleibt. Nie-veröffentlicht-Adressen werden nie
zugeordnet, abweichende Namen bekommen wie bisher eine neue Firma. Liegt
die Firma auf dem anderen Portal, wird sie both; bei fremd belegtem Slug
bleibt es beim alten Verhalten.

LegacyPressReleaseController: both ist kein Ziel-Portal für eine Meldung —
dann entscheidet das Portal des API-Nutzers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-15 10:26:13 +00:00
parent 4303266841
commit 8dad652d67
8 changed files with 367 additions and 34 deletions

View file

@ -0,0 +1,107 @@
<?php
namespace App\Services\Company;
use App\Enums\ActivityType;
use App\Enums\CompanyAddressClass;
use App\Enums\Portal;
use App\Models\Company;
use App\Models\CompanyEmailGroup;
use App\Models\User;
use App\Scopes\PortalScope;
use App\Services\ActivityLog;
/**
* Laufende Zuordnung neuer API-Firmen zu einem bestehenden Newsroom
* (Roadmap 1a.4).
*
* Der Alt-Vertrag der API legt vor jeder Meldung eine Firma an — connektar
* schickt je Meldung `POST /service/users/me/companies`. Ohne diese Klasse
* entsteht so je Meldung ein neuer Newsroom, und die Zusammenführung 1a.3
* müsste nachts nachlaufen. Hier wird stattdessen VOR dem Anlegen geprüft,
* ob unter derselben Firmen-E-Mail schon eine Firma mit demselben (oder
* normalisiert demselben) Namen steht. Dann bekommt der Aufrufer deren ID
* zurück — der Vertrag (201 + Location) bleibt, nur die Firma ist die alte.
*
* Dieselben Regeln wie die Klassifizierung (1a.2): Schlüssel ist die
* Firmen-E-Mail, „derselbe Name" ist `CompanyAddressClassifier::namesMatch()`.
* Unter einer Sammeladresse (Agentur) wird nur die Firma mit passendem Namen
* wiederverwendet — ein neuer Kunde der Agentur bekommt wie bisher eine neue
* Firma. Unter einer nie-veröffentlicht-Adresse wird nie zugeordnet.
*/
class ApiNewsroomAssignment
{
public function __construct(
private readonly CompanyAddressClassifier $classifier,
private readonly ActivityLog $activityLog,
) {}
/**
* Bestehende Firma für diese Anfrage — oder null, dann legt der Aufrufer
* wie bisher eine neue an.
*
* Nebenwirkungen bei Treffer: Der API-Nutzer wird als Mitglied an die
* Firma gehängt (sonst dürfte er dort keine Meldung anlegen), und liegt
* die Firma auf dem anderen Portal, wird sie `both` — ihre Meldungen
* behalten ihr Portal, die neue bekommt das angefragte.
*/
public function assign(User $user, ?string $email, string $name, Portal $portal): ?Company
{
$key = Company::emailKey($email);
if ($key === null) {
return null;
}
$group = CompanyEmailGroup::forEmail($key);
if ($group?->effectiveClassification() === CompanyAddressClass::NeverPublished) {
return null;
}
// Schlüssel UND Rohwert abfragen: Der Index auf `email` greift so auf
// jeder Datenbank, und die Kleinschreibung entscheidet der Filter
// danach — nicht die Kollation.
$matches = Company::query()
->withoutGlobalScope(PortalScope::class)
->whereIn('email', array_values(array_unique([$key, trim((string) $email)])))
->whereNull('gone_at')
->orderBy('id')
->get()
->filter(fn (Company $company): bool => Company::emailKey($company->email) === $key
&& $this->classifier->namesMatch($company->name, $name));
if ($matches->isEmpty()) {
return null;
}
// Mehrere Treffer gibt es nach 1a.3 nur noch als Slug-Kollision oder
// Handentscheidung. Dann die, die auf dem angefragten Portal schon
// erreichbar ist, sonst die älteste.
$company = $matches->first(fn (Company $company): bool => $company->portal === $portal || $company->portal === Portal::Both)
?? $matches->first();
if ($portal !== Portal::Both && $company->portal !== Portal::Both && $company->portal !== $portal) {
if ($company->slugTakenOnOtherPortals()) {
return null;
}
$company->forceFill(['portal' => Portal::Both->value])->save();
}
if (! $company->users()->whereKey($user->id)->exists() && $company->owner_user_id !== $user->id) {
$company->users()->attach($user->id, ['role' => 'member']);
}
$this->activityLog->record(
ActivityType::Correction,
sprintf('API-Firma „%s“ dem bestehenden Newsroom #%d „%s“ zugeordnet (%s)', $name, $company->getKey(), $company->name, $key),
$user,
$company,
['api_newsroom_assignment' => true, 'requested_name' => $name, 'portal' => $portal->value],
actorLabel: 'api',
);
return $company;
}
}

View file

@ -237,6 +237,16 @@ class CompanyAddressClassifier
return count($representatives);
}
/**
* Sind zwei Firmennamen dieselbe Firma? Dieselbe Frage, die die
* Klassifizierung je Adresse stellt — hier für die laufende Zuordnung
* (1a.4) einer einzelnen neuen Firma zu einer bestehenden.
*/
public function namesMatch(string $a, string $b): bool
{
return $this->similar($this->normalise($a), $this->normalise($b));
}
private function similar(string $a, string $b): bool
{
if ($a === $b) {

View file

@ -96,7 +96,7 @@ class CompanyMergeService
$targetPortal = $this->targetPortal($survivor, $portals);
if ($targetPortal === Portal::Both && $survivor->portal !== Portal::Both
&& $this->slugTakenElsewhere($survivor, $candidates)) {
&& $survivor->slugTakenOnOtherPortals($candidates->modelKeys())) {
$blocked[] = $survivor->slug;
continue;
@ -294,33 +294,6 @@ class CompanyMergeService
return $survivor->portal;
}
/**
* Trägt eine fremde, lebende Firma auf einem anderen Portal denselben
* Slug? Dann fände die öffentliche Auflösung (`portal IN (eigenes, both)`)
* nach dem Wechsel auf `both` zwei Firmen. Die eigenen Gruppenmitglieder
* zählen nicht — die sind danach soft-deleted.
*
* @param Collection<int, Company> $group
*/
private function slugTakenElsewhere(Company $survivor, Collection $group): bool
{
// Die anderen Portale ausdrücklich aufzählen statt `portal != ?`:
// Nur so greift der Unique-Index (portal, slug) — der Unterschied ist
// 24 ms gegen 0 ms je Adresse, bei vierzehntausend Adressen sechs
// Minuten gegen eine Sekunde.
$otherPortals = array_values(array_diff(
array_map(fn (Portal $portal): string => $portal->value, Portal::cases()),
[$survivor->portal->value],
));
return Company::query()
->withoutGlobalScope(PortalScope::class)
->whereIn('portal', $otherPortals)
->where('slug', $survivor->slug)
->whereKeyNot($group->modelKeys())
->exists();
}
/**
* @param list<int> $companyIds
* @return Builder<PressRelease>