presseportale/app/Services/Api/LegacyApiCustomerReporter.php
Kevin Adametz c7bd940f28 Optimierung Phase 0: Korrektheit & Security
Konsolidierter Entwicklungsplan aus den zwei Analyse-Dokumenten plus
Umsetzung der Phase-0-Massnahmen (Korrektheit/Security, je mit Tests):

0.1 BasicAuth: timing-sicherer Vergleich (hash_equals, beide Felder ohne
    Short-Circuit); Enabled-Check zur Laufzeit via config('auth.basic.*')
    statt env() im Bootstrap-Closure (greift jetzt unter config:cache);
    Config-Block + .env.example. Fund: auth.basic.* war nie als Config
    definiert, Gate war faktisch unkonfigurierbar.
0.2 Legacy-Invoice-"latest"-Abfrage zentralisiert (LegacyInvoice::
    scopeLatestFirst + User::latestLegacyInvoice); beide Consumer N+1-frei
    umgestellt; unterschiedliches null-Verhalten bewusst erhalten.
0.3 Payment-Option-Eligibility vereinheitlicht (period-aware, strikt):
    UserPaymentOption::scopeActiveNow/grandfatheredNow + User::
    hasActivePaymentOption; hasActiveBooking/hasUnlimitedPressReleaseQuota
    und ApiAccessEligibilityService darauf umgestellt; $hasGrandfathered-
    Mislabel behoben.
0.4 Theme->Portal-Vokabular: totes, projektfremdes ThemeMiddleware
    geloescht; Mapping in Portal::fromTheme() zentralisiert; fremden
    b2in-Default im ThemeServiceProvider auf Portal-Default korrigiert.
0.5 InvoiceNumberGenerator: Erst-Insert race-sicher via insertOrIgnore
    (Unique-Index auf circle existiert bereits). CreditWalletService war
    bereits abgesichert (Unique-Index + createOrFirst).

Tests: 4 neue Dateien, 5 bestehende auf gueltiges grandfathered_until
angepasst. Volle Suite gruen (793 passed, 1 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 08:09:02 +00:00

142 lines
5 KiB
PHP

<?php
namespace App\Services\Api;
use App\Enums\RegistrationType;
use App\Models\LegacyInvoice;
use App\Models\User;
use Illuminate\Support\Collection;
class LegacyApiCustomerReporter
{
/**
* @return array<string, mixed>
*/
public function report(string $portal = 'all'): array
{
$users = $this->candidateUsers($portal);
$rows = $users
->map(fn (User $user): array => $this->mapUser($user))
->values();
return [
'generated_at' => now()->toIso8601String(),
'portal' => $portal,
'source' => [
'candidate_rule' => 'users.registration_type = apiuser OR Spatie role api-only',
'eligibility_rule' => 'user is active AND latest legacy invoice status is paid',
'missing_data' => [
'Legacy api_key values are intentionally not imported.',
'Legacy apiReadAccess/apiWriteAccess assignments are not preserved per-user beyond mapped roles.',
'Users without archived legacy invoices require manual review.',
],
],
'summary' => [
'total_candidates' => $rows->count(),
'eligible' => $rows->where('classification', 'eligible')->count(),
'needs_review' => $rows->where('classification', 'needs_review')->count(),
'blocked' => $rows->where('classification', 'blocked')->count(),
],
'customers' => $rows->all(),
];
}
/**
* @return Collection<int, User>
*/
private function candidateUsers(string $portal): Collection
{
return User::query()
->select([
'id',
'name',
'email',
'portal',
'registration_type',
'is_active',
'is_super_admin',
'legacy_portal',
'legacy_id',
'last_login_at',
])
->with([
'roles:id,name',
'legacyInvoices' => fn ($query) => $query
->select([
'id',
'user_id',
'legacy_portal',
'legacy_id',
'number',
'status',
'invoice_date',
'paid_at',
'total_cents',
])
->latestFirst(),
])
->where(function ($query): void {
$query->where('registration_type', RegistrationType::ApiUser->value)
->orWhereHas('roles', fn ($roles) => $roles->where('name', 'api-only'));
})
->when($portal !== 'all', fn ($query) => $query->where('portal', $portal))
->orderBy('email')
->get();
}
/**
* @return array<string, mixed>
*/
private function mapUser(User $user): array
{
$latestInvoice = $user->legacyInvoices->first();
$classification = $this->classification($user, $latestInvoice);
return [
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'portal' => $user->portal?->value,
'legacy' => [
'portal' => $user->legacy_portal,
'id' => $user->legacy_id,
],
'registration_type' => $user->registration_type?->value,
'roles' => $user->roles->pluck('name')->values()->all(),
'is_active' => $user->is_active,
'is_super_admin' => $user->is_super_admin,
'last_login_at' => $user->last_login_at?->toDateTimeString(),
'latest_legacy_invoice' => $latestInvoice ? [
'id' => $latestInvoice->id,
'legacy' => [
'portal' => $latestInvoice->legacy_portal?->value,
'id' => $latestInvoice->legacy_id,
],
'number' => $latestInvoice->number,
'status' => $latestInvoice->status,
'invoice_date' => $latestInvoice->invoice_date?->toDateString(),
'paid_at' => $latestInvoice->paid_at?->toDateTimeString(),
'total_cents' => $latestInvoice->total_cents,
] : null,
'classification' => $classification,
'recommended_action' => match ($classification) {
'eligible' => 'invite_to_generate_sanctum_token',
'needs_review' => 'manual_billing_review_required',
default => 'do_not_grant_api_access',
},
];
}
private function classification(User $user, ?LegacyInvoice $latestInvoice): string
{
if (! $user->is_active) {
return 'blocked';
}
if ($latestInvoice === null) {
return 'needs_review';
}
return $latestInvoice->status === 'paid' ? 'eligible' : 'blocked';
}
}