Roadmap 6.16. Bisher stand jede Einlösung ohne Firma im Trichter (Stufe "Magic Link eingelöst" immer 0 Firmen), zwischen Mail-Klick und Einlösung fehlten "Verwaltungsseite geöffnet" und "Anmeldelink angefordert", und die Erstbuchung stand immer auf organic. Die Leiter hat jetzt sieben Stufen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
256 lines
8.8 KiB
PHP
256 lines
8.8 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Conversion;
|
|
|
|
use App\Enums\ConversionEventType;
|
|
use App\Enums\ConversionSource;
|
|
use App\Models\Company;
|
|
use App\Models\ConversionEvent;
|
|
use App\Models\MagicLink;
|
|
use App\Models\PressRelease;
|
|
use App\Models\User;
|
|
use App\Scopes\PortalScope;
|
|
use App\Support\CrawlerIdentifier;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\RateLimiter;
|
|
|
|
/**
|
|
* Schreibt die Ereignisse der Konversionsleiter (Roadmap 0.5).
|
|
*
|
|
* Zwei Regeln, die für alle Schreibstellen gelten:
|
|
*
|
|
* 1. **Fehler werden geschluckt.** Die Messung begleitet einen Vorgang, sie ist
|
|
* nicht der Vorgang. Eine kaputte Statistik darf keine Anmeldung, keinen
|
|
* Magic Link und erst recht keine Buchung scheitern lassen.
|
|
* 2. **Kein Personenbezug.** Festgehalten werden Firma und Meldung, nie ein
|
|
* Mensch. Der rote Faden durch den Trichter ist die Firma; für die Frage
|
|
* „an welcher Stufe bricht es ab" reicht das vollständig.
|
|
*/
|
|
class ConversionRecorder
|
|
{
|
|
public function record(
|
|
ConversionEventType $type,
|
|
ConversionSource $source,
|
|
?Company $company = null,
|
|
?PressRelease $pressRelease = null,
|
|
): void {
|
|
try {
|
|
ConversionEvent::query()->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();
|
|
}
|
|
}
|