- 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>
261 lines
9.9 KiB
PHP
261 lines
9.9 KiB
PHP
<?php
|
||
|
||
namespace App\Services\Auth;
|
||
|
||
use App\Enums\ConversionSource;
|
||
use App\Mail\MagicLoginLink;
|
||
use App\Models\Company;
|
||
use App\Models\Contact;
|
||
use App\Models\User;
|
||
use App\Scopes\PortalScope;
|
||
use Illuminate\Support\Collection;
|
||
use Illuminate\Support\Facades\Mail;
|
||
|
||
/**
|
||
* Magic-Link-Zugang für Firmen & Pressekontakte (WS-2).
|
||
*
|
||
* Überall, wo eine E-Mail hinterlegt ist – an einer Firma (`companies.email`)
|
||
* oder an einem Pressekontakt (`contacts.email`) –, kann sich diese E-Mail per
|
||
* Magic-Link anmelden. Es entsteht (lazy) GENAU EIN Account je E-Mail, dem ALLE
|
||
* Firmen und Pressekontakte zugeordnet werden, die diese E-Mail tragen – über
|
||
* alle Portale hinweg. Über den Firmen-Scope (PressReleasePolicy) verwaltet der
|
||
* Account danach die Pressemitteilungen (inkl. Terminierung/Kalender) und
|
||
* Kontakte seiner Firmen. Eintrittsweg in dieselbe Verwaltung wie der reguläre
|
||
* Login – kein paralleles System.
|
||
*/
|
||
class ContactAccessService
|
||
{
|
||
public function __construct(
|
||
private readonly MagicLinkGenerator $magicLinks,
|
||
private readonly CustomerAccountProvisioner $accountProvisioner,
|
||
) {}
|
||
|
||
/**
|
||
* Einheitlicher „Anmeldung per E-Mail-Link"-Eintritt für die zusammengeführte
|
||
* Seite: deckt bestehende (aktive) Konten, hinterlegte Firmen-E-Mails UND
|
||
* Pressekontakte ab. Enumeration-sicher – der Aufrufer zeigt unabhängig vom
|
||
* Ergebnis dieselbe neutrale Meldung.
|
||
*
|
||
* - Bestehendes aktives Konto → Login-Magic-Link (auch ohne Hinterlegung).
|
||
* - Hinterlegte Firma/Kontakt(e) ohne Konto → lazy Account + Scope + Link.
|
||
* - Beides zugleich → Konto wiederverwenden, Scope ergänzen, Link.
|
||
* - Mehrfach hinterlegt → ein Konto, ALLE Firmen/Kontakte zugeordnet.
|
||
* - Deaktiviertes Bestandskonto / unbekannte E-Mail → keine Aktion.
|
||
*/
|
||
public function requestMagicAccess(string $email, ?string $ip = null, ?ConversionSource $source = null, ?int $landingCompanyId = null, ?int $landingPressReleaseId = null): bool
|
||
{
|
||
$email = mb_strtolower(trim($email));
|
||
$contacts = $this->contactsFor($email);
|
||
$companies = $this->companiesFor($email);
|
||
$existing = User::findByEmail($email);
|
||
|
||
if (! $existing && $contacts->isEmpty() && $companies->isEmpty()) {
|
||
return false;
|
||
}
|
||
|
||
$user = $existing ?? $this->createAccount($email, $contacts, $companies);
|
||
|
||
// Deaktivierte Bestands-Accounts nicht verändern und nicht einloggen.
|
||
if (! $user->is_active) {
|
||
return false;
|
||
}
|
||
|
||
$this->linkMemberships($user, $this->companyIdsFrom($contacts, $companies), $contacts);
|
||
$this->sendLoginLink($user, $ip, $source, $landingCompanyId, $landingPressReleaseId);
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Verarbeitet eine reine Pressekontakt-Zugangsanfrage (nur hinterlegte
|
||
* Kontakte). Enumeration-sicher; gibt zurück, ob ein Link verschickt wurde
|
||
* (Tests/Logs). Bleibt für gezielte Aufrufe erhalten.
|
||
*/
|
||
public function requestAccess(string $email, ?string $ip = null): bool
|
||
{
|
||
$email = mb_strtolower(trim($email));
|
||
$contacts = $this->contactsFor($email);
|
||
|
||
if ($contacts->isEmpty()) {
|
||
return false;
|
||
}
|
||
|
||
$user = $this->resolveUser($email, $contacts, collect());
|
||
|
||
// Deaktivierte Bestands-Accounts nicht verändern: eine unauthentifizierte
|
||
// Anfrage darf weder Firmenzuordnungen vorbereiten noch einen Link
|
||
// auslösen. Frisch lazy angelegte Accounts sind aktiv und laufen weiter.
|
||
if (! $user->is_active) {
|
||
return false;
|
||
}
|
||
|
||
$this->linkMemberships($user, $this->companyIdsFrom($contacts, collect()), $contacts);
|
||
$this->sendLoginLink($user, $ip);
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Synchronisiert die Firmen-/Kontakt-Zuordnungen eines bereits
|
||
* angemeldeten Users anhand seiner E-Mail. Wird bei JEDEM Login aufgerufen
|
||
* (Magic-Link, Passwort, Google) – so greifen auch nachträglich (z. B. per
|
||
* API) hinzugekommene Firmen/Kontakte mit derselben E-Mail. Rein additiv,
|
||
* legt kein Konto an und verschickt keine Mail.
|
||
*/
|
||
public function syncMembershipsForEmail(User $user): void
|
||
{
|
||
$email = mb_strtolower(trim((string) $user->email));
|
||
|
||
if ($email === '') {
|
||
return;
|
||
}
|
||
|
||
$contacts = $this->contactsFor($email);
|
||
$companies = $this->companiesFor($email);
|
||
|
||
if ($contacts->isEmpty() && $companies->isEmpty()) {
|
||
return;
|
||
}
|
||
|
||
$this->linkMemberships($user, $this->companyIdsFrom($contacts, $companies), $contacts);
|
||
}
|
||
|
||
/**
|
||
* Hinterlegte Pressekontakte (mit Firma) zu einer E-Mail – case-insensitive,
|
||
* portalübergreifend (kein PortalScope).
|
||
*
|
||
* @return Collection<int, Contact>
|
||
*/
|
||
private function contactsFor(string $email): Collection
|
||
{
|
||
return Contact::withoutGlobalScope(PortalScope::class)
|
||
->whereRaw('LOWER(email) = ?', [$email])
|
||
->whereNotNull('company_id')
|
||
->get();
|
||
}
|
||
|
||
/**
|
||
* Firmen, die diese E-Mail selbst hinterlegt haben – case-insensitive,
|
||
* portalübergreifend (kein PortalScope).
|
||
*
|
||
* @return Collection<int, Company>
|
||
*/
|
||
private function companiesFor(string $email): Collection
|
||
{
|
||
return Company::withoutGlobalScope(PortalScope::class)
|
||
->whereRaw('LOWER(email) = ?', [$email])
|
||
->get();
|
||
}
|
||
|
||
/**
|
||
* Alle zuzuordnenden Firmen-IDs: direkt an Firmen hinterlegt ODER über
|
||
* Pressekontakte verknüpft.
|
||
*
|
||
* @param Collection<int, Contact> $contacts
|
||
* @param Collection<int, Company> $companies
|
||
* @return Collection<int, int>
|
||
*/
|
||
private function companyIdsFrom(Collection $contacts, Collection $companies): Collection
|
||
{
|
||
return $companies->pluck('id')
|
||
->merge($contacts->pluck('company_id'))
|
||
->filter()
|
||
->unique()
|
||
->values();
|
||
}
|
||
|
||
/**
|
||
* Bestehenden Account per E-Mail wiederverwenden (keine Dubletten) oder lazy
|
||
* einen neuen anlegen.
|
||
*
|
||
* @param Collection<int, Contact> $contacts
|
||
* @param Collection<int, Company> $companies
|
||
*/
|
||
private function resolveUser(string $email, Collection $contacts, Collection $companies): User
|
||
{
|
||
return User::findByEmail($email)
|
||
?? $this->createAccount($email, $contacts, $companies);
|
||
}
|
||
|
||
/**
|
||
* Legt für eine hinterlegte Firma/Kontakt lazy einen Account an. Verifizierung
|
||
* gilt über den Magic-Link-Kanal als erfüllt (Entscheidung 15.06.) – daher
|
||
* direkt aktiv + customer. Name: Kontaktname, sonst Firmenname, sonst E-Mail.
|
||
*
|
||
* @param Collection<int, Contact> $contacts
|
||
* @param Collection<int, Company> $companies
|
||
*/
|
||
private function createAccount(string $email, Collection $contacts, Collection $companies): User
|
||
{
|
||
$contact = $contacts->first();
|
||
$name = trim(($contact->first_name ?? '').' '.($contact->last_name ?? ''));
|
||
|
||
if ($name === '') {
|
||
$name = (string) ($companies->first()->name ?? '');
|
||
}
|
||
|
||
return $this->accountProvisioner->provisionVerifiedCustomer($email, $name);
|
||
}
|
||
|
||
/**
|
||
* Versendet einen regulären Login-Magic-Link an den Account.
|
||
*/
|
||
private function sendLoginLink(User $user, ?string $ip, ?ConversionSource $source = null, ?int $landingCompanyId = null, ?int $landingPressReleaseId = null): void
|
||
{
|
||
// Landung nach dem Login (Roadmap 1b.2/1c.2): die Firma, um die es
|
||
// ging — und, wenn der Weg über eine Veröffentlichungsmail kam, die
|
||
// konkrete Meldung mit ihren Aufrufen.
|
||
$generated = $this->magicLinks->createLoginLink(
|
||
$user,
|
||
$ip,
|
||
source: $source,
|
||
payload: array_filter([
|
||
'company_id' => $landingCompanyId,
|
||
'press_release_id' => $landingPressReleaseId,
|
||
]),
|
||
);
|
||
|
||
Mail::to($user->email)->queue((new MagicLoginLink(
|
||
user: $user,
|
||
loginUrl: route('magic-links.consume', ['token' => $generated['plain_token']]),
|
||
expiresAt: $generated['expires_at']->format('d.m.Y H:i'),
|
||
))->afterCommit());
|
||
}
|
||
|
||
/**
|
||
* Ordnet den User allen übergebenen Firmen als „Verantwortlicher"
|
||
* (`responsible`) zu und verknüpft ihn mit den Kontakt-Datensätzen. Wer seine
|
||
* E-Mail an einer Firma/einem Pressekontakt hinterlegt hat, verwaltet die
|
||
* gesamte Firma (Stammdaten, Kontakte, Pressemitteilungen) – `responsible`
|
||
* gewährt den dafür nötigen Schreibzugriff (siehe CompanyPolicy::update()).
|
||
*
|
||
* Owner (`owner_user_id`) bleiben unangetastet; ein bestehender reiner
|
||
* Lese-Pivot (`member`) wird auf `responsible` hochgestuft, `owner`/
|
||
* `responsible` bleiben erhalten.
|
||
*
|
||
* @param Collection<int, int> $companyIds
|
||
* @param Collection<int, Contact> $contacts
|
||
*/
|
||
private function linkMemberships(User $user, Collection $companyIds, Collection $contacts): void
|
||
{
|
||
foreach ($companyIds as $companyId) {
|
||
// Owner via owner_user_id → bereits voller Zugriff.
|
||
if ($user->ownedCompanies()->withoutGlobalScope(PortalScope::class)->whereKey($companyId)->exists()) {
|
||
continue;
|
||
}
|
||
|
||
$linked = $user->companies()->withoutGlobalScope(PortalScope::class)->whereKey($companyId)->first();
|
||
|
||
if ($linked === null) {
|
||
$user->companies()->attach($companyId, ['role' => 'responsible']);
|
||
} elseif ($linked->pivot->role === 'member') {
|
||
// Nur den Read-only-Default hochstufen; owner/responsible bleiben.
|
||
$user->companies()->updateExistingPivot($companyId, ['role' => 'responsible']);
|
||
}
|
||
}
|
||
|
||
if ($contacts->isNotEmpty()) {
|
||
$user->contacts()->syncWithoutDetaching($contacts->pluck('id')->all());
|
||
}
|
||
}
|
||
}
|