presseportale/app/Services/Company/NewsroomClaimService.php
Kevin Adametz 0c1c072487 newsroom: Verwaltungszugang „Diesen Newsroom verwalten" (Roadmap 1b.2)
Neue Seite /newsroom/{slug}/verwalten auf den Web-Portalen: schickt den
Anmeldelink an die hinterlegte Firmen-E-Mail (sonst Pressekontakte), zeigt
sie nur maskiert, und funktioniert auch für Firmen ohne veröffentlichte
Meldung. Konto, Zuordnung und Login laufen durch ContactAccessService —
neu dort: Herkunft (profile_claim auf dem Token) und Ziel-Firma, auf deren
Kundenseite der Login landet. 410-entfernte bleiben Tombstone, aufgegangene
Slugs führen zum Überlebenden (MergedNewsroomLookup, geteilt mit der
Newsroom-Seite). 3 Links je Newsroom und Tag, 15 je IP und Stunde.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 10:42:20 +00:00

107 lines
3.4 KiB
PHP

<?php
namespace App\Services\Company;
use App\Enums\ConversionSource;
use App\Models\Company;
use App\Scopes\PortalScope;
use App\Services\Auth\ContactAccessService;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
/**
* „Diesen Newsroom verwalten" (Roadmap 1b.2): Verwaltungszugang zu einer
* Firma über die hinterlegte Firmen-E-Mail, unabhängig davon, ob der
* Newsroom öffentlich ist.
*
* Der Unterschied zur allgemeinen Anmeldung per E-Mail-Link: Der Besucher
* gibt KEINE Adresse ein. Der Link geht an das, was an der Firma hinterlegt
* ist — die Firmen-E-Mail, sonst die Pressekontakte. Wer das Postfach hat,
* verwaltet die Firma; wer es nicht hat, erfährt nur eine maskierte Adresse.
* Kein neues Zugangssystem: Konto, Zuordnung und Login laufen durch
* `ContactAccessService`, genau wie bei der Anmeldung über pressekonto.
*
* Die Herkunft `profile_claim` steht auf dem Token — so misst die
* Konversionsleiter, wie viele Newsrooms über diesen Weg beansprucht werden.
*/
class NewsroomClaimService
{
public function __construct(private readonly ContactAccessService $contactAccess) {}
/**
* Firma zu einem Slug auf diesen Portalen — auch ohne veröffentlichte
* Meldung, auch deaktiviert. Nicht aber 410-entfernt oder gelöscht: Die
* Firma existiert als Datensatz, aber nicht mehr als Newsroom.
*
* @param list<string> $portalValues
*/
public function findClaimable(array $portalValues, string $slug): ?Company
{
return Company::query()
->withoutGlobalScope(PortalScope::class)
->whereIn('portal', $portalValues)
->where('slug', $slug)
->whereNull('gone_at')
->first();
}
/**
* Empfänger des Anmeldelinks: die Firmen-E-Mail, sonst die Adressen der
* Pressekontakte. Bewusst nicht beides — ein Klick eines Fremden soll
* nicht fünf Postfächer treffen.
*
* @return Collection<int, string>
*/
public function recipients(Company $company): Collection
{
$companyEmail = Company::emailKey($company->email);
if ($companyEmail !== null) {
return collect([$companyEmail]);
}
$emails = [];
foreach ($company->contacts()->withoutGlobalScope(PortalScope::class)->pluck('email') as $email) {
$key = Company::emailKey($email);
if ($key !== null) {
$emails[$key] = $key;
}
}
return collect(array_values($emails));
}
/**
* Verschickt die Anmeldelinks. Gibt zurück, an wie viele Adressen
* tatsächlich etwas ging — deaktivierte Konten bekommen nichts.
*/
public function request(Company $company, ?string $ip): int
{
$sent = 0;
foreach ($this->recipients($company) as $email) {
if ($this->contactAccess->requestMagicAccess($email, $ip, ConversionSource::ProfileClaim, $company->getKey())) {
$sent++;
}
}
return $sent;
}
/**
* @return Collection<int, string>
*/
public function maskedRecipients(Company $company): Collection
{
return $this->recipients($company)->map(fn (string $email): string => self::mask($email));
}
public static function mask(string $email): string
{
[$local, $domain] = array_pad(explode('@', $email, 2), 2, '');
return Str::substr($local, 0, 1).'***@'.$domain;
}
}