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. * * Grundlage ist ausschließlich eine NACHGEWIESENE Adresse (Audit F01): ein * Konto, das seine E-Mail gerade auf eine fremde Firmen-Adresse gewechselt * hat, ist unverifiziert und erhält nichts – der Login-Event feuert auch * vor der Verifizierungs-Weiche. Deaktivierte Konten ebenfalls nicht. * * Gegenrichtung (Audit F10/U01, E7): automatisch vergebene Zuordnungen, * deren E-Mail-Grundlage weggefallen ist (Adresse an Firma oder Kontakt * getauscht, Kontakt gelöscht, eigene Adresse geändert), werden entfernt – * `member` wäre keine Leserolle, sondern die Teamrolle mit Schreibrecht * auf alle Firmen-PMs. Eigene PMs bleiben über die Autorenschaft. Eine * automatisch HOCHGESTUFTE manuelle Mitgliedschaft (`auto-upgrade`) fällt * auf `member` zurück. Manuell oder aus dem Altbestand vergebene Rechte * (`granted_via = manual`) bleiben, ebenso Mitgliedschaften aus der * API-Zuordnung (`granted_via = api`, Audit R-01) — ihre Grundlage ist * die Freischaltung des Einlieferers, nicht die E-Mail. * * Neben Login und E-Mail-Bestätigung ruft auch der * CompanyMembershipObserver diese Methode auf, sobald sich Kontakt- oder * Firmenadressen bzw. Kontaktzuordnungen ändern — der Entzug greift damit * in der laufenden Session (Sicherheitsaudit 07.10.2026, S-03). */ public function syncMembershipsForEmail(User $user): void { $email = mb_strtolower(trim((string) $user->email)); // Ohne nachgewiesene Adresse gibt es keine Grundlage für automatische // Rechte — vorhandene fallen weg (Sicherheitsaudit 07.10.2026, S-03: // der Abgleich läuft jetzt auch außerhalb des Logins). if (! $user->is_active || ! $user->hasVerifiedEmail() || $email === '') { $this->revokeStaleAutomaticGrants($user, collect()); return; } $contacts = $this->contactsFor($email); $companies = $this->companiesFor($email); $companyIds = $this->companyIdsFrom($contacts, $companies); $this->revokeStaleAutomaticGrants($user, $companyIds); if ($companyIds->isEmpty()) { return; } $this->linkMemberships($user, $companyIds, $contacts); } /** * Entfernt automatisch vergebene `responsible`-Pivots ohne aktuellen * E-Mail-Treffer (E7). Eine hochgestufte manuelle Mitgliedschaft geht auf * ihren vorherigen Stand `member` zurück und gilt wieder als manuell. * * @param Collection $currentCompanyIds */ private function revokeStaleAutomaticGrants(User $user, Collection $currentCompanyIds): void { $stale = DB::table('company_user') ->where('user_id', $user->id) ->where('role', 'responsible') ->whereNotIn('company_id', $currentCompanyIds->all()); (clone $stale)->where('granted_via', 'auto')->delete(); (clone $stale)->where('granted_via', 'auto-upgrade') ->update(['role' => 'member', 'granted_via' => 'manual', 'updated_at' => now()]); } /** * Hinterlegte Pressekontakte (mit Firma) zu einer E-Mail – case-insensitive * über den Index (EmailQuery), portalübergreifend (kein PortalScope). * * @return Collection */ private function contactsFor(string $email): Collection { return EmailQuery::whereEmail(Contact::withoutGlobalScope(PortalScope::class), $email) ->whereNotNull('company_id') ->get(); } /** * Firmen, die diese E-Mail selbst hinterlegt haben – case-insensitive, * portalübergreifend (kein PortalScope). * * @return Collection */ private function companiesFor(string $email): Collection { return EmailQuery::whereEmail(Company::withoutGlobalScope(PortalScope::class), $email) ->get(); } /** * Alle zuzuordnenden Firmen-IDs: direkt an Firmen hinterlegt ODER über * Pressekontakte verknüpft. * * @param Collection $contacts * @param Collection $companies * @return Collection */ 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 $contacts * @param Collection $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 $contacts * @param Collection $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 $companyIds * @param Collection $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', 'granted_via' => 'auto']); } elseif ($linked->pivot->role === 'member') { // Nur den Team-Default hochstufen; owner/responsible bleiben. // `auto-upgrade` merkt sich, dass darunter eine manuelle // Mitgliedschaft liegt – die Rücknahme führt dorthin zurück // statt die Zeile zu löschen. $user->companies()->updateExistingPivot($companyId, ['role' => 'responsible', 'granted_via' => 'auto-upgrade']); } } if ($contacts->isNotEmpty()) { $user->contacts()->syncWithoutDetaching($contacts->pluck('id')->all()); } } }