presseportale/app/Services/Company/NewsroomClaimService.php
Kevin Adametz 3ef1b84600 customer: Sichtbarkeit belegen - Aufrufe im Verlauf, Newsroom-Uebersicht (Roadmap 1c)
- VisibilityStats: Firmen-Kennzahlen ueber alle Meldungen, Aufrufe je
  Meldung (30 Tage), lueckenlose Tagesreihe (Tage vor der Zaehlung sind
  null, nicht 0), Sitemap-seit und Google-Suchlink (Indexierung Variante B).
- Firmenseite: alle Meldungen der Firma statt nur eigene (Beansprucher
  sahen eine leere Liste), Aufrufe-Spalten, echtes Statistik-Panel.
- Meldungsseite: Panel Sichtbarkeit mit SVG-Tagesbalken 30/90 Tage.
- Claim aus der Veroeffentlichungsmail landet auf der Meldung
  (payload press_release_id), Mail-Hinweis erweitert, ein Element (E4).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 11:53:09 +00:00

137 lines
4.5 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 App\Services\Mail\EmailSuppressionService;
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,
private readonly EmailSuppressionService $suppressions,
) {}
/**
* 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. Gesperrte Adressen (Bounce-Suppression,
* 1b.6) zählen nicht: Ein Newsroom, dessen einzige Adresse tot ist, ist
* nicht beanspruchbar — siehe isUnreachable().
*
* @return Collection<int, string>
*/
public function recipients(Company $company): Collection
{
$candidates = $this->candidateRecipients($company);
if ($candidates === []) {
return collect();
}
$suppressed = $this->suppressions->suppressedAmong($candidates);
return collect(array_values(array_filter($candidates, fn (string $email): bool => ! in_array($email, $suppressed, true))));
}
/**
* Es gäbe Adressen, aber alle sind gesperrt.
*/
public function isUnreachable(Company $company): bool
{
return $this->candidateRecipients($company) !== [] && $this->recipients($company)->isEmpty();
}
/**
* @return list<string>
*/
private function candidateRecipients(Company $company): array
{
$companyEmail = Company::emailKey($company->email);
if ($companyEmail !== null) {
return [$companyEmail];
}
$emails = [];
foreach ($company->contacts()->withoutGlobalScope(PortalScope::class)->pluck('email') as $email) {
$key = Company::emailKey($email);
if ($key !== null) {
$emails[$key] = $key;
}
}
return 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, ConversionSource $source = ConversionSource::ProfileClaim, ?int $landingPressReleaseId = null): int
{
$sent = 0;
foreach ($this->recipients($company) as $email) {
if ($this->contactAccess->requestMagicAccess($email, $ip, $source, $company->getKey(), $landingPressReleaseId)) {
$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;
}
}