create([ 'type' => $type, 'source' => $source, 'company_id' => $company?->getKey(), 'press_release_id' => $pressRelease?->getKey(), ]); } catch (\Throwable $e) { report($e); } } /** * Wie record(), aber höchstens einmal je Firma und Stufe. * * Für Stufen, deren Name eine Einmaligkeit behauptet — „Konto angelegt", * „Erste Buchung". Eine zweite Buchung ist keine erste, und ein Ereignis, * das bei jeder Zahlung mitschreibt, würde die Stufe unbrauchbar machen. * * Ohne Firmenbezug greift die Sperre nicht: Dann fehlt der Schlüssel, gegen * den geprüft würde. Solche Ereignisse landen ohne Prüfung im Log und * werden im Trichter ohnehin nur als Randnotiz geführt. */ public function recordOnce( ConversionEventType $type, ConversionSource $source, ?Company $company = null, ?PressRelease $pressRelease = null, ): void { try { if ($company !== null && $this->alreadyRecorded($type, $company)) { return; } } catch (\Throwable $e) { report($e); return; } $this->record($type, $source, $company, $pressRelease); } /** * Stufe 2: die Seite „Diesen Newsroom verwalten“ wurde geöffnet. * * Aus einer Mail (`src`) oder über den Hinweis auf der Newsroom- bzw. * Meldungsseite. Wie beim Mail-Klick zählen nur echte Aufrufe, und * dieselbe Adresse zählt je Firma höchstens einmal im Zeitfenster. */ public function recordManagePageOpened(Request $request, Company $company, ConversionSource $source, ?PressRelease $pressRelease = null): void { try { if (! $request->isMethod('GET') || $this->isAutomatedVisit($request->userAgent())) { return; } $key = 'manage-page:'.hash('sha256', (string) $request->ip()).':'.$company->getKey(); $decay = max(1, (int) config('conversion.mail_clicks.throttle_minutes', 30)) * 60; if (! RateLimiter::attempt($key, 1, static fn (): bool => true, $decay)) { return; } } catch (\Throwable $e) { report($e); return; } $this->record(ConversionEventType::ManagePageOpened, $source, $company, $pressRelease); } /** * Stufe 4: Magic Link eingelöst — mit der Firma und Meldung, die das * Token trägt („Diesen Newsroom verwalten“, Veröffentlichungsmail). * Ohne diesen Bezug landete jede Einlösung in „ohne Firma“, und die * Stufe zeigte bis 13.10.2026 immer null Firmen (Roadmap 6.16). */ public function recordMagicLinkConsumed(MagicLink $magicLink): void { try { $companyId = (int) ($magicLink->payload['company_id'] ?? 0); $pressReleaseId = (int) ($magicLink->payload['press_release_id'] ?? 0); $company = $companyId > 0 ? Company::query()->withoutGlobalScope(PortalScope::class)->find($companyId) : null; $pressRelease = $pressReleaseId > 0 ? PressRelease::query()->withoutGlobalScope(PortalScope::class)->find($pressReleaseId) : null; } catch (\Throwable $e) { report($e); $company = null; $pressRelease = null; } $this->record(ConversionEventType::MagicLinkConsumed, $this->sourceOf($magicLink), $company, $pressRelease); } /** * Mailserver-Scanner, Vorschau-Dienste und Crawler — kein Mensch, also * kein Schritt in der Leiter (Umsetzungsaudit 29.09.2026, R-08). */ public function isAutomatedVisit(?string $userAgent): bool { $userAgent = mb_strtolower(trim((string) $userAgent)); if ($userAgent === '' || CrawlerIdentifier::identify($userAgent) !== null) { return true; } foreach ((array) config('conversion.mail_clicks.scanner_agents', []) as $pattern) { if (str_contains($userAgent, mb_strtolower((string) $pattern))) { return true; } } return false; } /** * Herkunft eines Magic-Link-Tokens. * * Genau hier zahlt sich die Entscheidung aus, den Wert auf dem Token statt * in der Session zu führen: Der Domainwechsel zwischen Mail, Spoke und Hub * hat die Sitzung längst zerrissen, das Token trägt die Herkunft trotzdem * noch. */ public function sourceOf(MagicLink $magicLink): ConversionSource { return ConversionSource::parse($magicLink->source); } /** * Firma zu einer Meldung — scope-frei, weil die Schreibstellen auch aus * Jobs und Webhooks ohne passenden Portal-Host laufen. */ public function companyOf(?PressRelease $pressRelease): ?Company { if ($pressRelease?->company_id === null) { return null; } return Company::query() ->withoutGlobalScope(PortalScope::class) ->find($pressRelease->company_id); } /** * Stufe 7 der Konversionsleiter. * * Der Trichter läuft über die FIRMA, gezahlt wird aber vom Konto. Die * Brücke ist die Firma des Kontos — bevorzugt die eigene, sonst die erste * zugeordnete. Ohne Firma bleibt der Bezug leer; das Ereignis ist dann * für die Trichter-Auswertung wertlos, aber immer noch eine ehrliche Zeile * im Log. * * Die Herkunft ist wie bei Stufe 5 die des letzten eingelösten Magic * Links; bis 13.10.2026 stand hier immer `organic`, auch nach einem * Claim aus der Veröffentlichungsmail (Roadmap 6.16). */ public function recordFirstBooking(?User $user): void { if ($user === null) { return; } try { $company = $user->ownedCompanies()->withoutGlobalScope(PortalScope::class)->first() ?? $user->companies()->withoutGlobalScope(PortalScope::class)->first(); } catch (\Throwable $e) { report($e); return; } $this->recordOnce(ConversionEventType::FirstBooking, $this->lastMagicLinkSource($user), $company); } /** * Stufe 5 der Konversionsleiter: Newsroom gepflegt (Roadmap 1b.3). * * Einmal je Firma. Die Herkunft ist die des letzten eingelösten Magic * Links dieses Kontos — wer über „Diesen Newsroom verwalten" aus der Mail * kam und dann die Beschreibung schreibt, zählt für diese Mail. Ohne * eingelösten Link (Passwort-Login) bleibt es `organic`. */ public function recordNewsroomEdited(Company $company, ?User $user): void { $this->recordOnce(ConversionEventType::NewsroomEdited, $this->lastMagicLinkSource($user), $company); } private function lastMagicLinkSource(?User $user): ConversionSource { if ($user === null) { return ConversionSource::Organic; } try { $source = MagicLink::query() ->where('user_id', $user->getKey()) ->whereIn('purpose', MagicLink::LOGIN_PURPOSES) ->whereNotNull('consumed_at') ->latest('consumed_at') ->value('source'); } catch (\Throwable $e) { report($e); $source = null; } return ConversionSource::parse($source); } private function alreadyRecorded(ConversionEventType $type, Company $company): bool { return ConversionEvent::query() ->where('type', $type->value) ->where('company_id', $company->getKey()) ->exists(); } }