diff --git a/.env.example b/.env.example index 245563c9..76bba651 100644 --- a/.env.example +++ b/.env.example @@ -77,6 +77,10 @@ DB_LOCK_WAIT_TIMEOUT=10 # Bestimmt zugleich die Tiefe der Verlaufsanzeige. 395 = 13 Monate. PRESS_RELEASE_EVENTS_RETENTION_DAYS=395 +# Aufbewahrung der Konversions-Ereignisse in Tagen (siehe config/conversion.php). +# Bewusst derselbe Horizont wie oben. 395 = 13 Monate. +CONVERSION_EVENTS_RETENTION_DAYS=395 + # --------------------------------------------------------------------------- # Legacy-Datenbanken (Alt-Systeme, read-only für Migration/Abgleich). # Die businessportal-Verbindung ist ein Snapshot-Dump, nicht live. diff --git a/app/Console/Commands/PurgeConversionEvents.php b/app/Console/Commands/PurgeConversionEvents.php new file mode 100644 index 00000000..346b35b2 --- /dev/null +++ b/app/Console/Commands/PurgeConversionEvents.php @@ -0,0 +1,73 @@ +option('days') ?? config('conversion.events.retention_days')); + + if ($days < 1) { + $this->error('Aufbewahrungszeitraum muss mindestens einen Tag betragen.'); + + return self::FAILURE; + } + + $cutoff = now()->subDays($days); + $chunk = max(100, (int) $this->option('chunk')); + + $expired = ConversionEvent::query()->where('created_at', '<', $cutoff); + + if ($this->option('dry-run')) { + $this->info(sprintf( + '[Dry-Run] %d Ereignisse älter als %s.', + (clone $expired)->count(), + $cutoff->format('d.m.Y'), + )); + + return self::SUCCESS; + } + + $deleted = 0; + + while (true) { + $removed = (clone $expired)->limit($chunk)->delete(); + + if ($removed === 0) { + break; + } + + $deleted += $removed; + } + + $this->info(sprintf( + 'Konversions-Ereignisse bereinigt: %d Einträge vor dem %s gelöscht (Aufbewahrung %d Tage).', + $deleted, + $cutoff->format('d.m.Y'), + $days, + )); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/ReportConversionFunnel.php b/app/Console/Commands/ReportConversionFunnel.php new file mode 100644 index 00000000..3a287ccf --- /dev/null +++ b/app/Console/Commands/ReportConversionFunnel.php @@ -0,0 +1,80 @@ +option('to') !== null ? Carbon::parse((string) $this->option('to')) : Carbon::now(); + $from = $this->option('from') !== null + ? Carbon::parse((string) $this->option('from')) + : $to->copy()->subMonths(max(1, (int) $this->option('months'))); + + $data = $report->build($from, $to); + + if ($this->option('json')) { + $this->line((string) json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + + return self::SUCCESS; + } + + $this->info("Konversionstrichter — {$data['from']} bis {$data['to']}"); + + if ($data['sources'] === []) { + $this->newLine(); + $this->warn('Keine Ereignisse im Zeitraum. Herkunftsdaten lassen sich nicht rückwirkend erzeugen — ' + .'wenn hier dauerhaft nichts steht, greift eine Schreibstelle nicht.'); + + return self::SUCCESS; + } + + foreach ($data['sources'] as $source) { + $this->newLine(); + $this->line("{$source['label']}"); + + $rows = []; + + foreach ($source['steps'] as $step) { + $rows[] = [ + $step['step'], + $step['label'], + $step['firmen'], + $step['uebergang'] === null ? '–' : $step['uebergang'].' %', + $step['ohne_firma'] > 0 ? $step['ohne_firma'] : '', + $step['diagnosis'], + ]; + } + + $this->table( + ['#', 'Stufe', 'Firmen', 'Übergang', 'ohne Firma', 'Ein Abbruch hier bedeutet'], + $rows, + ); + } + + $this->newLine(); + $this->line(' Gezählt werden eindeutige Firmen je Stufe — ein doppelter Klick ist kein doppelter Interessent.'); + $this->line(' „ohne Firma" steht daneben statt darin: Diese Ereignisse gehören nicht in eine Quote, deren Nenner Firmen sind.'); + + return self::SUCCESS; + } +} diff --git a/app/Enums/ConversionEventType.php b/app/Enums/ConversionEventType.php new file mode 100644 index 00000000..8b549206 --- /dev/null +++ b/app/Enums/ConversionEventType.php @@ -0,0 +1,81 @@ + 1, + self::MagicLinkConsumed => 2, + self::NewsroomEdited => 3, + self::AccountCreated => 4, + self::FirstBooking => 5, + }; + } + + public function label(): string + { + return match ($this) { + self::MailLinkClicked => 'Mail-Link geklickt', + self::MagicLinkConsumed => 'Magic Link eingelöst', + self::NewsroomEdited => 'Newsroom bearbeitet', + self::AccountCreated => 'Konto angelegt', + self::FirstBooking => 'Erste Buchung', + }; + } + + /** + * Was ein Abbruch auf dieser Stufe bedeutet — die Diagnose, wegen der die + * Leiter überhaupt als Leiter geführt wird. + */ + public function diagnosis(): string + { + return match ($this) { + self::MailLinkClicked => 'Betreff oder Anlass trägt nicht', + self::MagicLinkConsumed => 'Der Verwaltungszugang wird nicht verstanden', + self::NewsroomEdited => 'Kein Anreiz, das Profil zu pflegen', + self::AccountCreated => 'Der Nutzen des Kontos ist nicht erkennbar', + self::FirstBooking => 'Preis oder Angebot passt nicht', + }; + } + + /** + * Die Leiter in ihrer Reihenfolge. + * + * @return list + */ + public static function ladder(): array + { + $cases = self::cases(); + + usort($cases, static fn (self $a, self $b): int => $a->step() <=> $b->step()); + + return $cases; + } +} diff --git a/app/Enums/ConversionSource.php b/app/Enums/ConversionSource.php new file mode 100644 index 00000000..03ac7faf --- /dev/null +++ b/app/Enums/ConversionSource.php @@ -0,0 +1,46 @@ + 'Veröffentlichungsmail', + self::MailReject => 'Ablehnungsmail', + self::ProfileClaim => 'Hinweis auf der Newsroom-Seite', + self::Organic => 'ohne zugeordnete Herkunft', + }; + } + + /** + * Auflösung eines Werts aus einer URL oder einem Token. + * + * Unbekannte Werte werden zu `organic` statt zu einem Fehler: Der Parameter + * steht in fremder Hand (Mailclient, Weiterleitung, manuell getippt) und + * darf niemals einen Request scheitern lassen. + */ + public static function parse(?string $value): self + { + return self::tryFrom((string) $value) ?? self::Organic; + } +} diff --git a/app/Http/Controllers/Auth/MagicLinkConsumeController.php b/app/Http/Controllers/Auth/MagicLinkConsumeController.php index e65bb1bf..fb6615b7 100644 --- a/app/Http/Controllers/Auth/MagicLinkConsumeController.php +++ b/app/Http/Controllers/Auth/MagicLinkConsumeController.php @@ -2,15 +2,19 @@ namespace App\Http\Controllers\Auth; +use App\Enums\ConversionEventType; use App\Http\Controllers\Controller; use App\Listeners\RecordAuthActivity; use App\Models\MagicLink; +use App\Services\Conversion\ConversionRecorder; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; class MagicLinkConsumeController extends Controller { + public function __construct(private readonly ConversionRecorder $recorder) {} + public function __invoke(Request $request, string $token): RedirectResponse { $magicLink = MagicLink::query() @@ -46,6 +50,14 @@ class MagicLinkConsumeController extends Controller return redirect()->route('login')->with('status', __('The magic login link has expired or was already used.')); } + // Stufe 2 der Konversionsleiter. Die Herkunft kommt vom Token, nicht + // aus der Session — die ist beim Weg über Mail und Domainwechsel + // längst gerissen (Roadmap 0.4). + $this->recorder->record( + ConversionEventType::MagicLinkConsumed, + $this->recorder->sourceOf($magicLink), + ); + $user = $magicLink->user; // 2FA aktiv: nicht direkt einloggen, sondern – wie der Passwort-Login – diff --git a/app/Http/Middleware/RecordMailLinkClick.php b/app/Http/Middleware/RecordMailLinkClick.php new file mode 100644 index 00000000..a673e948 --- /dev/null +++ b/app/Http/Middleware/RecordMailLinkClick.php @@ -0,0 +1,78 @@ +` und, wo es um eine + * konkrete Meldung geht, `&pr=`. Beides wird hier einmal beim Eintritt + * mitgeschrieben. + * + * Zur ID im Klartext: Sie ist fälschbar. Das ist hier vertretbar, weil sie + * nichts freischaltet — sie ordnet ein Ereignis in der internen Statistik + * einer Firma zu. Wer sie fälscht, verfälscht ausschließlich unsere eigene + * Auswertung. Sobald der Verwaltungszugang über Magic Link steht (1b.2), + * tragen die Mail-Links ohnehin ein Token, das Herkunft UND Meldung + * fälschungssicher mitführt; der `pr`-Parameter entfällt dann. + * + * Nur Herkünfte aus Mails lösen aus. `organic` würde sonst bei jedem + * getippten Parameter ein Ereignis erzeugen. + */ +class RecordMailLinkClick +{ + public function __construct(private readonly ConversionRecorder $recorder) {} + + public function handle(Request $request, Closure $next): Response + { + $source = ConversionSource::parse($request->query('src') !== null ? (string) $request->query('src') : null); + + if (! in_array($source, [ConversionSource::MailPublish, ConversionSource::MailReject], true)) { + return $next($request); + } + + // Nur echte Aufrufe. Mailclients und Sicherheitsscanner holen Links + // vorab per HEAD; das wäre kein Klick, sondern eine Zustellung. + if (! $request->isMethod('GET')) { + return $next($request); + } + + $pressRelease = $this->pressRelease($request); + + $this->recorder->record( + ConversionEventType::MailLinkClicked, + $source, + $this->recorder->companyOf($pressRelease), + $pressRelease, + ); + + return $next($request); + } + + /** + * Scope-frei aufgelöst: Der Klick landet je nach Link auf dem Hub oder auf + * einem der beiden Spokes, der Portal-Scope würde die Meldung dort + * wegfiltern. + */ + private function pressRelease(Request $request): ?PressRelease + { + $id = (int) $request->query('pr'); + + if ($id <= 0) { + return null; + } + + return PressRelease::query() + ->withoutGlobalScope(PortalScope::class) + ->find($id); + } +} diff --git a/app/Listeners/ProcessStripeWebhook.php b/app/Listeners/ProcessStripeWebhook.php index effee61e..d09642c8 100644 --- a/app/Listeners/ProcessStripeWebhook.php +++ b/app/Listeners/ProcessStripeWebhook.php @@ -23,6 +23,7 @@ use App\Scopes\PortalScope; use App\Services\ActivityLog; use App\Services\Billing\CreditTopupService; use App\Services\Billing\InvoiceNumberGenerator; +use App\Services\Conversion\ConversionRecorder; use App\Services\PressRelease\CorrectionService; use App\Services\PressRelease\CorrectionTooLargeException; use App\Services\PressRelease\PressReleaseService; @@ -55,6 +56,7 @@ class ProcessStripeWebhook public function __construct( private readonly InvoiceNumberGenerator $numbers, private readonly ActivityLog $activityLog, + private readonly ConversionRecorder $conversions, ) {} public function handle(WebhookReceived $event): void @@ -245,6 +247,12 @@ class ProcessStripeWebhook return; } + // Stufe 5 der Konversionsleiter: die erste bezahlte Rechnung eines + // Kontos. Deckt Abos und alle über Rechnung abgewickelten Zahlungen ab; + // Einmalkäufe melden zusätzlich in fulfillSinglePurchase(). Die Sperre + // je Firma sorgt dafür, dass trotzdem nur die erste Buchung zählt. + $this->conversions->recordFirstBooking($user); + try { $invoice = DB::transaction(function () use ($user, $stripeInvoice, $stripeInvoiceId, $subtotal, $tax, $total): ?Invoice { if (Invoice::query()->where('stripe_invoice_id', $stripeInvoiceId)->exists()) { @@ -452,6 +460,11 @@ class ProcessStripeWebhook if ($result['marked_paid']) { Log::info('Einmalkauf als bezahlt markiert.', ['single_purchase_id' => $purchase->id]); + + // Stufe 5 der Konversionsleiter. recordFirstBooking() zählt je + // Firma nur den ersten Treffer, deshalb dürfen beide Zahlungspfade + // melden, ohne sich gegenseitig zu verfälschen. + $this->conversions->recordFirstBooking($purchase->user); } if (($changedRelease = $result['changed_release']) !== null) { diff --git a/app/Listeners/RecordAuthActivity.php b/app/Listeners/RecordAuthActivity.php index 805c00d3..01eaeae4 100644 --- a/app/Listeners/RecordAuthActivity.php +++ b/app/Listeners/RecordAuthActivity.php @@ -3,8 +3,11 @@ namespace App\Listeners; use App\Enums\ActivityType; +use App\Enums\ConversionEventType; +use App\Enums\ConversionSource; use App\Models\User; use App\Services\ActivityLog; +use App\Services\Conversion\ConversionRecorder; use Illuminate\Auth\Events\Login; use Illuminate\Auth\Events\Registered; @@ -30,7 +33,10 @@ class RecordAuthActivity 'impersonation' => 'Admin-Impersonation', ]; - public function __construct(private readonly ActivityLog $activityLog) {} + public function __construct( + private readonly ActivityLog $activityLog, + private readonly ConversionRecorder $recorder, + ) {} public function recordRegistered(Registered $event): void { @@ -46,6 +52,11 @@ class RecordAuthActivity user: $user, meta: ['way' => 'form'], ); + + // Stufe 4 der Konversionsleiter. Ohne Firmenbezug — beim Anlegen des + // Kontos steht noch keine fest; die Zuordnung entsteht erst, wenn ein + // Newsroom beansprucht wird. + $this->recorder->record(ConversionEventType::AccountCreated, ConversionSource::Organic); } public function recordLogin(Login $event): void diff --git a/app/Mail/PressReleasePublished.php b/app/Mail/PressReleasePublished.php index 546841e0..00e38c1d 100644 --- a/app/Mail/PressReleasePublished.php +++ b/app/Mail/PressReleasePublished.php @@ -2,6 +2,7 @@ namespace App\Mail; +use App\Enums\ConversionSource; use App\Enums\EmailNotificationType; use App\Mail\Concerns\Unsubscribable; use App\Models\PressRelease; @@ -38,11 +39,20 @@ class PressReleasePublished extends Mailable ?string $recipientName = null, ) { $this->recipientName = $recipientName ?? $user->name; - $this->showUrl = $user->canAccessAdmin() - ? route('admin.press-releases.show', $pressRelease->id) - : route('me.press-releases.show', $pressRelease->id); + $this->showUrl = self::tagged( + $user->canAccessAdmin() + ? route('admin.press-releases.show', $pressRelease->id) + : route('me.press-releases.show', $pressRelease->id), + $pressRelease->id, + ); - $this->portalLinks = $pressRelease->publicPortalLinks(); + $this->portalLinks = array_map( + fn (array $link): array => [ + ...$link, + 'url' => self::tagged($link['url'], $pressRelease->id), + ], + $pressRelease->publicPortalLinks(), + ); $this->configureUnsubscribe($user, EmailNotificationType::PressReleasePublished); } @@ -58,4 +68,18 @@ class PressReleasePublished extends Mailable { return new Content(view: 'emails.press-release.published'); } + + /** + * Hängt Herkunft und Meldung an einen Mail-Link (Roadmap 0.4). + * + * Ohne diesen Schritt weiß die Auswertung am Ende, dass jemand gebucht hat, + * aber nicht, ob er aus dieser Mail kam — und genau das ist die Frage. + */ + public static function tagged(string $url, int $pressReleaseId): string + { + return $url.(str_contains($url, '?') ? '&' : '?').http_build_query([ + 'src' => ConversionSource::MailPublish->value, + 'pr' => $pressReleaseId, + ]); + } } diff --git a/app/Mail/PressReleaseRejected.php b/app/Mail/PressReleaseRejected.php index fc94d68c..86ca074e 100644 --- a/app/Mail/PressReleaseRejected.php +++ b/app/Mail/PressReleaseRejected.php @@ -2,6 +2,7 @@ namespace App\Mail; +use App\Enums\ConversionSource; use App\Enums\EmailNotificationType; use App\Mail\Concerns\Unsubscribable; use App\Models\PressRelease; @@ -31,10 +32,17 @@ class PressReleaseRejected extends Mailable ?string $recipientName = null, ) { $this->recipientName = $recipientName ?? $user->name; - $this->editUrl = $user->canAccessAdmin() + $editUrl = $user->canAccessAdmin() ? route('admin.press-releases.edit', $pressRelease->id) : route('me.press-releases.edit', $pressRelease->id); + // Herkunft und Meldung an den Link (Roadmap 0.4) — sonst ist später + // nicht unterscheidbar, ob eine Wiedereinreichung aus dieser Mail kam. + $this->editUrl = $editUrl.(str_contains($editUrl, '?') ? '&' : '?').http_build_query([ + 'src' => ConversionSource::MailReject->value, + 'pr' => $pressRelease->id, + ]); + $this->configureUnsubscribe($user, EmailNotificationType::PressReleaseRejected); } diff --git a/app/Models/ConversionEvent.php b/app/Models/ConversionEvent.php new file mode 100644 index 00000000..a6d08f0f --- /dev/null +++ b/app/Models/ConversionEvent.php @@ -0,0 +1,55 @@ + + */ + protected function casts(): array + { + return [ + 'type' => ConversionEventType::class, + 'source' => ConversionSource::class, + ]; + } + + /** + * @return BelongsTo + */ + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + /** + * @return BelongsTo + */ + public function pressRelease(): BelongsTo + { + return $this->belongsTo(PressRelease::class); + } +} diff --git a/app/Models/MagicLink.php b/app/Models/MagicLink.php index 2931b6c8..609dfa0b 100644 --- a/app/Models/MagicLink.php +++ b/app/Models/MagicLink.php @@ -11,6 +11,7 @@ class MagicLink extends Model 'user_id', 'token_hash', 'purpose', + 'source', 'payload', 'expires_at', 'consumed_at', diff --git a/app/Services/Auth/MagicLinkGenerator.php b/app/Services/Auth/MagicLinkGenerator.php index b081bb23..60d4f8a6 100644 --- a/app/Services/Auth/MagicLinkGenerator.php +++ b/app/Services/Auth/MagicLinkGenerator.php @@ -2,6 +2,7 @@ namespace App\Services\Auth; +use App\Enums\ConversionSource; use App\Models\MagicLink; use App\Models\PressRelease; use App\Models\User; @@ -13,8 +14,12 @@ class MagicLinkGenerator /** * @return array{magic_link: MagicLink, plain_token: string, expires_at: Carbon} */ - public function createLoginLink(User $user, ?string $requestedIp = null, int $ttlMinutes = 15): array - { + public function createLoginLink( + User $user, + ?string $requestedIp = null, + int $ttlMinutes = 15, + ?ConversionSource $source = null, + ): array { $plainToken = Str::random(64); $tokenHash = hash('sha256', $plainToken); $expiresAt = now()->addMinutes($ttlMinutes); @@ -31,6 +36,10 @@ class MagicLinkGenerator $magicLink = $user->magicLinks()->create([ 'token_hash' => $tokenHash, 'purpose' => 'login', + // Herkunft gehört auf das Token, nicht in die Session: Die Strecke + // führt über zwei Domains und einen Mailclient, die Sitzung reißt + // dabei genau an der interessanten Stelle (Roadmap 0.4). + 'source' => $source?->value, 'expires_at' => $expiresAt, 'ip_requested' => $requestedIp, ]); diff --git a/app/Services/Conversion/ConversionFunnelReport.php b/app/Services/Conversion/ConversionFunnelReport.php new file mode 100644 index 00000000..fec3b72a --- /dev/null +++ b/app/Services/Conversion/ConversionFunnelReport.php @@ -0,0 +1,94 @@ +, + * }>, + * } + */ + public function build(Carbon $from, ?Carbon $to = null): array + { + $to ??= Carbon::now(); + + $rows = ConversionEvent::query() + ->whereBetween('created_at', [$from, $to]) + ->get(['type', 'source', 'company_id']); + + $sources = []; + + foreach (ConversionSource::cases() as $source) { + $forSource = $rows->where('source', $source); + + if ($forSource->isEmpty()) { + continue; + } + + $steps = []; + $previous = null; + + foreach (ConversionEventType::ladder() as $type) { + $forType = $forSource->where('type', $type); + + $companies = $forType + ->pluck('company_id') + ->filter(static fn ($id): bool => $id !== null) + ->unique() + ->count(); + + $steps[] = [ + 'step' => $type->step(), + 'type' => $type->value, + 'label' => $type->label(), + 'diagnosis' => $type->diagnosis(), + 'firmen' => $companies, + // Übergang von der VORIGEN Stufe. Die erste Stufe hat + // keinen Vorgänger und damit keine Quote. + 'uebergang' => $previous === null || $previous === 0 + ? null + : round($companies / $previous * 100, 1), + 'ohne_firma' => $forType->whereNull('company_id')->count(), + ]; + + $previous = $companies; + } + + $sources[] = [ + 'source' => $source->value, + 'label' => $source->label(), + 'steps' => $steps, + ]; + } + + return [ + 'from' => $from->toDateString(), + 'to' => $to->toDateString(), + 'sources' => $sources, + ]; + } +} diff --git a/app/Services/Conversion/ConversionRecorder.php b/app/Services/Conversion/ConversionRecorder.php new file mode 100644 index 00000000..b85ab49d --- /dev/null +++ b/app/Services/Conversion/ConversionRecorder.php @@ -0,0 +1,138 @@ +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); + } + + /** + * 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 5 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. + */ + 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, ConversionSource::Organic, $company); + } + + private function alreadyRecorded(ConversionEventType $type, Company $company): bool + { + return ConversionEvent::query() + ->where('type', $type->value) + ->where('company_id', $company->getKey()) + ->exists(); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 240c54e2..8a202b07 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -2,6 +2,7 @@ use App\Http\Middleware\BasicAuthMiddleware; use App\Http\Middleware\LogApiUsage; +use App\Http\Middleware\RecordMailLinkClick; use App\Http\Middleware\RedirectWwwToApex; use App\Http\Middleware\RejectLegacyApiKeys; use App\Http\Middleware\SecurityHeaders; @@ -40,6 +41,12 @@ return Application::configure(basePath: dirname(__DIR__)) // Portal-Kontext nach dem Theme-Provider setzen (liest config('app.theme')) $middleware->append(SetCurrentPortal::class); + // Stufe 1 der Konversionsleiter: Klick aus einer unserer Mails + // (`?src=`). Bewusst global statt je Route — die Mail-Links zeigen auf + // Hub, Spokes und öffentliche Detailseiten gleichermaßen, und eine + // vergessene Route wäre eine unsichtbare Lücke in der Messung. + $middleware->append(RecordMailLinkClick::class); + // Ausgabe/Sprache (de|en) aus dem URL-Präfix ableiten und URL-Defaults setzen. $middleware->append(SetEdition::class); diff --git a/config/conversion.php b/config/conversion.php new file mode 100644 index 00000000..6fc7ac0b --- /dev/null +++ b/config/conversion.php @@ -0,0 +1,27 @@ + [ + 'retention_days' => (int) env('CONVERSION_EVENTS_RETENTION_DAYS', 395), + ], + +]; diff --git a/database/migrations/2026_09_01_100000_create_conversion_events_table.php b/database/migrations/2026_09_01_100000_create_conversion_events_table.php new file mode 100644 index 00000000..e0fa66ab --- /dev/null +++ b/database/migrations/2026_09_01_100000_create_conversion_events_table.php @@ -0,0 +1,52 @@ +id(); + $table->string('type', 32); + $table->string('source', 32); + $table->foreignId('company_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('press_release_id')->nullable()->constrained()->nullOnDelete(); + $table->timestamp('created_at')->useCurrent(); + + // Der Trichter zählt je Stufe die eindeutigen Firmen im Zeitraum, + // aufgeschlüsselt nach Herkunft — genau diese drei Spalten. + $table->index(['type', 'source', 'created_at'], 'conversion_events_type_source_created_idx'); + // Der Weg einer einzelnen Firma durch die Leiter. + $table->index(['company_id', 'type'], 'conversion_events_company_type_idx'); + // Aufräumen läuft über das Alter. + $table->index('created_at', 'conversion_events_created_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('conversion_events'); + } +}; diff --git a/database/migrations/2026_09_01_100100_add_source_to_magic_links_table.php b/database/migrations/2026_09_01_100100_add_source_to_magic_links_table.php new file mode 100644 index 00000000..1cee88af --- /dev/null +++ b/database/migrations/2026_09_01_100100_add_source_to_magic_links_table.php @@ -0,0 +1,42 @@ +string('source', 32)->nullable()->after('purpose'); + }); + } + } + + public function down(): void + { + if (Schema::hasColumn('magic_links', 'source')) { + Schema::table('magic_links', function (Blueprint $table) { + $table->dropColumn('source'); + }); + } + } +}; diff --git a/dev/2026-09-01/umsetzung-protokoll.md b/dev/2026-09-01/umsetzung-protokoll.md index 4786d0c2..e227951f 100644 --- a/dev/2026-09-01/umsetzung-protokoll.md +++ b/dev/2026-09-01/umsetzung-protokoll.md @@ -423,3 +423,127 @@ Zusammen mit Schritt 6 erledigt, siehe dort. | Aufbewahrung des Ereignis-Logs: 395 Tage | **Meine Festlegung, deine Entscheidung.** Bestimmt die Tiefe von Roadmap 1c.1 und ist nur *vor* dem ersten Lauf frei wählbar. | | Die 166 Baseline-Einträge | Kein Fehler, aber auch keine Sichtprüfung wert — Zusammensetzung und Begründung siehe Schritt 5. Wer sie weiter senken will, fängt bei den Closure-Generics in `app/Services/Web/` an. | | Roadmap-Entscheidungen E1–E6 | Unberührt — das sind fachliche Fragen, keine Code-Befunde. | + +--- + +# Teil 2 — Roadmap Block 0 (Messbarkeit) + +## 0.1 und 0.2 — Basiswerte + +Zwei Auswertungen als Artisan-Befehle, Ergebnisse und Ableitungen in +**[`basiswerte-block0.md`](basiswerte-block0.md)**. + +``` +php artisan press-releases:view-distribution [--months=3] [--from=] [--to=] [--json] +php artisan press-releases:rejection-rate [--months=3] [--from=] [--to=] [--json] +``` + +**Zwei Befunde, die den Plan verändert haben:** + +1. **Der Gesamtmedian taugt nicht als Basiswert.** `hits` ist ein mitlaufender + Zähler, und die Aufrufe wachsen nach der Veröffentlichung wochenlang weiter + (⌀ 75 → 128 → 179 → 231 in den ersten vier Wochen, ohne Plateau). Über ein + Drei-Monats-Fenster misst man damit die Altersmischung, nicht die Reichweite. + Der Report weist deshalb zusätzlich **Alterskohorten** aus; der Basiswert ist + die Kohorte „28–55 Tage", nicht die Gesamtzahl. +2. **openPR ist keine zweite Quelle.** Es ist der Presse-API-Verteiler von + connektar, also derselbe Zulauf. In den Daten hat genau ein API-Token je + einen Zugriff gemacht. Roadmap-Text korrigiert. + +**E2 ist damit entschieden: Rohzahlen.** Begründung in `basiswerte-block0.md`. +Kurzfassung: kein Bodensatz (unter 25 Aufrufen liegen 2 % bzw. 0 %), +Verteilungen nahezu symmetrisch, und eine Einordnung gegen den +Portaldurchschnitt wäre wegen der Wachstumskurve sogar falsch. + +--- + +## 0.3 bis 0.7 — Konversionsleiter + +### Warum eine eigene Tabelle statt Umami + +Die zu messende Strecke führt über zwei Domains und einen Magic Link. Ein +cookiefreies Analytics-Tool verliert die Sitzung genau an der Stelle, an der +die Konversion passiert. Claim, Registrierung und Buchung sind außerdem keine +Seitenaufrufe, sondern Zustandsänderungen in der Datenbank, bei denen die +Identität ohnehin bekannt ist. + +**Rollenteilung bleibt:** Ereignistabelle für den Trichter (Block 1), Umami für +Seitenaufrufe und Suchbegriffe (Block 2). Keine Vermischung. + +### 0.3 — `conversion_events` + +Spalten: `type`, `source`, `company_id`, `press_release_id`, `created_at`. +Keine Personen, keine Klartextadressen, keine IP, kein User-Agent. Der rote +Faden durch den Trichter ist die **Firma**, nicht der Mensch — für „an welcher +Stufe bricht es ab" reicht das vollständig. + +Die Leiter als Enum `ConversionEventType`, mit Stufe **und** Diagnose am Fall: + +| # | Stufe | Ein Abbruch hier bedeutet | +|---|---|---| +| 1 | Mail-Link geklickt | Betreff oder Anlass trägt nicht | +| 2 | Magic Link eingelöst | Der Verwaltungszugang wird nicht verstanden | +| 3 | Newsroom bearbeitet | Kein Anreiz, das Profil zu pflegen | +| 4 | Konto angelegt | Der Nutzen des Kontos ist nicht erkennbar | +| 5 | Erste Buchung | Preis oder Angebot passt nicht | + +### 0.4 — Herkunft auf dem Token + +Neue Spalte `magic_links.source` (bewusst eine Spalte statt eines Eintrags in +`payload`: Die Herkunft wird gruppiert und gefiltert, nicht nur gelesen). +`MagicLinkGenerator::createLoginLink()` nimmt sie entgegen. + +Die Mail-Links tragen `?src=&pr=`. `ConversionSource::parse()` +fällt bei unbekannten Werten auf `organic` zurück statt zu scheitern — der +Parameter steht in fremder Hand. + +**Zur Meldungs-ID im Klartext:** Sie ist fälschbar. Vertretbar, weil sie nichts +freischaltet, sondern ein Ereignis in der internen Statistik einer Firma +zuordnet; wer sie fälscht, verfälscht nur die eigene Auswertung. Sobald der +Verwaltungszugang über Magic Link steht (1b.2), tragen die Mail-Links ein +Token, das Herkunft und Meldung fälschungssicher mitführt — der `pr`-Parameter +entfällt dann. + +### 0.5 — Schreibstellen + +| Stufe | Wo | +|---|---| +| 1 | `RecordMailLinkClick` — globale Middleware. Bewusst global statt je Route: Die Mail-Links zeigen auf Hub, Spokes und öffentliche Detailseiten, eine vergessene Route wäre eine unsichtbare Lücke. Nur `mail_*`-Herkünfte lösen aus, nur GET. | +| 2 | `MagicLinkConsumeController`, Herkunft vom Token | +| 3 | **Enum und Auswertung stehen, Schreibstelle folgt mit 1b.3** (abgesprochen) | +| 4 | `RecordAuthActivity::recordRegistered()` | +| 5 | `ProcessStripeWebhook` an beiden Zahlungspfaden (`mirrorPaidInvoice`, `fulfillSinglePurchase`), dedupliziert über `recordOnce()` je Firma | + +Zwei Regeln für alle Schreibstellen: **Fehler werden geschluckt** (die Messung +begleitet einen Vorgang, sie ist nicht der Vorgang) und **kein Personenbezug**. + +### 0.6 — Auswertung + +``` +php artisan conversion:funnel [--months=3] [--from=] [--to=] [--json] +``` + +Zählt je Stufe die **eindeutigen Firmen**, nicht die Ereignisse: Ein doppelter +Klick ist kein doppelter Interessent, und nur mit derselben Einheit auf allen +Stufen ist ein Übergang überhaupt eine Quote. Ereignisse ohne Firmenbezug +stehen in einer eigenen Spalte daneben statt im Nenner. + +### 0.7 — Aufbewahrung + +**395 Tage**, derselbe Horizont wie beim Aufruf-Ereignis-Log (Entscheidung +01.09.2026). `config/conversion.php`, Befehl `conversion:purge-events`, täglich +03:25 im Scheduler. + +> **Offen und bewusst nicht von mir geschrieben:** Der Absatz in der +> Datenschutzerklärung. Rechtstext gehört nicht aus einer Codeänderung heraus +> formuliert. Inhaltlich zu führen ist: Zweck (interne Reichweiten- und +> Konversionsstatistik), Umfang (Ereignistyp, Herkunft, Firmen- und +> Meldungsbezug — kein Personenbezug, keine IP), Aufbewahrung (395 Tage), +> Rechtsgrundlage. + +### Angepasster Bestandstest + +`EmailBrandingTest` verglich die Mail-URL roh gegen das gerenderte HTML. Die +Links tragen jetzt zwei Parameter, Blade escapt das trennende `&` korrekt zu +`&` — der Vergleich läuft nun über `e()`. Verhalten unverändert richtig, +nur die Erwartung war zu eng. diff --git a/routes/console.php b/routes/console.php index 853552da..2cb024e2 100644 --- a/routes/console.php +++ b/routes/console.php @@ -3,6 +3,7 @@ use App\Console\Commands\AggregatePressReleaseViews; use App\Console\Commands\GenerateManualInvoices; use App\Console\Commands\PublishScheduledPressReleases; +use App\Console\Commands\PurgeConversionEvents; use App\Console\Commands\PurgeExpiredPressReleaseDrafts; use App\Console\Commands\PurgeMagicLinks; use App\Console\Commands\PurgePressReleaseEvents; @@ -92,6 +93,14 @@ Schedule::command(PurgePressReleaseEvents::class) ->runInBackground() ->onFailure($alertOnFailure('press-releases:purge-events')); +// Konversions-Ereignisse auf den Aufbewahrungszeitraum begrenzen (täglich 03:25). +// Frist zusammen mit der Tabelle festgelegt (395 Tage), siehe config/conversion.php. +Schedule::command(PurgeConversionEvents::class) + ->dailyAt('03:25') + ->withoutOverlapping() + ->runInBackground() + ->onFailure($alertOnFailure('conversion:purge-events')); + // ======================================== // Manueller Rechnungskreis (MAN-) — Legacy-Zahlungen // ======================================== diff --git a/tests/Feature/ConversionFunnelTest.php b/tests/Feature/ConversionFunnelTest.php new file mode 100644 index 00000000..39a25a5e --- /dev/null +++ b/tests/Feature/ConversionFunnelTest.php @@ -0,0 +1,260 @@ +create([ + 'type' => $type, + 'source' => $source, + 'company_id' => $company?->getKey(), + ]); +} + +// ------------------------------------------------- 0.4 Herkunft am Token + +test('a mail link carries its origin and the press release', function () { + /** @var TestCase $this */ + $release = PressRelease::factory()->published()->create(); + $user = $release->user; + + $mail = new PressReleasePublished($user, $release); + + expect($mail->showUrl)->toContain('src='.ConversionSource::MailPublish->value) + ->and($mail->showUrl)->toContain('pr='.$release->id); +}); + +test('the rejection mail marks its own origin', function () { + /** @var TestCase $this */ + $release = PressRelease::factory()->create(); + + $mail = new PressReleaseRejected($release->user, $release, 'Grund'); + + // Ohne eigenen Herkunftswert wäre später nicht unterscheidbar, ob eine + // Wiedereinreichung aus der Ablehnungsmail kam. + expect($mail->editUrl)->toContain('src='.ConversionSource::MailReject->value); +}); + +test('the origin is stored on the magic link token, not in a session', function () { + /** @var TestCase $this */ + $user = User::factory()->create(); + + $link = app(MagicLinkGenerator::class)->createLoginLink($user, null, 15, ConversionSource::MailPublish); + + // Genau das ist der Punkt: Die Strecke führt über zwei Domains und einen + // Mailclient. Eine Session reißt dabei, das Token nicht. + expect($link['magic_link']->source)->toBe(ConversionSource::MailPublish->value) + ->and(app(ConversionRecorder::class)->sourceOf($link['magic_link'])) + ->toBe(ConversionSource::MailPublish); +}); + +test('an unknown origin falls back to organic instead of failing', function () { + /** @var TestCase $this */ + // Der Parameter steht in fremder Hand — Mailclient, Weiterleitung, manuell + // getippt. Er darf niemals einen Request scheitern lassen. + expect(ConversionSource::parse('kaputt'))->toBe(ConversionSource::Organic) + ->and(ConversionSource::parse(null))->toBe(ConversionSource::Organic); +}); + +// --------------------------------------------------- 0.5 Schreibstellen + +test('a mail link click is recorded with its company', function () { + /** @var TestCase $this */ + $company = Company::factory()->create(['portal' => Portal::Businessportal24]); + $release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24) + ->create(['company_id' => $company->id, 'published_at' => now()->subDay()]); + + $this->get('https://businessportal24.test/de/press-release/'.$release->slug + .'?src='.ConversionSource::MailPublish->value.'&pr='.$release->id)->assertOk(); + + $event = ConversionEvent::query()->sole(); + + expect($event->type)->toBe(ConversionEventType::MailLinkClicked) + ->and($event->source)->toBe(ConversionSource::MailPublish) + ->and($event->company_id)->toBe($company->id) + ->and($event->press_release_id)->toBe($release->id); +}); + +test('a page view without an origin parameter records nothing', function () { + /** @var TestCase $this */ + $release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24) + ->create(['published_at' => now()->subDay()]); + + $this->get('https://businessportal24.test/de/press-release/'.$release->slug)->assertOk(); + + expect(ConversionEvent::query()->count())->toBe(0); +}); + +test('an organic origin parameter does not fake a mail click', function () { + /** @var TestCase $this */ + $release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24) + ->create(['published_at' => now()->subDay()]); + + $this->get('https://businessportal24.test/de/press-release/'.$release->slug.'?src=organic')->assertOk(); + + // Sonst erzeugte jeder getippte Parameter ein Ereignis. + expect(ConversionEvent::query()->count())->toBe(0); +}); + +test('consuming a magic link records step two with the token origin', function () { + /** @var TestCase $this */ + Mail::fake(); + $user = User::factory()->create(['is_active' => true]); + $link = app(MagicLinkGenerator::class)->createLoginLink($user, null, 15, ConversionSource::MailReject); + + $this->get(route('magic-links.consume', ['token' => $link['plain_token']])); + + $event = ConversionEvent::query() + ->where('type', ConversionEventType::MagicLinkConsumed->value) + ->sole(); + + expect($event->source)->toBe(ConversionSource::MailReject); +}); + +test('registering records step four', function () { + /** @var TestCase $this */ + event(new Registered(User::factory()->create())); + + expect(ConversionEvent::query() + ->where('type', ConversionEventType::AccountCreated->value) + ->count())->toBe(1); +}); + +test('the first booking is recorded once per company', function () { + /** @var TestCase $this */ + $user = User::factory()->create(); + $company = Company::factory()->create(['owner_user_id' => $user->id]); + $recorder = app(ConversionRecorder::class); + + $recorder->recordFirstBooking($user); + $recorder->recordFirstBooking($user); + + // Eine zweite Buchung ist keine erste. Beide Zahlungspfade dürfen melden, + // ohne die Stufe zu verfälschen. + expect(ConversionEvent::query() + ->where('type', ConversionEventType::FirstBooking->value) + ->where('company_id', $company->id) + ->count())->toBe(1); +}); + +test('a broken recording never breaks the request it accompanies', function () { + /** @var TestCase $this */ + Schema::drop('conversion_events'); + + // Die Messung begleitet einen Vorgang, sie ist nicht der Vorgang. + app(ConversionRecorder::class)->record(ConversionEventType::AccountCreated, ConversionSource::Organic); +})->throwsNoExceptions(); + +// ------------------------------------------------------ 0.6 Auswertung + +test('the funnel counts distinct companies per step', function () { + /** @var TestCase $this */ + $a = Company::factory()->create(); + $b = Company::factory()->create(); + + funnelEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailPublish, $a); + funnelEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailPublish, $a); + funnelEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailPublish, $b); + funnelEvent(ConversionEventType::MagicLinkConsumed, ConversionSource::MailPublish, $a); + + $data = app(ConversionFunnelReport::class)->build(now()->subMonth()); + $steps = collect($data['sources'])->firstWhere('source', ConversionSource::MailPublish->value)['steps']; + + // Ein doppelter Klick ist kein doppelter Interessent. + expect($steps[0]['firmen'])->toBe(2) + ->and($steps[1]['firmen'])->toBe(1) + ->and($steps[1]['uebergang'])->toBe(50.0) + ->and($steps[0]['uebergang'])->toBeNull(); +}); + +test('the funnel keeps events without a company out of the quota', function () { + /** @var TestCase $this */ + $company = Company::factory()->create(); + + funnelEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailPublish, $company); + funnelEvent(ConversionEventType::AccountCreated, ConversionSource::MailPublish, null); + + $data = app(ConversionFunnelReport::class)->build(now()->subMonth()); + $steps = collect(collect($data['sources'])->firstWhere('source', ConversionSource::MailPublish->value)['steps']) + ->keyBy('type'); + + // „Konto angelegt" hat naturgemäß keine Firma — das gehört ausgewiesen, + // nicht in einen Nenner, dessen Einheit Firmen sind. + expect($steps[ConversionEventType::AccountCreated->value]['firmen'])->toBe(0) + ->and($steps[ConversionEventType::AccountCreated->value]['ohne_firma'])->toBe(1); +}); + +test('the funnel separates the origins', function () { + /** @var TestCase $this */ + funnelEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailPublish, Company::factory()->create()); + funnelEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailReject, Company::factory()->create()); + + $data = app(ConversionFunnelReport::class)->build(now()->subMonth()); + + // Ohne diese Trennung wüsste man, dass jemand gebucht hat, aber nicht, + // ob er aus einer Ablehnungsmail kam. + expect(collect($data['sources'])->pluck('source')->all()) + ->toContain(ConversionSource::MailPublish->value, ConversionSource::MailReject->value); +}); + +test('the funnel command says plainly when nothing was recorded', function () { + /** @var TestCase $this */ + $this->artisan('conversion:funnel') + ->expectsOutputToContain('Keine Ereignisse im Zeitraum.') + ->assertSuccessful(); +}); + +// ------------------------------------------------------ 0.7 Aufbewahrung + +test('the purge drops events past the retention window', function () { + /** @var TestCase $this */ + $old = funnelEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailPublish); + $old->forceFill(['created_at' => now()->subDays(400)])->save(); + $fresh = funnelEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailPublish); + + $this->artisan('conversion:purge-events')->assertSuccessful(); + + expect(ConversionEvent::query()->find($old->id))->toBeNull() + ->and(ConversionEvent::query()->find($fresh->id))->not->toBeNull(); +}); + +test('the purge dry run counts without deleting', function () { + /** @var TestCase $this */ + $old = funnelEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailPublish); + $old->forceFill(['created_at' => now()->subDays(400)])->save(); + + $this->artisan('conversion:purge-events', ['--dry-run' => true]) + ->expectsOutputToContain('[Dry-Run] 1 Ereignisse') + ->assertSuccessful(); + + expect(ConversionEvent::query()->find($old->id))->not->toBeNull(); +}); + +test('the ladder is complete and ordered', function () { + /** @var TestCase $this */ + $steps = array_map(fn (ConversionEventType $t): int => $t->step(), ConversionEventType::ladder()); + + // Eine Leiter ohne Sprossen sagt nicht, wo es hakt. + expect($steps)->toBe([1, 2, 3, 4, 5]) + ->and(ConversionEventType::NewsroomEdited->diagnosis())->toBe('Kein Anreiz, das Profil zu pflegen'); +}); diff --git a/tests/Feature/Mail/EmailBrandingTest.php b/tests/Feature/Mail/EmailBrandingTest.php index a18b2c83..0dea94c1 100644 --- a/tests/Feature/Mail/EmailBrandingTest.php +++ b/tests/Feature/Mail/EmailBrandingTest.php @@ -110,7 +110,10 @@ test('the published mail links the account area below the portal links with a lo $publicUrl = config('domains.domain_presseecho_url').'/de/press-release/'.$pressRelease->slug; expect($html) - ->toContain($mailable->showUrl) + // Escapt verglichen: Die Mail-Links tragen seit der Konversionsmessung + // zwei Parameter (`src`, `pr`), und Blade escapt das trennende `&` im + // HTML korrekt zu `&`. + ->toContain(e($mailable->showUrl)) ->toContain('Pressemitteilung im pressekonto öffnen') ->toContain('Login-Bereich') ->toContain('mit Ihrem Passwort oder per Magic-Link');