presseportale/app/Services/Conversion/ConversionRecorder.php
Kevin Adametz 1ccb4a8479 auth/security: API-Firmenzuordnung nur für Mitglieder oder Allowlist, Google-Vorabübernahme geschlossen, gemeinsamer Login-Abschluss (Umsetzungsaudit 29.09.: R-01, K-04, K-07, K-08, B-10)
- R-01: ApiNewsroomAssignment hängt an eine bestehende Firma nur noch Owner,
  Mitglieder oder Nutzer aus NEWSROOM_API_ASSIGNMENT_USER_IDS; Pivot mit
  granted_via = api; Befehl company-memberships:api-assignments (Liste,
  --remove mit Vorschau)
- K-04: OAuth-Verifikation eines offenen Kontos verwirft Passwort,
  remember_token und Sanctum-Tokens; SessionRevocation + Middleware
  EnsureSessionIsNotRevoked widerruft alte Sessions (Redis-tauglich)
- K-08: LoginCompletion für Passwort, Magic Link und Google — last_login_at
  und Anmeldemethode auch nach 2FA, Landeziel über die Challenge, Google
  ohne erzwungenes remember, magic-login ohne guest für eigene Links
- B-10: eigener Magic-Link-Zweck image_confirmation (Migration)
- K-07: legalAcceptanceVersions #[Locked], Versionswechsel beim Absenden
  wird abgelehnt, Gate-Abfragen je Request zwischengespeichert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:09:00 +00:00

163 lines
5.4 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;
/**
* 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);
}
/**
* 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);
}
/**
* Stufe 3 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
{
try {
$source = $user === null ? null : 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;
}
$this->recordOnce(ConversionEventType::NewsroomEdited, ConversionSource::parse($source), $company);
}
private function alreadyRecorded(ConversionEventType $type, Company $company): bool
{
return ConversionEvent::query()
->where('type', $type->value)
->where('company_id', $company->getKey())
->exists();
}
}