contactsFor($email); $companies = $this->companiesFor($email); $existing = User::query()->whereRaw('LOWER(email) = ?', [$email])->first(); 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); 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 */ 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 */ 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 $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::query()->whereRaw('LOWER(email) = ?', [$email])->first() ?? $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 ?? ''); } $user = User::create([ 'name' => $name !== '' ? $name : $email, 'email' => $email, 'registration_type' => RegistrationType::Company->value, 'is_active' => true, ]); $user->forceFill(['email_verified_at' => now()])->save(); $this->roleSync->assignRoleAndSyncPermissions($user, 'customer'); return $user; } /** * Versendet einen regulären Login-Magic-Link an den Account. */ private function sendLoginLink(User $user, ?string $ip): void { $generated = $this->magicLinks->createLoginLink($user, $ip); Mail::to($user->email)->send(new MagicLoginLink( user: $user, loginUrl: route('magic-links.consume', ['token' => $generated['plain_token']]), expiresAt: $generated['expires_at']->format('d.m.Y H:i'), )); } /** * 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']); } 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()); } } }