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>
This commit is contained in:
parent
6cb7b79dae
commit
c7bd940f28
27 changed files with 990 additions and 133 deletions
|
|
@ -11,6 +11,12 @@ APP_FAKER_LOCALE=en_US
|
|||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
# HTTP Basic Auth Gate (Staging-Schutz). Nur erzwungen, wenn aktiviert
|
||||
# UND BASIC_AUTH_USER/BASIC_AUTH_PASSWORD gesetzt sind.
|
||||
BASIC_AUTH_ENABLED=true
|
||||
BASIC_AUTH_USER=
|
||||
BASIC_AUTH_PASSWORD=
|
||||
|
||||
PHP_CLI_SERVER_WORKERS=4
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
|
|
|||
|
|
@ -8,6 +8,21 @@ enum Portal: string
|
|||
case Businessportal24 = 'businessportal24';
|
||||
case Both = 'both';
|
||||
|
||||
/**
|
||||
* Mappt ein Domain-Theme (aus config/domains.php, gesetzt vom
|
||||
* ThemeServiceProvider) auf das zugehörige Portal. Admin-/Hub-Themes
|
||||
* (portal, pressekonto, main, …) liefern null → kein Portal-Filter.
|
||||
* Single Source of Truth für die Theme→Portal-Auflösung.
|
||||
*/
|
||||
public static function fromTheme(?string $theme): ?self
|
||||
{
|
||||
return match ($theme) {
|
||||
'presseecho' => self::Presseecho,
|
||||
'businessportal24' => self::Businessportal24,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@ class BasicAuthMiddleware
|
|||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// Gate global deaktiviert (z. B. Produktion/Tests)
|
||||
if (! config('auth.basic.enabled', true)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Skip Basic Auth für Livewire-Requests komplett
|
||||
// Diese sind bereits durch Laravel Session/CSRF geschützt
|
||||
$path = $request->path();
|
||||
|
|
@ -38,11 +43,20 @@ class BasicAuthMiddleware
|
|||
return $next($request);
|
||||
}
|
||||
|
||||
// Credentials from .env file
|
||||
// Zugangsdaten aus der Config (gespeist aus .env). Ist nichts
|
||||
// konfiguriert, wird der Gate nicht erzwungen.
|
||||
$user = config('auth.basic.user');
|
||||
$pass = config('auth.basic.password');
|
||||
|
||||
if ($request->getUser() != $user || $request->getPassword() != $pass) {
|
||||
if (($user === null || $user === '') && ($pass === null || $pass === '')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Timing-sicherer Vergleich beider Felder (kein Short-Circuit).
|
||||
$userValid = hash_equals((string) $user, (string) $request->getUser());
|
||||
$passValid = hash_equals((string) $pass, (string) $request->getPassword());
|
||||
|
||||
if (! $userValid || ! $passValid) {
|
||||
return response('Unauthorized.', 401, ['WWW-Authenticate' => 'Basic']);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,13 +44,8 @@ class SetCurrentPortal
|
|||
}
|
||||
}
|
||||
|
||||
// Domain-basierte Auflösung via ThemeServiceProvider
|
||||
$theme = config('app.theme', 'main');
|
||||
|
||||
return match ($theme) {
|
||||
'presseecho' => Portal::Presseecho,
|
||||
'businessportal24' => Portal::Businessportal24,
|
||||
default => null, // Admin/Portal-Domain → kein automatischer Filter
|
||||
};
|
||||
// Domain-basierte Auflösung via ThemeServiceProvider; Mapping zentral
|
||||
// im Portal-Enum (Admin/Portal-Domain → null, kein automatischer Filter).
|
||||
return Portal::fromTheme(config('app.theme', 'main'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ThemeMiddleware
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$host = $request->getHost();
|
||||
$path = $request->path();
|
||||
|
||||
// Theme-Switching über Subdomains
|
||||
if (str_contains($host, 'b2in')) {
|
||||
config(['app.theme' => 'b2in']);
|
||||
} elseif (str_contains($host, 'b2a') || str_contains($host, 'bridges2america')) {
|
||||
config(['app.theme' => 'b2a']);
|
||||
} elseif (str_contains($host, 'stileigentum')) {
|
||||
config(['app.theme' => 'stileigentum']);
|
||||
} elseif (str_contains($host, 'style2own')) {
|
||||
config(['app.theme' => 'style2own']);
|
||||
}
|
||||
|
||||
// Theme-Switching über URL-Parameter (für Testing)
|
||||
if ($request->has('theme')) {
|
||||
$theme = $request->get('theme');
|
||||
if (in_array($theme, ['b2in', 'b2a', 'stileigentum', 'style2own'])) {
|
||||
config(['app.theme' => $theme]);
|
||||
}
|
||||
}
|
||||
|
||||
// Theme-Switching über Pfade (für lokale Entwicklung ohne Domain-Setup)
|
||||
if (str_starts_with($path, 'b2in/')) {
|
||||
config(['app.theme' => 'b2in']);
|
||||
$request->server->set('REQUEST_URI', '/'.substr($path, 5)); // Entferne 'b2in/' vom Pfad
|
||||
} elseif (str_starts_with($path, 'b2a/') || str_starts_with($path, 'bridges2america/')) {
|
||||
config(['app.theme' => 'b2a']);
|
||||
$request->server->set('REQUEST_URI', '/'.substr($path, 4)); // Entferne 'b2a/' vom Pfad
|
||||
} elseif (str_starts_with($path, 'stileigentum/')) {
|
||||
config(['app.theme' => 'stileigentum']);
|
||||
$request->server->set('REQUEST_URI', '/'.substr($path, 13)); // Entferne 'stileigentum/' vom Pfad
|
||||
} elseif (str_starts_with($path, 'style2own/')) {
|
||||
config(['app.theme' => 'style2own']);
|
||||
$request->server->set('REQUEST_URI', '/'.substr($path, 10)); // Entferne 'style2own/' vom Pfad
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Models;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
|
|
@ -52,4 +53,13 @@ class LegacyInvoice extends Model
|
|||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Neueste Rechnung zuerst: nach Rechnungsdatum, dann ID als Tiebreaker.
|
||||
* Single Source of Truth für die „latest legacy invoice"-Ordnung.
|
||||
*/
|
||||
public function scopeLatestFirst(Builder $query): Builder
|
||||
{
|
||||
return $query->orderByDesc('invoice_date')->orderByDesc('id');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ namespace App\Models;
|
|||
use App\Enums\Portal;
|
||||
use App\Enums\RegistrationType;
|
||||
use App\Enums\Tier;
|
||||
use App\Enums\UserPaymentOptionStatus;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
|
@ -188,12 +187,17 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||
return $this->cachedHasUnlimitedQuota = true;
|
||||
}
|
||||
|
||||
return $this->cachedHasUnlimitedQuota = $this->userPaymentOptions()
|
||||
->whereIn('status', [
|
||||
UserPaymentOptionStatus::Active->value,
|
||||
UserPaymentOptionStatus::Grandfathered->value,
|
||||
])
|
||||
->exists();
|
||||
return $this->cachedHasUnlimitedQuota = $this->hasActivePaymentOption();
|
||||
}
|
||||
|
||||
/**
|
||||
* Period-aware Prüfung auf eine aktuell gültige Zahlungsoption
|
||||
* (aktives Abo in laufender Periode oder laufender Bestandsschutz).
|
||||
* Single Source of Truth für API-Gate und Buchungs-/Kontingent-Checks.
|
||||
*/
|
||||
public function hasActivePaymentOption(): bool
|
||||
{
|
||||
return $this->userPaymentOptions()->activeNow()->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -253,12 +257,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||
return true;
|
||||
}
|
||||
|
||||
return $this->userPaymentOptions()
|
||||
->whereIn('status', [
|
||||
UserPaymentOptionStatus::Active->value,
|
||||
UserPaymentOptionStatus::Grandfathered->value,
|
||||
])
|
||||
->exists();
|
||||
return $this->hasActivePaymentOption();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -369,6 +368,15 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||
return $this->hasMany(LegacyInvoice::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Neueste Legacy-Rechnung des Users (nach Rechnungsdatum, dann ID).
|
||||
* Zentralisiert die zuvor mehrfach reimplementierte „latest"-Abfrage.
|
||||
*/
|
||||
public function latestLegacyInvoice(): ?LegacyInvoice
|
||||
{
|
||||
return $this->legacyInvoices()->latestFirst()->first();
|
||||
}
|
||||
|
||||
public function filterPresets(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserFilterPreset::class);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Models;
|
||||
|
||||
use App\Enums\UserPaymentOptionStatus;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
|
@ -58,4 +59,33 @@ class UserPaymentOption extends Model
|
|||
{
|
||||
return $this->hasMany(UserPayment::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktuell gültige Zahlungsoption (period-aware): aktives Abo innerhalb der
|
||||
* laufenden Periode ODER laufender Bestandsschutz. Single Source of Truth
|
||||
* für „aktive Zahlungsoption" in API-Gate, Buchungs- und Kontingent-Checks.
|
||||
*/
|
||||
public function scopeActiveNow(Builder $query): Builder
|
||||
{
|
||||
return $query->where(function (Builder $query): void {
|
||||
$query
|
||||
->where(function (Builder $active): void {
|
||||
$active
|
||||
->where('status', UserPaymentOptionStatus::Active->value)
|
||||
->whereDate('current_period_start', '<=', now())
|
||||
->whereDate('current_period_end', '>=', now());
|
||||
})
|
||||
->orWhere(fn (Builder $grandfathered): Builder => $grandfathered->grandfatheredNow());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Laufender Bestandsschutz (Grandfathered mit gültigem grandfathered_until).
|
||||
*/
|
||||
public function scopeGrandfatheredNow(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
->where('status', UserPaymentOptionStatus::Grandfathered->value)
|
||||
->whereDate('grandfathered_until', '>=', now());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,11 +31,12 @@ class ThemeServiceProvider extends ServiceProvider
|
|||
$host = Request::getHost();
|
||||
$themeOverride = Request::get('theme');
|
||||
|
||||
// Fallback für unbekannte Hosts: Portal-/Admin-Theme dieses Projekts.
|
||||
$defaults = [
|
||||
'name' => config('app.name'),
|
||||
'theme' => 'b2in',
|
||||
'view_prefix' => 'b2in',
|
||||
'assets_dir' => 'build/b2in',
|
||||
'theme' => 'portal',
|
||||
'view_prefix' => 'portal',
|
||||
'assets_dir' => 'build/portal',
|
||||
'url' => config('app.url'),
|
||||
'domain_name' => config('app.domain_name'),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Services\Api;
|
||||
|
||||
use App\Enums\UserPaymentOptionStatus;
|
||||
use App\Models\User;
|
||||
|
||||
class ApiAccessEligibilityService
|
||||
|
|
@ -18,40 +17,16 @@ class ApiAccessEligibilityService
|
|||
return 'Ihr Benutzerkonto ist nicht aktiv. API-Tokens können nur für aktive Benutzer erstellt werden.';
|
||||
}
|
||||
|
||||
if ($this->hasActivePaymentOption($user) || $this->hasPaidLegacyInvoice($user)) {
|
||||
if ($user->hasActivePaymentOption() || $this->hasPaidLegacyInvoice($user)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'API-Tokens werden erst freigeschaltet, wenn ein aktiver Zahlungsstatus oder freigegebener Bestandsschutz vorliegt.';
|
||||
}
|
||||
|
||||
private function hasActivePaymentOption(User $user): bool
|
||||
{
|
||||
return $user->userPaymentOptions()
|
||||
->where(function ($query): void {
|
||||
$query
|
||||
->where(function ($active): void {
|
||||
$active
|
||||
->where('status', UserPaymentOptionStatus::Active->value)
|
||||
->whereDate('current_period_start', '<=', now())
|
||||
->whereDate('current_period_end', '>=', now());
|
||||
})
|
||||
->orWhere(function ($grandfathered): void {
|
||||
$grandfathered
|
||||
->where('status', UserPaymentOptionStatus::Grandfathered->value)
|
||||
->whereDate('grandfathered_until', '>=', now());
|
||||
});
|
||||
})
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function hasPaidLegacyInvoice(User $user): bool
|
||||
{
|
||||
$latestInvoice = $user->legacyInvoices()
|
||||
->latest('invoice_date')
|
||||
->latest('id')
|
||||
->first(['id', 'status']);
|
||||
|
||||
return $latestInvoice?->status === 'paid';
|
||||
// Bewusstes Verhalten dieses Gates: ohne Legacy-Rechnung kein Zugang.
|
||||
return $user->latestLegacyInvoice()?->status === 'paid';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,8 +73,7 @@ class LegacyApiCustomerReporter
|
|||
'paid_at',
|
||||
'total_cents',
|
||||
])
|
||||
->latest('invoice_date')
|
||||
->latest('id'),
|
||||
->latestFirst(),
|
||||
])
|
||||
->where(function ($query): void {
|
||||
$query->where('registration_type', RegistrationType::ApiUser->value)
|
||||
|
|
|
|||
|
|
@ -39,22 +39,21 @@ class InvoiceNumberGenerator
|
|||
}
|
||||
|
||||
$number = DB::transaction(function () use ($circle): int {
|
||||
// Sequenz-Zeile race-sicher sicherstellen: insertOrIgnore nutzt den
|
||||
// Unique-Index auf `circle`, sodass parallele Erstaufrufe nicht in
|
||||
// doppelte Zeilen (und damit doppelte Nummern) laufen.
|
||||
DB::table('invoice_number_sequences')->insertOrIgnore([
|
||||
'circle' => $circle,
|
||||
'next_number' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$sequence = DB::table('invoice_number_sequences')
|
||||
->where('circle', $circle)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $sequence) {
|
||||
DB::table('invoice_number_sequences')->insert([
|
||||
'circle' => $circle,
|
||||
'next_number' => 2,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
DB::table('invoice_number_sequences')
|
||||
->where('id', $sequence->id)
|
||||
->update(['next_number' => $sequence->next_number + 1, 'updated_at' => now()]);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
namespace App\Services\Customer;
|
||||
|
||||
use App\Enums\Tier;
|
||||
use App\Enums\UserPaymentOptionStatus;
|
||||
use App\Models\Plan;
|
||||
use App\Models\User;
|
||||
|
||||
|
|
@ -45,10 +44,7 @@ class CustomerStatusSummary
|
|||
}
|
||||
|
||||
$hasGrandfathered = $user->userPaymentOptions()
|
||||
->whereIn('status', [
|
||||
UserPaymentOptionStatus::Active->value,
|
||||
UserPaymentOptionStatus::Grandfathered->value,
|
||||
])
|
||||
->grandfatheredNow()
|
||||
->exists();
|
||||
|
||||
return new self(
|
||||
|
|
|
|||
|
|
@ -54,10 +54,10 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||
'ability' => CheckForAnyAbility::class,
|
||||
]);
|
||||
|
||||
// BasicAuth ganz am Ende, nach Session-Middleware
|
||||
if (env('BASIC_AUTH_ENABLED', true)) {
|
||||
$middleware->append(BasicAuthMiddleware::class);
|
||||
}
|
||||
// BasicAuth ganz am Ende, nach Session-Middleware. Immer anhängen;
|
||||
// ob erzwungen wird, entscheidet die Middleware zur Laufzeit anhand
|
||||
// von config('auth.basic.*') (greift auch unter `config:cache`).
|
||||
$middleware->append(BasicAuthMiddleware::class);
|
||||
// Trust all proxies (for Traefik/Docker setup)
|
||||
$middleware->trustProxies(
|
||||
at: '*',
|
||||
|
|
|
|||
|
|
@ -114,4 +114,22 @@ return [
|
|||
|
||||
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTP Basic Auth Gate (Staging-Schutz)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Optionaler vorgelagerter Basic-Auth-Schutz für die gesamte Web-App
|
||||
| (z. B. Staging). Wird nur erzwungen, wenn aktiviert UND Zugangsdaten
|
||||
| gesetzt sind. Diese Werte über config() statt env() zu lesen stellt
|
||||
| sicher, dass der Schutz auch unter `config:cache` aktiv bleibt.
|
||||
|
|
||||
*/
|
||||
|
||||
'basic' => [
|
||||
'enabled' => env('BASIC_AUTH_ENABLED', true),
|
||||
'user' => env('BASIC_AUTH_USER'),
|
||||
'password' => env('BASIC_AUTH_PASSWORD'),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
108
dev/optimize/entwicklungsplan.md
Normal file
108
dev/optimize/entwicklungsplan.md
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# Entwicklungsplan: System-Optimierung Pressekonto
|
||||
|
||||
> Konsolidiert aus `system-optimierung-analyse-codex.md` und `system-optimierungen-analyse-claude.md` (Stand 18.06.2026).
|
||||
> Reihenfolge nach **Risiko/Nutzen**: erst Korrektheit & Security, dann risikoarme Deduplikation, dann größere Extraktionen.
|
||||
|
||||
## Arbeitsregeln (für jeden Schritt)
|
||||
|
||||
- **Test-First bei Korrektheits-/Verhaltensänderungen** (Phase 0): erst einen Pest-Test schreiben, der das Soll-Verhalten festschreibt, dann ändern.
|
||||
- **Test-After bei reiner Extraktion** (Phasen 1–5): bestehende Feature-Tests vor und nach dem Schritt laufen lassen — sie müssen grün bleiben.
|
||||
- Tests gezielt laufen: `vendor/bin/sail artisan test --compact --filter=<Name>`.
|
||||
- Nach PHP-Änderungen: `vendor/bin/sail bin pint --dirty --format agent`.
|
||||
- **Web-Frontend** (`resources/views/web/**`, `components/web/**`) gehört dem User → Phase 6, separat committen, vor jedem Löschen bestätigen lassen.
|
||||
- Jeder Schritt = ein eigener, fokussierter Commit.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Korrektheit & Security (höchste Dringlichkeit, Test-First)
|
||||
|
||||
Latente Fehlerquellen und Sicherheitslücken. Klein, aber wichtig.
|
||||
|
||||
- [x] **0.1 BasicAuth absichern** — `!=` → `hash_equals()` (timing-sicher, beide Felder ohne Short-Circuit). Enabled-Check von `env()` (brach unter `config:cache`/im Bootstrap) in die Middleware zur Laufzeit via `config('auth.basic.*')` verlagert; Config-Block in `config/auth.php` + `.env.example` ergänzt. **Fund:** `auth.basic.user/password` waren nie als Config-Key definiert → Gate war faktisch nicht konfigurierbar; jetzt nur erzwungen, wenn aktiviert UND Zugangsdaten gesetzt. Test: `tests/Feature/BasicAuthMiddlewareTest.php`.
|
||||
- [x] **0.2 Legacy-Invoice-„latest"-Abfrage zentralisiert** — `LegacyInvoice::scopeLatestFirst()` als Single Source of Truth + `User::latestLegacyInvoice()`; `ApiAccessEligibilityService` und `LegacyApiCustomerReporter` (N+1-frei via Eager-Load-Scope) nutzen sie. **Bewusst erhalten:** unterschiedliches `null`-Verhalten (Gate=deny vs. Report=needs_review) ist kontextuell korrekt, keine Vereinheitlichung. Test: `tests/Feature/ApiAccessEligibilityServiceTest.php`.
|
||||
- [x] **0.3 Zahlungsoption-Eligibility vereinheitlicht (period-aware, strikt)** — Entscheidung des Users: überall period-aware. Neue Single Source of Truth: `UserPaymentOption::scopeActiveNow()` (Active in laufender Periode ODER laufender Bestandsschutz) + `scopeGrandfatheredNow()`; `User::hasActivePaymentOption()`. Umgestellt: `User::hasUnlimitedPressReleaseQuota`, `User::hasActiveBooking`, `ApiAccessEligibilityService`. Mislabel behoben: `CustomerStatusSummary::$hasGrandfathered` nutzt jetzt `grandfatheredNow()` (spezifisch Bestandsschutz statt „Active ODER Grandfathered"). Test: `tests/Feature/ApiAccessEligibilityServiceTest.php`; 5 bestehende Tests auf gültiges `grandfathered_until` angepasst (waren via Factory-`optional()` nicht-deterministisch). **Folge der strikten Wahl (geklärt 19.06.):** Ein Grandfathered-User **ohne** `grandfathered_until` zählt nicht mehr als aktiv — User-Entscheidung: `NULL = abgelaufen/ungültig`, Verhalten beibehalten (konsistent mit altem API-Gate), kein weiterer Code-Change.
|
||||
- [x] **0.4 Theme→Portal-Vokabular vereinheitlicht** — **Fund:** `ThemeMiddleware` war **nirgends registriert** und referenzierte projektfremde Themes (`b2in/b2a/stileigentum/style2own` aus einem anderen Projekt) → ersatzlos gelöscht. Theme→Portal-Mapping zentralisiert in `Portal::fromTheme()` (genutzt von `SetCurrentPortal`). Fremden `$defaults`-Fallback im `ThemeServiceProvider` (`b2in`/`build/b2in`) auf echten Portal-Default (`portal`/`build/portal`) korrigiert. Test: `tests/Unit/PortalFromThemeTest.php`.
|
||||
- [x] **0.5 Race-Conditions abgesichert** — **Fund:** Die empfohlenen Unique-Indizes existieren bereits (`credit_wallets.user_id`, `invoice_number_sequences.circle`) → keine Migration nötig. `CreditWalletService::post` ist damit bereits race-sicher (Unique-Index + `firstOrCreate`/`createOrFirst` fängt Unique-Violation; Saldo-Update unter `lockForUpdate` in Transaktion) → kein Change. `InvoiceNumberGenerator::next()` gehärtet: Erst-Insert-Pfad (kein Seeder → produktiv getroffen) crashte bei parallelem Erstaufruf an der Unique-Violation; jetzt `insertOrIgnore` (race-sicher, null-Branch eliminiert). Test ergänzt: `tests/Feature/Billing/InvoiceNumberGeneratorTest.php`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Aufräumen / Toter Code (risikoarm, Paket A)
|
||||
|
||||
Keine Fachlogik-Änderung. Reduziert erreichbare Demo-Komponenten und Altlasten.
|
||||
|
||||
- [ ] **1.1 Prototyp `admin/users/table` entfernen** — Route in `routes/admin.php` + `resources/views/livewire/admin/users/table.blade.php`. Filtert auf nicht existentes `users.status`, liest `$user->role` (statt Spatie), englische Demo-Menüs → kann SQL-Fehler/falsche UI erzeugen. Test für entfernte Route anpassen/ergänzen.
|
||||
- [ ] **1.2 Tote Admin-Wrapper-Views prüfen & entfernen** — `resources/views/admin/*` werden außer `admin.dashboard` (via `DashboardController`) nicht produktiv gerendert. Wrapper mit alten Parametern (`request()->route('pressRelease')` vs. aktuelle `{id}`-Route) entfernen oder klar als Legacy markieren. `admin.dashboard` behalten.
|
||||
- [ ] **1.3 Tote Helfer entfernen** — `EnsureUserIsAdmin:16-18` totes `loadMissing('roles')`; `statusColor` aus `with()` (admin/edit:558-564, admin/show:111-117) prüfen/entfernen; `LegacyInvoicePdfController:26` Laufzeit-`Schema::hasColumn` nach Migration entfernen.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Press-Release-Editoren entdoppeln (größter Einzel-Hebel, Paket B)
|
||||
|
||||
4 Dateien, ~5.500 Zeilen, ~80–95 % Überschneidung. In kleinen, risikoarmen Schritten.
|
||||
|
||||
- [ ] **2.1 Admin-Editoren nutzen `InteractsWithPressReleaseTags`** (H5) — Customer nutzt den Trait bereits, Admin inlined `addTag/removeTag/tagsArray`+`tags`-Computed. → `use`-Statement + ~35 Zeilen je Datei löschen.
|
||||
- [ ] **2.2 `InteractsWithPressReleaseScheduling`-Trait** (H1) — `updatedPublishMode/ScheduledDate/ScheduledTime`, `syncScheduledAt`, `resolveScheduledAt`, `validateScheduledAtWhenReady`, `notifyValidationError` + `scheduled*`-Block sind 4× wortgleich. Spiegelt bestehenden Tags-Trait.
|
||||
- [ ] **2.3 `formRules()` zentralisieren** (H2) — ~95 % identisch; einziger Unterschied admin `portal`+`companyId exists` vs. customer `companyId integer`. → `baseRules()` im Trait, Komponenten überschreiben nur abweichende Keys.
|
||||
- [ ] **2.4 `presubmitChecks` kanonisieren** (H3) — 4× dupliziert mit Copy-Paste-Drift (`trans_choice` vs. `__`). → eine Version im Trait.
|
||||
- [ ] **2.5 Kontakt-Helfer + `tagSuggestionsFor()` zentralisieren** (H4) — `defaultContactIdFor/companyContacts/companyContact` in allen vier, divergente Query-Styles. → Trait/Scope.
|
||||
- [ ] **2.6 Persistierung in `PressReleaseService`** (M1) — Customer hat bereits `persistDraft/persistEdit/pressReleaseAttributes`, Admin inlined volle `create/update`. Slug + `HtmlSanitizer::clean()` + `contacts()->sync()` + `contentChanged`→reclassify/rescore überall wiederholt. → `PressReleaseService::createDraft()` / `updateContent()`.
|
||||
- [ ] **2.7 Firmen-Combobox-Suche** (M2) — Admin baut Query inline, Customer delegiert an `CustomerCompanyContext::searchCompaniesFor()`. `supportsFullTextSearch()` 3× kopiert. → `Company::scopeSearchByTerm()` (siehe auch 3.x).
|
||||
- [ ] **2.8 Formular-Markup als Blade-Components** (M3, größte Roh-Zeilenersparnis) — `pr-char-meter`, `pr-tag-input`, `pr-presubmit-check`, `pr-publish-schedule`, `pr-contact-select`, `pr-boilerplate-panel`.
|
||||
- [ ] **2.9 Listen-/Show-Components + Admin-Aktionen** (M4–M6) — `pr-table-row`, `filter-chip`, `pr-status-timeline`, `pr-rejection-banner`; `HandlesAdminPressReleaseActions`-Trait (`findAdminPR()` ersetzt ~10× `withoutGlobalScopes()->findOrFail($id)`); Status-`match` auf `PressReleaseStatus::badgeClass()/color()`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Backend-Querschnittslogik zentralisieren (Paket D + Domain)
|
||||
|
||||
- [ ] **3.1 `Company::scopeAccessibleBy()` / `CompanyPolicy`** — identische `owner_user_id == me OR whereHas('users', me)`-Closure in 4 Controllern + Überlappung mit `User::accessibleCompanyIds()` und `CustomerCompanyContext::accessibleCompanyQuery()`. Policy wird in `ContactAccessService:229` bereits referenziert. → konsequent über Gate/Policy.
|
||||
- [ ] **3.2 API-PressRelease-Zugriff + Eager-Loads zentralisieren** — `findOwnedPressRelease` doppelt (PressRelease/Image-Controller); Relation-Liste (`company:…`, `category.translations`, `images`) mehrfach. → expliziter Scope (`ownedByApiUser`) + `withApiResourceRelations()`. Unique-Slug-Reimplementierung (`PressReleaseController:201-220`) durch bestehendes `HasUniqueSlug` ersetzen.
|
||||
- [ ] **3.3 `DomainContext`-Service** — Host→Config-Auflösung 4× (`ThemeServiceProvider::boot`, `ThemeMiddleware`, `SetDomainUrl`, `SetCurrentPortal`). **Vorher prüfen, ob `SetDomainUrl` toter Code ist** (scheint nicht in `bootstrap/app.php` registriert). `CurrentPortalContext` mutable static → request-scoped Binding.
|
||||
- [ ] **3.4 Performance** — `LogApiUsage` synchroner INSERT/Request → terminable/queued. `CustomerStatusSummary:35-66` Query-Sturm (always-on im Header) memoisieren; offene Single-Purchase 3× → einmal berechnen. `CustomerCompanyContext::selectedCompany()` doppelte Query → einmal `first()`. `LegacySubscriptionService::cancelBundleFor` Update-pro-Iteration → Bulk/Transaction.
|
||||
- [ ] **3.5 Kleinere Zentralisierungen** — `VatResolver::normalize/isEuCountry` (3×), `User::findByEmail()` statt `LOWER()`-Queries (defeated Index, 4×), `config('billing.default_currency')` statt `'EUR'`/`'eur'`-Literale, gemeinsame Account-Provisioning-Methode (`ContactAccessService` ≈ `SocialAuthService`).
|
||||
- [ ] **3.6 Auth-Gates** — `EnsureUserIsAdmin/Customer`, `EnsureApiUserIsActive` von Hand-Rolling → `Gate::define('access-admin'|'access-customer')` + `can:`-Middleware. Impersonation-Redirect aus dem Admin-Gate in eigene Middleware.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Web-Routen entflechten (Paket C)
|
||||
|
||||
`routes/web.php` > 1100 Zeilen mit kompletter Datenaufbereitung. Schrittweise, gut absicherbar.
|
||||
|
||||
- [ ] **4.1 Portal-/Domain-Kontext extrahieren** (baut auf 3.3 `DomainContext` auf).
|
||||
- [ ] **4.2 Published-Release-Query zentralisieren** — gleiche Bedingung (Portal `both`, `status=published`, Sprache, `published_at <= now()`) mehrfach, teils mit/ohne `withoutGlobalScope(PortalScope)`. → Scopes `scopePublished`, `scopeForPortalValues`, `scopeForLanguage`, `scopeVisibleInArchive`.
|
||||
- [ ] **4.3 Page-Data in Services** — `HomePageData`/`CategoryPageData`/`NewsroomPageData` für Home/Kategorie/Newsroom/Detail/Suche/Preise.
|
||||
- [ ] **4.4 Routen auf schlanke (Invokable) Controller umstellen** — schrittweise je Seite.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Admin-CRUD & Auth: Concerns + Blade-Components (risikoarm)
|
||||
|
||||
- [ ] **5.1 Tabellen-Concerns** — `HasSortableTable` (`sort()` 3× wortgleich), `HasFullTextSearch` (`supportsFullTextSearch()` 6×+ + `applyFuzzySearch`), `HasResettableFilters` (`updatedXxxFilter()`→`resetPage`). `stats()` mit `SUM(CASE WHEN is_active)` → `AdminPerformanceCache::activeInactiveStats()`; Count-Hydration → `hydrateRelationCounts()`.
|
||||
- [ ] **5.2 User create/edit entdoppeln** — `UserFormRules`-Provider + `UserUpsertService`/`SaveUserAction` (`save()` trägt ~175 Zeilen). Markup → `<x-admin.billing-fields>`, `<x-admin.company-link-picker>`, `<x-admin.role-checkboxes>`. Kontakt → `ValidatesContactData`-Concern + `<x-contact-fields>`.
|
||||
- [ ] **5.3 Formatierungs-Components/Enum** — `Portal::badgeColor()` + `<x-portal.portal-badge>`; `<x-portal.status-badge>`; `PressReleaseStatus`-Enum-Methoden; `<x-portal.money>`, `<x-portal.datetime>`, `Category::localizedName()`, `Contact::displayName()`.
|
||||
- [ ] **5.4 Seiten-Struktur-Components** — `<x-portal.page-header>`, `<x-portal.stat-cards>`, `<x-portal.table-panel>`, `<x-portal.table-pagination>`, `<x-portal.empty-state>`, `<x-portal.searchable-select>`. (Muster: bestehende `<x-portal.stat-card>`/`components.portal.pagination`.)
|
||||
- [ ] **5.5 Flash/Auth-Components** — `<x-portal.alert :dismissible>` + `<x-session-status>` app-weit; `<x-password-input>`, `<x-form-field>`/`<x-email-input>`; `ensureNotRateLimited()/throttleKey()`-Trait (login/magic-link divergieren); `<x-auth.divider-actions>`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Web-Frontend (separat, mit User abstimmen)
|
||||
|
||||
> `resources/views/web/**` + `components/web/**` gehören dem User. Beratend; eigener Commit. „unused"-Zählungen stammen aus `grep` — dynamische Auflösung vor Löschen prüfen.
|
||||
|
||||
- [ ] **6.1 Pricing-Cards** — `web/pressekonto.blade.php` codet 3× ~67-Zeilen-`<article>`s, obwohl datengetriebenes `<x-web.pricing-teaser>` (iteriert `Plan::active()`) existiert. → einsetzen (~200 Zeilen → eine Schleife).
|
||||
- [ ] **6.2 Release-Cards vereinheitlichen** — `press-release-card` ≈ `presseecho-release-card` (~95 %, letztere 0 Verwendungen). → eine Komponente mit `variant`-Prop oder löschen. Industrie-Icon-Map (3×) → `CategoryService`/Config; Release-Normalisierung (3×) → `ReleaseCardData::from()`.
|
||||
- [ ] **6.3 Footer/Header/Toter Code** — `site-footer` ≈ `hub/site-footer`, Top-Utility-Bar; tote Legacy-Cards in `web/examples/` nach Bestätigung entfernen; Inline-SVGs → `components/web/icons/`-Set; `<x-web.container>` für `max-w-layout`-Shell.
|
||||
|
||||
---
|
||||
|
||||
## Reihenfolge-Empfehlung (TL;DR)
|
||||
|
||||
1. **Phase 0** zuerst — Korrektheit/Security, Test-First.
|
||||
2. **Phase 1** — schnelles Aufräumen, kein Risiko.
|
||||
3. **Phase 2** — größter Dedup-Gewinn (PR-Editoren), inkrementell.
|
||||
4. **Phase 3 + 4** — Backend/Web-Architektur (mittlerer Aufwand, hoher struktureller Gewinn).
|
||||
5. **Phase 5** — Admin/Auth-Components.
|
||||
6. **Phase 6** — Web-Frontend separat mit dem User.
|
||||
|
||||
**Beibehalten (gute Muster):** `PressRelease::accessibleBy`, `PortalScope`, `HasUniqueSlug`, Form Requests + Eloquent Resources, `AdminPerformanceCache`, `InteractsWithPressReleaseTags`, `<x-portal.stat-card>`/`components.portal.pagination`.
|
||||
</content>
|
||||
</invoke>
|
||||
326
dev/optimize/system-optimierung-analyse-codex.md
Normal file
326
dev/optimize/system-optimierung-analyse-codex.md
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
# System-Optimierung Analyse
|
||||
|
||||
Stand: 18.06.2026
|
||||
|
||||
## Ziel
|
||||
|
||||
Analyse des aktuellen Entwicklungsstands mit Fokus auf Controller, Views, Livewire/Volt-Komponenten und wiederverwendbare Code-Strukturen. Schwerpunkt: Code-Dopplungen, tote/alte Strukturen, Query-Wiederholungen und Refactoring-Potenzial.
|
||||
|
||||
## Kurzfazit
|
||||
|
||||
Das System hat bereits gute fachliche Bausteine wie Services, Policies, Model-Scopes, Eloquent Resources und einzelne Livewire-Concerns. Das groesste Optimierungspotenzial liegt nicht in den klassischen Controllern, sondern in:
|
||||
|
||||
- sehr grosser Route-/Page-Logik in `routes/web.php`
|
||||
- sehr grossen Volt-Dateien mit wiederholter Formular-, Scheduling- und Query-Logik
|
||||
- alten Admin-Wrapper-Views neben den aktiven Volt-Routen
|
||||
- einzelnen Demo-/Prototyp-Komponenten, die noch erreichbar sind
|
||||
- mehrfach eingebetteten API-Zugriffsqueries
|
||||
|
||||
## Priorisierte Findings
|
||||
|
||||
### 1. Erreichbarer Prototyp: `admin/users/table`
|
||||
|
||||
**Dateien**
|
||||
|
||||
- `routes/admin.php`
|
||||
- `resources/views/livewire/admin/users/table.blade.php`
|
||||
|
||||
`routes/admin.php` routet noch `admin/users/table` auf die Volt-Komponente `admin.users.table`. Diese Komponente wirkt wie ein frueher Flux-Prototyp:
|
||||
|
||||
- filtert auf `users.status`, obwohl die Tabelle laut Schema kein `status`-Feld hat
|
||||
- sortiert Demo-Spalten wie `date`, `status`, `amount`
|
||||
- liest `$user->role`, obwohl Rollen ueber Spatie-Relationen laufen
|
||||
- enthaelt englische Demo-Menupunkte wie `New post`, `Draft`, `Published`, `Archived`
|
||||
|
||||
**Risiko**
|
||||
|
||||
Die Route ist erreichbar und kann zu SQL-Fehlern oder fachlich falscher UI fuehren.
|
||||
|
||||
**Empfehlung**
|
||||
|
||||
Route und Komponente entfernen, falls sie nicht mehr gebraucht werden. Falls eine separate User-Tabelle geplant ist, sollte sie auf Basis der produktiven `admin.users`-Komponente neu aufgebaut werden.
|
||||
|
||||
**Prioritaet:** Hoch
|
||||
|
||||
### 2. Alte Admin-Wrapper-Views neben direkten Volt-Routen
|
||||
|
||||
**Dateien**
|
||||
|
||||
- `resources/views/admin/*`
|
||||
- `routes/admin.php`
|
||||
- `app/Http/Controllers/Admin/DashboardController.php`
|
||||
|
||||
Die aktiven Admin-Seiten laufen groesstenteils direkt ueber `Volt::route(...)`. Gleichzeitig existieren alte Wrapper-Views unter `resources/views/admin/*`, die Livewire-Komponenten manuell mounten, z. B.:
|
||||
|
||||
- `resources/views/admin/companies/show.blade.php`
|
||||
- `resources/views/admin/press-releases/show.blade.php`
|
||||
- `resources/views/admin/roles/edit.blade.php`
|
||||
|
||||
Ein Suchlauf hat nur einen produktiven `view('admin.*')`-Aufruf ergeben:
|
||||
|
||||
- `DashboardController` rendert `admin.dashboard`
|
||||
|
||||
Viele Wrapper sind dadurch wahrscheinlich tot. Einige verwenden zudem alte Routennamen oder alte Route-Parameter, z. B. `request()->route('pressRelease')`, waehrend die aktuelle Volt-Route `{id}` nutzt.
|
||||
|
||||
**Risiko**
|
||||
|
||||
Verwirrung bei Wartung, falsche Wiederverwendung, alte Parameter-Konventionen, unklare Quelle der Wahrheit.
|
||||
|
||||
**Empfehlung**
|
||||
|
||||
- `admin.dashboard` behalten, solange der DashboardController sie nutzt
|
||||
- ungenutzte Admin-Wrapper entfernen oder in einen klaren Legacy-/Dokumentationsbereich verschieben
|
||||
- `resources/views/admin/*.md` separat bewerten, da diese eher Dokumentation/Status darstellen
|
||||
|
||||
**Prioritaet:** Hoch
|
||||
|
||||
### 3. `routes/web.php` enthaelt zu viel Controller- und Query-Logik
|
||||
|
||||
**Datei**
|
||||
|
||||
- `routes/web.php`
|
||||
|
||||
`routes/web.php` hat aktuell ueber 1100 Zeilen und enthaelt komplette Datenaufbereitung fuer:
|
||||
|
||||
- Home-Daten (`$webHomeData`)
|
||||
- Kategorie-/Branchenseite (`$categoryPage`)
|
||||
- Pressemitteilungs-Detailseite (`$releaseDetailPage`)
|
||||
- Veroeffentlichen-Seite (`$veroeffentlichenPage`)
|
||||
- Suche (`$suchePage`)
|
||||
- Newsrooms-Index (`$newsroomsIndex`)
|
||||
- Newsroom-Detail (`$newsroomDetail`)
|
||||
- Preise (`$preisePage`)
|
||||
- Legacy-Redirects
|
||||
|
||||
Die Datei wiederholt mehrfach:
|
||||
|
||||
- Domain-/Portal-Erkennung
|
||||
- `portal/status/language/published_at`-Filter
|
||||
- Eager-Load-Sets fuer `company`, `category.translations`, `images`
|
||||
- Most-read Queries
|
||||
- Newsroom Count Queries
|
||||
- Statistikfenster wie 7/14/30 Tage
|
||||
|
||||
**Risiko**
|
||||
|
||||
Schwer testbar, schwer zu optimieren, hohe Aenderungskosten, Query-Regeln koennen auseinanderlaufen.
|
||||
|
||||
**Empfehlung**
|
||||
|
||||
In mehreren kleinen Schritten extrahieren:
|
||||
|
||||
1. `WebPortalContext` oder aehnlicher Service fuer DomainKey, Portal, PortalValues, Language und Theme-Konfiguration.
|
||||
2. `PublishedReleaseQuery` oder lokale Scopes am Model fuer veroeffentlichte Pressemitteilungen.
|
||||
3. Page-Data-Klassen wie `HomePageData`, `CategoryPageData`, `NewsroomPageData`.
|
||||
4. Danach Routen auf schlanke Controller oder Invokable Controller umstellen.
|
||||
|
||||
**Prioritaet:** Hoch
|
||||
|
||||
### 4. Wiederholte Published-Release-Bedingungen
|
||||
|
||||
**Dateien**
|
||||
|
||||
- `routes/web.php`
|
||||
- `app/Models/PressRelease.php`
|
||||
|
||||
Die gleiche fachliche Bedingung fuer oeffentlich sichtbare Pressemitteilungen taucht mehrfach auf:
|
||||
|
||||
- Portal muss aktuelles Portal oder `both` sein
|
||||
- Status ist `published`
|
||||
- Sprache passt
|
||||
- `published_at` ist gesetzt
|
||||
- `published_at <= now()`
|
||||
|
||||
Teilweise wird `withoutGlobalScope(PortalScope::class)` verwendet, teilweise nicht. Das ist fachlich nachvollziehbar, aber die Logik sollte zentraler formuliert werden.
|
||||
|
||||
**Empfehlung**
|
||||
|
||||
Moegliche Scopes:
|
||||
|
||||
```php
|
||||
public function scopePublished(Builder $query): Builder
|
||||
public function scopeForPortalValues(Builder $query, array $portalValues): Builder
|
||||
public function scopeForLanguage(Builder $query, string $language): Builder
|
||||
public function scopeVisibleInArchive(Builder $query, array $portalValues, string $language): Builder
|
||||
```
|
||||
|
||||
Alternativ ein Query-Objekt, wenn die Kombination mit `withoutGlobalScope(PortalScope::class)` bewusst kontrolliert werden soll.
|
||||
|
||||
**Prioritaet:** Hoch
|
||||
|
||||
### 5. Grosse PM-Editoren mit duplizierter Formular- und Scheduling-Logik
|
||||
|
||||
**Dateien**
|
||||
|
||||
- `resources/views/livewire/admin/press-releases/create.blade.php`
|
||||
- `resources/views/livewire/admin/press-releases/edit.blade.php`
|
||||
- `resources/views/livewire/customer/press-releases/create.blade.php`
|
||||
- `resources/views/livewire/customer/press-releases/edit.blade.php`
|
||||
- `app/Livewire/Concerns/InteractsWithPressReleaseTags.php`
|
||||
|
||||
Die vier PM-Editoren sind sehr gross und wiederholen viele Muster:
|
||||
|
||||
- Properties fuer Portal, Sprache, Firma, Kategorie, Kontakt, Titel, Text, Keywords
|
||||
- `updatedPublishMode`
|
||||
- `updatedScheduledDate`
|
||||
- `updatedScheduledTime`
|
||||
- `syncScheduledAt`
|
||||
- `resolveScheduledAt`
|
||||
- `validateScheduledAtWhenReady`
|
||||
- grosse `formRules`
|
||||
- Teile der Speicher- und Submit-Logik
|
||||
|
||||
Positiv: Fuer Customer-Editoren existiert bereits `InteractsWithPressReleaseTags`. Admin-Editoren duplizieren die Tag-Logik aber noch lokal.
|
||||
|
||||
**Empfehlung**
|
||||
|
||||
In kleinen, risikoarmen Schritten:
|
||||
|
||||
1. Admin-PM-Editoren ebenfalls `InteractsWithPressReleaseTags` nutzen lassen.
|
||||
2. `InteractsWithPressReleaseScheduling` einfuehren fuer Scheduling-Hooks und Validierung.
|
||||
3. Gemeinsame Rule-Fragmente in einen Service oder Trait auslagern, z. B. `PressReleaseFormRules`.
|
||||
4. Wiederkehrende Formularabschnitte als Blade-Komponenten oder partials extrahieren.
|
||||
|
||||
**Prioritaet:** Hoch
|
||||
|
||||
### 6. API-Zugriffsqueries mehrfach eingebettet
|
||||
|
||||
**Dateien**
|
||||
|
||||
- `app/Http/Controllers/Api/V1/PressReleaseController.php`
|
||||
- `app/Http/Controllers/Api/V1/PressReleaseImageController.php`
|
||||
- `app/Http/Controllers/Api/V1/CompanyController.php`
|
||||
- `app/Models/PressRelease.php`
|
||||
- `app/Models/Company.php`
|
||||
|
||||
Mehrere Controller enthalten fast gleiche Ownership-/Access-Queries:
|
||||
|
||||
- `findOwnedPressRelease` in `PressReleaseController`
|
||||
- `findOwnedPressRelease` in `PressReleaseImageController`
|
||||
- Company-Zugriff ueber `owner_user_id` oder `users`-Pivot in `CompanyController`
|
||||
- dieselbe Company-Zugriffslogik nochmals in `PressReleaseController`
|
||||
|
||||
Es gibt bereits `PressRelease::accessibleBy(User $user)`, allerdings nutzt die API aktuell eine engere `user_id`-Logik. Das kann fachlich korrekt sein, sollte aber explizit zentralisiert werden.
|
||||
|
||||
**Empfehlung**
|
||||
|
||||
- `Company::accessibleBy(User $user)` als Scope einfuehren
|
||||
- API-spezifischen PressRelease-Scope definieren, z. B. `ownedByApiUser(User $user)`, falls API-Zugriff bewusst nur Autor-Ownership meint
|
||||
- alternativ Policies konsequenter nutzen
|
||||
|
||||
**Prioritaet:** Mittel bis hoch
|
||||
|
||||
### 7. Wiederholte API-Resource-Eager-Loads
|
||||
|
||||
**Datei**
|
||||
|
||||
- `app/Http/Controllers/Api/V1/PressReleaseController.php`
|
||||
|
||||
Die gleiche Relation-Liste wird mehrfach geladen:
|
||||
|
||||
- `company:id,name,portal,legacy_portal,legacy_id`
|
||||
- `category.translations`
|
||||
- `images`
|
||||
|
||||
**Empfehlung**
|
||||
|
||||
Eine private Methode oder ein Query-Scope:
|
||||
|
||||
```php
|
||||
private function resourceRelations(): array
|
||||
```
|
||||
|
||||
oder:
|
||||
|
||||
```php
|
||||
PressRelease::query()->withApiResourceRelations()
|
||||
```
|
||||
|
||||
**Prioritaet:** Mittel
|
||||
|
||||
### 8. Grosse Volt-Dateien als langfristiges Wartungsrisiko
|
||||
|
||||
**Beispiele**
|
||||
|
||||
- `resources/views/livewire/admin/press-releases/edit.blade.php` mit ca. 1394 Zeilen
|
||||
- `resources/views/livewire/customer/press-releases/create.blade.php` mit ca. 1371 Zeilen
|
||||
- `resources/views/livewire/customer/press-releases/edit.blade.php` mit ca. 1331 Zeilen
|
||||
- `resources/views/livewire/admin/users/edit.blade.php` mit ca. 1241 Zeilen
|
||||
- `resources/views/livewire/admin/press-releases/index.blade.php` mit ca. 1093 Zeilen
|
||||
|
||||
Volt Single-File-Komponenten sind hier legitim, aber bei dieser Groesse vermischen sich Zustand, Query, Validierung, Aktionen und Markup stark.
|
||||
|
||||
**Empfehlung**
|
||||
|
||||
Nicht pauschal auf klassische Livewire-Klassen migrieren, sondern gezielt extrahieren:
|
||||
|
||||
- Concerns fuer wiederkehrende Interaktionen
|
||||
- Service-Klassen fuer fachliche Aktionen
|
||||
- kleine Blade-Komponenten fuer wiederkehrende UI-Abschnitte
|
||||
- Query-Methoden/Scopes fuer Tabellenfilter
|
||||
|
||||
**Prioritaet:** Mittel
|
||||
|
||||
## Positive Muster, die beibehalten werden sollten
|
||||
|
||||
- `PressRelease::accessibleBy(User $user)` ist ein guter Single-Source-Ansatz fuer Kundenzugriff.
|
||||
- `PortalScope` kapselt Portalfilterung zentral.
|
||||
- `HasUniqueSlug` reduziert Slug-Duplikate.
|
||||
- API nutzt Form Requests und Eloquent Resources.
|
||||
- Dashboard nutzt bereits `AdminPerformanceCache`.
|
||||
- Es gibt viele Feature-Tests fuer Admin, API, Customer und Web.
|
||||
- Erste Concerns wie `InteractsWithPressReleaseTags` zeigen die richtige Richtung.
|
||||
|
||||
## Vorgeschlagene Refactoring-Pakete
|
||||
|
||||
### Paket A: Aufraeumen ohne Fachlogik-Aenderung
|
||||
|
||||
1. `admin/users/table` Route entfernen.
|
||||
2. `resources/views/livewire/admin/users/table.blade.php` entfernen.
|
||||
3. Ungenutzte `resources/views/admin/*` Wrapper entfernen oder eindeutig als Legacy markieren.
|
||||
4. Tests fuer fehlende/entfernte Route anpassen oder ergaenzen.
|
||||
|
||||
**Nutzen:** Weniger Altlasten, weniger Risiko durch erreichbare Demo-Komponente.
|
||||
|
||||
### Paket B: Gemeinsame PM-Editor-Concerns
|
||||
|
||||
1. Admin-PM-Editoren auf `InteractsWithPressReleaseTags` umstellen.
|
||||
2. `InteractsWithPressReleaseScheduling` extrahieren.
|
||||
3. Gemeinsame Rule-Fragmente zentralisieren.
|
||||
4. Bestehende PM-Editor-Tests gezielt laufen lassen.
|
||||
|
||||
**Nutzen:** Weniger Dopplung in vier grossen Dateien, geringere Fehlerwahrscheinlichkeit bei Scheduling.
|
||||
|
||||
### Paket C: Web-Routen entflechten
|
||||
|
||||
1. Portal-/Domain-Kontext extrahieren.
|
||||
2. Published-Release-Query zentralisieren.
|
||||
3. Home/Kategorie/Newsroom-Daten in Services verschieben.
|
||||
4. Routen schrittweise auf kleine Controller umstellen.
|
||||
|
||||
**Nutzen:** Bessere Testbarkeit, klarere Verantwortlichkeiten, zentraleres Performance-Tuning.
|
||||
|
||||
### Paket D: API-Zugriff zentralisieren
|
||||
|
||||
1. `Company::accessibleBy(User $user)` einfuehren.
|
||||
2. API-PressRelease-Zugriff zentral benennen.
|
||||
3. Eager-Load-Sets fuer API Resources zentralisieren.
|
||||
4. API-Feature-Tests laufen lassen.
|
||||
|
||||
**Nutzen:** Weniger Query-Duplikate, klarere Zugriffspolitik.
|
||||
|
||||
## Pruefung / Verifikation
|
||||
|
||||
Die Analyse basiert auf statischer Codepruefung, Laravel-Boost-Projektinformationen, versionierter Dokumentationssuche und Schema-Inspektion. Eine Artisan-Routentabelle konnte nicht ausgefuehrt werden, weil Sail gemeldet hat:
|
||||
|
||||
```text
|
||||
Docker or Podman is not running.
|
||||
```
|
||||
|
||||
## Hinweis zum aktuellen Worktree
|
||||
|
||||
Zum Analysezeitpunkt gab es bereits eine nicht von mir angelegte Aenderung:
|
||||
|
||||
- `resources/views/components/layouts/app/sidebar.blade.php`
|
||||
|
||||
Diese Datei wurde fuer die Analyse nicht veraendert.
|
||||
225
dev/optimize/system-optimierungen-analyse-claude.md
Normal file
225
dev/optimize/system-optimierungen-analyse-claude.md
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
# System-Optimierungs-Analyse (Pressekonto)
|
||||
|
||||
> Erstellt von Claude Code am 2026-06-18. Read-only-Analyse — **es wurden keine Dateien geändert.**
|
||||
> Fokus: Code-Doppelungen, geteilter/wiederverwendbarer Code, Effizienz- und Best-Practice-Potenzial über Controller, Services, Models, Middleware, Livewire/Volt-Komponenten und Blade-Views.
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
1. [Management Summary](#1-management-summary)
|
||||
2. [Top-Prioritäten (höchster Hebel)](#2-top-prioritäten-höchster-hebel)
|
||||
3. [Backend: Controller, Services, Models, Middleware](#3-backend-controller-services-models-middleware)
|
||||
4. [Livewire/Volt: Press-Release-Editoren](#4-livewirevolt-press-release-editoren)
|
||||
5. [Livewire/Volt: Admin-CRUD, Customer, Settings, Auth](#5-livewirevolt-admin-crud-customer-settings-auth)
|
||||
6. [Web-Frontend (gehört dem User, separat committen)](#6-web-frontend-gehört-dem-user-separat-committen)
|
||||
7. [Vorgeschlagene neue Bausteine (Traits/Components/Services)](#7-vorgeschlagene-neue-bausteine)
|
||||
|
||||
---
|
||||
|
||||
## 1. Management Summary
|
||||
|
||||
Das System ist **architektonisch bereits sauber** aufgesetzt: eine umfangreiche Service-Schicht (`app/Services/**`), Domain-/Theme-System, Concerns-Verzeichnis, geteilte Portal-Komponenten (`x-portal.stat-card`, `components.portal.pagination`) und ein `PressReleaseService` existieren. Die Probleme liegen nicht in der Struktur, sondern in **Duplikation an den Rändern**:
|
||||
|
||||
- **Die größten Brocken sind die vier Press-Release-Editoren** (admin + customer, create + edit) mit zusammen ~5.500 Zeilen Blade/PHP, die zu ~80–95 % identische Validierung, Scheduling-Logik, Persistierung und Formular-Markup enthalten.
|
||||
- **Querschnittslogik ist mehrfach implementiert**: Firmenzugriff/Ownership (3–4×), Zahlungsoption-Eligibility (4×), VAT-Normalisierung (3×), Volltextsuche-Fallback (6×), Tabellen-Sortierung/Filter (3×).
|
||||
- **Diverging-Risk (Korrektheit!):** Mehrere dieser Duplikate sind bereits leicht auseinandergedriftet (z. B. Legacy-Invoice-Eligibility liefert bei `null` mal *deny*, mal *needs_review*). Das ist nicht nur Wartungslast, sondern eine latente Fehlerquelle.
|
||||
- **Domain/Theme/Portal-Auflösung** ist über 4 Stellen (Provider + 3 Middleware) verteilt und teils mit unterschiedlichen Theme-Vokabularen.
|
||||
- **Wenige echte Performance-/Security-Punkte**: synchrones API-Logging pro Request, Query-Sturm im Status-Widget, timing-unsicherer Basic-Auth-Vergleich, Race-Conditions bei Wallet-/Rechnungsnummern-Erstellung.
|
||||
|
||||
Mehrheit der Findings ist **risikoarm** (reine Extraktion ohne Verhaltensänderung) bei hohem Zeilen-Einsparpotenzial.
|
||||
|
||||
---
|
||||
|
||||
## 2. Top-Prioritäten (höchster Hebel)
|
||||
|
||||
| # | Maßnahme | Bereich | Severity | Aufwand |
|
||||
|---|----------|---------|----------|---------|
|
||||
| 1 | `InteractsWithPressReleaseScheduling`-Trait + Admin-Editoren nutzen bestehenden Tags-Trait | PR-Editoren | Hoch | Niedrig |
|
||||
| 2 | Persistierung + `formRules()` der PR-Editoren in `PressReleaseService` / geteilten Trait verlagern | PR-Editoren | Hoch | Mittel |
|
||||
| 3 | `Company::scopeAccessibleBy()` / `CompanyPolicy` — Firmenzugriff zentralisieren | Backend | Hoch | Mittel |
|
||||
| 4 | Eine `User::hasActivePaymentOption()` + `latestLegacyInvoice()` (Eligibility-Divergenz beheben) | Backend | Hoch (Korrektheit) | Mittel |
|
||||
| 5 | Domain/Theme/Portal-Auflösung in einen `DomainContext`-Service konsolidieren | Middleware | Hoch | Mittel |
|
||||
| 6 | Concerns: `HasSortableTable`, `HasFullTextSearch`, `HasResettableFilters` | Admin-CRUD | Hoch | Niedrig |
|
||||
| 7 | `<x-portal.alert>` + `<x-session-status>` + `<x-password-input>` / `<x-form-field>` | Admin/Auth | Hoch | Niedrig |
|
||||
| 8 | `Portal::badgeColor()` + `<x-portal.portal-badge>` / `<x-portal.status-badge>` | Querschnitt | Hoch | Niedrig |
|
||||
| 9 | Synchrones `LogApiUsage` → terminable/queued; Status-Widget-Queries memoisieren | Performance | Hoch | Mittel |
|
||||
| 10 | `BasicAuthMiddleware` → `hash_equals()`; `BASIC_AUTH_ENABLED` aus `env()` in Config | Security | Hoch | Niedrig |
|
||||
|
||||
---
|
||||
|
||||
## 3. Backend: Controller, Services, Models, Middleware
|
||||
|
||||
### 3.1 N+1 / Query-Effizienz
|
||||
- **Status-Widget-Query-Sturm (Hoch)** — `app/Services/Customer/CustomerStatusSummary.php:35-66` feuert pro Request viele Einzelqueries (`currentPlan`, `subscription`, `exists()`, `hasActiveBooking()` mit 3 Sub-Queries, offene Single-Purchases). Da das Widget always-on im Header sitzt, summiert sich das. → Sub-Ergebnisse memoisieren/eager-loaden und durchreichen.
|
||||
- **Offene Single-Purchase 3× gezählt (Mittel)** — gleiche `COUNT`-Query in `CustomerStatusSummary.php:61`, `User.php:215`, `User.php:229`. → Einmal berechnen (wie `currentPlan()` memoisiert in `User.php:128-162`).
|
||||
- **`LegacySubscriptionService::cancelBundleFor` (Mittel)** — `app/Services/Billing/LegacySubscriptionService.php:45-54` macht ein `$post->update()` pro Iteration. → Bulk-`update()` oder `DB::transaction`.
|
||||
- **`selectedCompany()` doppelte Query (Mittel)** — `app/Services/Customer/CustomerCompanyContext.php:49` + `153-158`: `exists()` und `findFor()` führen dieselbe Query aus. → Einmal `first()`.
|
||||
- **`LogApiUsage` synchroner INSERT pro Request (Hoch)** — `app/Http/Middleware/LogApiUsage.php:35-46` schreibt bei jedem API-Response. → Terminable Middleware oder Queued Job.
|
||||
|
||||
### 3.2 Duplizierte Querschnittslogik
|
||||
- **Firmen-Ownership-Query 3–4× (Hoch)** — identische `owner_user_id == me OR whereHas('users', me)`-Closure in `Api/V1/PressReleaseController.php:182-191`, `CompanyController.php:18-21` & `:39-42`, `PressReleaseImageController.php:87`; überlappt zusätzlich `User::accessibleCompanyIds()` (`User.php:406-413`) und `CustomerCompanyContext::accessibleCompanyQuery()` (`:143-151`). → Ein `Company::scopeAccessibleBy()` **oder** `CompanyPolicy` (die in `ContactAccessService.php:229` schon referenziert wird).
|
||||
- **Zahlungsoption-Eligibility 4× (Hoch)** — gleicher Status-Set, leicht abweichende Periodenlogik in `Api/ApiAccessEligibilityService.php:28-46`, `CustomerStatusSummary.php:47-52`, `User::hasUnlimitedPressReleaseQuota()` (`User.php:191-196`), `User::hasActiveBooking()` (`User.php:256-261`). Variable `$hasGrandfathered` in `CustomerStatusSummary.php:47` matcht faktisch Active *oder* Grandfathered (Mislabel). → `UserPaymentOption`-Scope + eine `User::hasActivePaymentOption()`.
|
||||
- **Legacy-Invoice-Eligibility divergiert (Hoch — Korrektheit)** — `ApiAccessEligibilityService::hasPaidLegacyInvoice()` (`:48-56`) behandelt `null` als *deny*, `LegacyApiCustomerReporter::classification()` (`:131-142`) als *needs_review*. Beide re-implementieren „latest by invoice_date then id". → Ein `User::latestLegacyInvoice()` + eine Eligibility-Methode.
|
||||
- **VAT-ID-Normalisierung + EL/GR-Mapping 3× (Mittel)** — `VatResolver.php:30,56,63`, `VatIdValidationService.php:37,44,69`, `StripeCheckoutService.php:48,54,56`. → `VatResolver::normalize()` / `isEuCountry()` zentral; `StripeCheckoutService` konsumiert statt selbst herzuleiten.
|
||||
- **Case-insensitive Email-Lookup via `LOWER()` (Mittel)** — `whereRaw('LOWER(email) = ?')` in `ContactAccessService.php:50,130-148,176`, `SocialAuthService.php:32`; defeated den Email-Index. → Beim Schreiben normalisieren + `User::findByEmail()`-Scope.
|
||||
- **Lazy-Account-Erstellung doppelt (Mittel)** — `ContactAccessService::createAccount()` (`:188-208`) ≈ `SocialAuthService::createUser()` (`:65-82`). → gemeinsame Provisioning-Methode.
|
||||
- **Currency-Literal inkonsistent (Mittel)** — `'EUR'` in `CheckoutController.php:66`, `CreditTopupService.php:59`, `ManualInvoiceService.php:125` vs. `'eur'` in `ExtraPmPurchaseService.php:50`, `ProofPdfService.php:69`. → `config('billing.default_currency')`.
|
||||
|
||||
### 3.3 Fat Controller / Validierung / Model
|
||||
- **`Api/V1/PressReleaseController` (Mittel)** — `:201-220` re-implementiert Unique-Slug, obwohl `PressRelease` bereits `HasUniqueSlug` mit `slugScopeAttributes()` nutzt (`PressRelease.php:27,32`). Create/Update/Reclassify-Orchestrierung (`:44-122`) gehört in `PressReleaseService`.
|
||||
- **`LegalRequestController` (Mittel)** — Inline-Validation `:42-57` → `StoreLegalRequestRequest` (Projekt nutzt `app/Http/Requests/Api/V1/*` bereits). Portal-aus-Host `:103-122` dupliziert Portal-Detection; „max 1 open per PM+type"-Regel `:71-91` → `LegalRequestService`.
|
||||
- **`CheckoutController::singlePm` (Niedrig)** — baut `SinglePurchase` inline `:61-67` (hardcoded `'EUR'`) → Service-Methode analog `CreditTopupService::startTopup`.
|
||||
- **Fat `User` Model (Mittel)** — `app/Models/User.php` mischt Auth, Relations, Stripe-Adresse, Plan/Tier, Quota-Mathe, Booking-Gates. → Trait `HasBillingProfile`/`InteractsWithQuota` (`:86-262, 352-355`).
|
||||
|
||||
### 3.4 Authorization → Policies/Gates
|
||||
- **Firmenzugriff umgeht bestehende `CompanyPolicy` (Hoch)** — `CustomerCompanyContext.php:143-151,244-249` + 3 API-Controller (s. 3.2). → über `Gate`/Policy.
|
||||
- **Domain-Auth-Middleware → Gates (Mittel)** — `EnsureUserIsAdmin.php:28`, `EnsureUserIsCustomer.php:13`, `EnsureApiUserIsActive.php:18` rollen `$user?->method()`+abort von Hand; API-Active-Check dupliziert `is_active` aus `canAccessAdmin/Customer` (`User.php:379,392`). → `Gate::define('access-admin'…)` + `can:`-Middleware.
|
||||
- **Impersonation-Logik im Admin-Gate (Mittel)** — `EnsureUserIsAdmin.php:20-26` mischt Impersonation-Redirect in den Access-Check. → eigene Middleware. Zusätzlich totes `loadMissing('roles')` `:16-18` (Niedrig).
|
||||
|
||||
### 3.5 Middleware-Overlap (Domain/Theme/Portal/Edition)
|
||||
- **Vierfach duplizierte Host→Config-Auflösung (Hoch)** — `ThemeServiceProvider::boot` (`:29-88`), `ThemeMiddleware::handle` (`:16-56`), `SetDomainUrl::handle` (`:22-58`), `SetCurrentPortal::resolvePortal` (`:36-55`) lesen alle Host/`app.theme` und schreiben Config; `SetDomainUrl` ruft dieselben `URL::forceRootUrl/forceScheme/useAssetOrigin` erneut. → ein `DomainContext`-Service. *(Hinweis: `SetDomainUrl` scheint nicht in `bootstrap/app.php` registriert — vor Refactor prüfen, ob toter Code.)*
|
||||
- **Disjunkte Theme-Vokabulare (Hoch — Korrektheits-Smell)** — `ThemeMiddleware.php:22-53` kennt `b2in/b2a/stileigentum/style2own`, `SetCurrentPortal.php:50-54` nur `presseecho/businessportal24` (Rest → `null`-Portal). → Theme→Portal-Map an *einer* Stelle (`Portal`-Enum oder `config/domains.php`).
|
||||
- **`ThemeMiddleware` mutiert `REQUEST_URI` (Mittel)** — `:43-52` schreibt URIs um (fragil), `:33-38` re-implementiert `?theme=`-Override mit anderer Whitelist als der Provider. → Route-Prefixes (wie `SetEdition`) + ein Override-Pfad.
|
||||
- **`CurrentPortalContext` mutable static (Mittel)** — `app/Services/CurrentPortalContext.php:15` hält `private static ?Portal` pro Request (Octane-/Test-Risiko). → request-scoped Container-Binding.
|
||||
|
||||
### 3.6 Weitere Korrektheit/Security
|
||||
- **`BasicAuthMiddleware` (Hoch — Security)** — `:45` nutzt `!=` (timing-unsicher) → `hash_equals()`. `bootstrap/app.php:58` liest `env('BASIC_AUTH_ENABLED')` zur Laufzeit → unter `config:cache` `null` ⇒ Basic-Auth still deaktiviert. → Config-Key.
|
||||
- **`CreditWalletService::post` Race (Mittel)** — `:88-89` `firstOrCreate()` (unlocked) dann separates `lockForUpdate()->find()`. → Unique-Index auf `credit_wallets.user_id` + locked Upsert.
|
||||
- **`InvoiceNumberGenerator` Erstzeilen-Kollision (Niedrig/Mittel)** — `:41-63` `->first()` (kein Lock) dann `insert()`; zwei parallele Erstaufrufe ⇒ doppelte `MAN-00001`. → Unique-Index auf `circle` + Retry.
|
||||
- **`LegacyInvoicePdfController` Laufzeit-`Schema::hasColumn` (Niedrig)** — `:26` Schema-Introspektion pro Download. → nach Migration entfernen.
|
||||
- **`ExtraPmPurchaseService` magisches `* 100` (Niedrig)** — `:50` Credit→Cents außerhalb `CreditPricingService`. → in Pricing-Service.
|
||||
|
||||
---
|
||||
|
||||
## 4. Livewire/Volt: Press-Release-Editoren
|
||||
|
||||
> Vier Dateien, ~5.500 Zeilen, ~80–95 % Überschneidung. Größter einzelner Optimierungs-Hebel im Projekt.
|
||||
> `admin/press-releases/{create,edit,index,show}.blade.php`, `customer/press-releases/{create,edit,index,show}.blade.php`.
|
||||
|
||||
### Hoch
|
||||
- **H1 — Scheduling-Maschinerie 4× wortgleich.** `updatedPublishMode/ScheduledDate/ScheduledTime()`, `syncScheduledAt()`, `resolveScheduledAt()`, `validateScheduledAtWhenReady()`, `updated()`, `notifyValidationError()` und der `scheduled*`-Block in `formRules()` sind identisch (admin/create:62-89,169-255; admin/edit:121-143,215-339; customer/create:123-145,216-302; customer/edit:163-185,253-339). → **`InteractsWithPressReleaseScheduling`-Trait** (spiegelt bestehendes `InteractsWithPressReleaseTags`). ~150 Zeilen × 3.
|
||||
- **H2 — `formRules()` ~95 % identisch** (admin/create:152-194; admin/edit:198-240; customer/create:197-241; customer/edit:237-278). Einziger Unterschied: admin `portal`+`companyId exists`, customer `companyId integer`. → `baseRules()` im Trait, Komponenten überschreiben nur `portal`/`companyId`.
|
||||
- **H3 — `presubmitChecks` 4× dupliziert, mit Drift.** 6-Punkt-Checkliste (admin/create:380-425; admin/edit:574-619; customer/create:583-632; customer/edit:561-608). `customer/create` nutzt `trans_choice(...)` + `selectedCompany()?->name`, die anderen drei `__(':n Zeichen')` + statischen Text — versehentliche Copy-Paste-Drift. → eine kanonische Version im Trait.
|
||||
- **H4 — Kontakt-Helfer dupliziert, divergente Query-Styles.** `defaultContactIdFor()`, `companyContacts()`, `companyContact()` in allen vier; admin nutzt `Contact::withoutGlobalScopes()`, customer `Contact::forCompany()`. `tagSuggestionsFor()` identisch. → `tagSuggestionsFor()` in Tags-Trait; Kontakt-Helfer in Trait/Scope.
|
||||
- **H5 — Admin-Editoren nutzen `InteractsWithPressReleaseTags` NICHT.** Customer (`use ...` in create:28, edit:29), Admin inlined `addTag/removeTag/tagsArray` + `tags`-Computed (admin/create:113-144,374-378,430-442; admin/edit:162-193,...). → `use InteractsWithPressReleaseTags;` + ~35 Zeilen je Datei löschen.
|
||||
|
||||
### Mittel
|
||||
- **M1 — Persistierung dupliziert; Customer zeigt das bessere Muster.** Customer zentralisiert in `persistDraft()`/`persistEdit()` + `pressReleaseAttributes()` (customer/create:521-543); Admin inlined volle `PressRelease::create([...])`/`update([...])` (admin/create:310-328; admin/edit:355-370). Slug + `HtmlSanitizer::clean()` + `contacts()->sync()` + `contentChanged`→reclassify/rescore (admin/edit:383-388; customer/edit:435-440,497-502) überall wiederholt. → `PressReleaseService::createDraft()` / `updateContent()`.
|
||||
- **M2 — Firmen-Combobox-Suche dupliziert, zwei Backends.** Admin baut Query inline in `with()` (admin/create:344-360, admin/edit:526-542), customer delegiert an `CustomerCompanyContext::searchCompaniesFor()`. `supportsFullTextSearch()` kopiert (admin/create:493-496, admin/edit:687-690, admin/index:384-388). → `Company`-Scope `searchByTerm()`.
|
||||
- **M3 — Formular-Markup ~85 % identisch** (Firma-Selektor, Titel mit Char-Meter `@php`, Untertitel, Fließtext-Meter, Boilerplate, Tag-Input-Chips, Veröffentlichungs-Panel, Pressekontakt, Pre-Submit-Check). → Blade-Components: `<x-portal.pr-char-meter>`, `<x-portal.pr-tag-input>`, `<x-portal.pr-presubmit-check>`, `<x-portal.pr-publish-schedule>`, `<x-portal.pr-contact-select>`, `<x-portal.pr-boilerplate-panel>`. **Größte Roh-Zeilenersparnis.**
|
||||
- **M4 — Listenzeilen admin vs. customer dupliziert** (admin/index:836-968 ≈ customer/index:421-531). Filter-Chips mit inline-`<svg>` ~9× in admin/index:662-801, 3× customer/index:320-376. → `<x-portal.pr-table-row>` + `<x-portal.filter-chip>`.
|
||||
- **M5 — Show-Seiten duplizieren Status-Timeline + Rejection-Banner** (admin/show:223-251,431-472 ≈ customer/show:345-370,738-771). `statusColor`/`$statusClass`-`match` an 4+ Stellen. → Mappings auf `PressReleaseStatus`-Enum (`badgeClass()`/`color()`) + `<x-portal.pr-status-timeline>`, `<x-portal.pr-rejection-banner>`.
|
||||
- **M6 — Status-Aktionen dupliziert** (`publish/reject/archive` in admin/index:163-215, admin/edit:446-486, admin/show:25-68). `withoutGlobalScopes()->findOrFail($id)` ~10× in Admin-Code. → `HandlesAdminPressReleaseActions`-Trait + `findAdminPR()`.
|
||||
|
||||
### Niedrig
|
||||
- **L1** — `categoryOptions()` admin gecacht (`AdminPerformanceCache`), customer uncached pro Render (customer/create:557-561, customer/edit:531-535). → gemeinsamer Helfer + Cache.
|
||||
- **L2** — Kategorie-Anzeigename `->translations->firstWhere('locale','de')?->name ?? ...` ~10× → `Category::localizedName()`.
|
||||
- **L3** — Kontakt-Name `trim(first.' '.last) ?: ...` mehrfach → `Contact::displayName()`.
|
||||
- **L4** — `statusColor` aus `with()` (admin/edit:558-564, admin/show:111-117) scheinbar ungenutzt (Blade nutzt lokales `$statusClass`). → prüfen/entfernen.
|
||||
- **L5** — Image-/Attachment-Manager teilen `getPressRelease()`/`canChange*()` (images-manager:257-269, attachments-manager:214-239). Attachments aktuell deaktiviert → bei Reaktivierung `ManagesPressReleaseMedia`-Trait; `canChange*` gehört in `PressReleasePolicy`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Livewire/Volt: Admin-CRUD, Customer, Settings, Auth
|
||||
|
||||
### Tabellen-Plumbing → Concerns (Hoch)
|
||||
- **`sort($column)` wortgleich** in `admin/users:39-48`, `companies/index:45-54`, `contacts/index:51-60`. → `HasSortableTable` (`$sortBy`/`$sortDir`/`sort()` + Whitelist-Helfer).
|
||||
- **`supportsFullTextSearch()` 6×+ byte-gleich** (users/edit:627-631, users/create:216-220, companies/index:169-173, contacts/index:262-266, contacts/edit:143-147, companies/show:178-182) + „whereFullText else multi-LIKE"-Block mehrfach. → `HasFullTextSearch` + `applyFuzzySearch($query,$columns,$term)`.
|
||||
- **`updatedXxxFilter()` nur `resetPage()`** (users:285-313 = 6 Methoden, companies/index:175-227, contacts/index:62-90). → `HasResettableFilters` + `filterProperties()`-Array.
|
||||
- **`stats()` mit `SUM(CASE WHEN is_active...)`** strukturidentisch (users:187-203, companies/index:122-141). → `AdminPerformanceCache::activeInactiveStats()`.
|
||||
- **Count-Hydration** (users:205-218 `hydrateCompanyCounts`, companies/index:143-167 `hydrateCounts`). → `hydrateRelationCounts($paginator, $map)`.
|
||||
|
||||
### Create vs. Edit Duplikation (Hoch)
|
||||
- **User create/edit teilen Rule-Sets + Billing/Company-Logik** (create:110-130 ⊂ edit:377-421; `billingIsComplete()` create:222-229 = edit:708-715; `availableRoles()` create:209-214 = edit:620-625). `save()` trägt ~175 Zeilen Business-Logik (edit:375-550). → `UserFormRules`-Provider + `UserUpsertService`/`SaveUserAction`.
|
||||
- **Billing-/Company-/Role-Markup dupliziert** (Rechnungsadresse create:386-436 ≈ edit:1177-1228; Company-Combobox create:322-376 ≈ edit:1003-1069; Rollen create:261-313 ≈ edit:842-995). → `<x-admin.billing-fields>`, `<x-admin.company-link-picker>`, `<x-admin.role-checkboxes>`.
|
||||
- **Kontakt-Validierung/Markup** (users/edit:406-412, contacts/edit, customer/profile:147-168, press-kits/show:169-175). → `ValidatesContactData`-Concern + `<x-contact-fields>` (admin+customer geteilt).
|
||||
|
||||
### Formatierungs-Helfer → Components/Enum (Hoch/Mittel)
|
||||
- **`portalBadgeColor(?Portal)` 3–4×** (companies/index:229-237, companies/show:184-192, contacts/index:383-391, contacts/edit:170-178). → `Portal::badgeColor()` + `<x-portal.portal-badge>`.
|
||||
- **Status-Badge (ok/err-dot)** (companies/index:519-524, users:558-564, customer press-kits). → `<x-portal.status-badge :active>`.
|
||||
- **`pressReleaseStatusColor()`** (users:161-170) → auf `PressReleaseStatus`-Enum.
|
||||
- **`formatEuro()`** (customer/bookings:36-39) → `<x-portal.money>` / Helfer.
|
||||
- **Datum `?->format('d.m.Y H:i') ?? '–'`** überall → `<x-portal.datetime :value>`.
|
||||
|
||||
### Seiten-Struktur → Components (Mittel)
|
||||
- **Page-Header-Block** (users:318-337, users/create:234-253, users/edit:720-746, companies/index:301-320, contacts/index:395-421, payments/index:158-180, companies/show:215-268, companies/edit:199-220). → `<x-portal.page-header :eyebrow :title :subtitle>` mit Action-Slot.
|
||||
- **KPI-Grid-Wrapper** (users:340-353, companies/index:323-335). → `<x-portal.stat-cards :stats>`.
|
||||
- **Filter-Panel + flux:table + Pagination-Footer** (users:599-601, companies/index:588-590, payments 4×). → `<x-portal.table-panel>` / `<x-portal.table-pagination :paginator>`.
|
||||
- **Empty-State-Zelle** (users:582-594, companies/index:569-584). → `<x-portal.empty-state>`.
|
||||
- **Combobox-Pattern** (`flux:select variant=combobox` + Clear-Button: users/create:322-352, users/edit:1003-1043,1074-1121, companies/index:369-445, companies/show:399-432, contacts/edit:211-243). → `<x-portal.searchable-select>`.
|
||||
|
||||
### Flash/Notification (Hoch)
|
||||
- **Auto-Dismiss-Toast** (`x-data="{show:true}" x-init=setTimeout`) in users/edit:748-754, contacts/index:423-437, payments/index:182-196; Session-Varianten companies/show:196-213.
|
||||
- **`@if(session('status'))`** in auth/login:128-132, register:57-61, forgot-password:30-34, verify-email:33-37, magic-link:87-91; settings nutzen `<x-action-message>`. → ein `<x-portal.alert :type :dismissible>` + `<x-session-status>` app-weit.
|
||||
|
||||
### Auth & Settings (Hoch/Mittel)
|
||||
- **Password show/hide-Feld** (auth/login:134-180, register:63-143, confirm-password:40-72, reset-password:73-135) → `<x-password-input>` (Hoch).
|
||||
- **Email-Input + `@error`-Markup** in allen Auth-Forms → `<x-form-field>`/`<x-email-input>`.
|
||||
- **Rate-Limiting inline & divergent** (auth/login:100-124, magic-link:54-83) → `ensureNotRateLimited()`/`throttleKey()`-Trait.
|
||||
- **Heavy Auth-Logik in Volt** (2FA/Activation/Redirect: auth/login:28-95, settings/profile:19-73) → Fortify-Actions/Response-Contracts (architektonisch).
|
||||
- **Divider + Secondary-Action** (auth/login:196-218 ≈ register:174-192) → `<x-auth.divider-actions>`.
|
||||
|
||||
### Customer-Bereich (Mittel/Niedrig)
|
||||
- Portal-Pill/Status-Badge wiederholt (press-kits/index:669-685,778-786, dashboard:352-358, press-kits/show:298-307) → dieselben `<x-portal.portal-badge>`/`<x-portal.status-badge>`.
|
||||
- Company-Logo-Logik (`logoVariant/logoInitials/logoUrl`, press-kits/index:221-259,645-650,760-766) → `<x-portal.company-logo :company>`.
|
||||
- Inline-Edit-Form-Toggles (`showCompanyForm`/`showContactForm`) in customer/profile, press-kits/show → `HasInlineFormModals`-Concern.
|
||||
- Onboarding-Step-Grid (dashboard:382-398, press-kits/index:611-630) → `<x-portal.step-guide :steps>`.
|
||||
|
||||
> **Bereits gute Wiederverwendung:** `<x-portal.stat-card>` und `components.portal.pagination` werden konsistent genutzt — als Muster für die neuen Components übernehmen.
|
||||
|
||||
---
|
||||
|
||||
## 6. Web-Frontend (gehört dem User, separat committen)
|
||||
|
||||
> **Hinweis:** `resources/views/web/**` und `resources/views/components/web/**` gehören laut Projekt-Memory dem User und werden separat committet. Findings sind beratend; nicht in Feature-Commits bündeln. *(Vor jedem Löschen vom User bestätigen lassen — „unused"-Zählungen stammen aus `grep` über `x-web.<name>` und prüfen keine dynamische Komponenten-Auflösung.)*
|
||||
|
||||
### Hoch
|
||||
- **Pricing-Cards 3× handcodiert** in `web/pressekonto.blade.php:401-466,469-535,538-~610` — fast identische ~67-Zeilen-`<article>`s, derselbe Check/Dash/Cross-SVG ~15×. **Es existiert bereits ein datengetriebenes `<x-web.pricing-teaser>`** (`components/web/pricing-teaser.blade.php`, iteriert `Plan::active()`), das diese Seite ignoriert. → `pricing-teaser` einsetzen; ~200 Zeilen → eine Schleife.
|
||||
- **`press-release-card` vs. `presseecho-release-card` ~95 % identisch** (je ~160 Zeilen; nur Default-`$contentTypeClasses`, Gradient-Klassen, Badge unterscheiden sich). `presseecho-release-card` hat **0 Verwendungen**. → vereinheitlichen auf eine Komponente mit `variant`-Prop oder löschen.
|
||||
|
||||
### Mittel
|
||||
- **Industrie-Icon-Map 3× dupliziert** (press-release-card:15-31, presseecho-release-card:15-31, highlight-card:5-22). → in `CategoryService` (zentralisiert bereits `getIconPath()`) oder Config.
|
||||
- **Release-Normalisierung 3×** (feed-item:8-50, feed-top-item:6-36, focus-side-item:8-33): gleiche Kategorie-/Firma-/Datum-/Bild-`@php`-Blöcke; „verified"-SVG verbatim. → View-Model `ReleaseCardData::from($release)` + `<x-web.icons.verified-badge>`.
|
||||
- **Footer standard vs. hub** (site-footer:1-82 ≈ hub/site-footer:1-82) → ein `<x-web.site-footer>` mit `columns`+`variant`.
|
||||
- **Top-Utility-Bar** inline in site-header:47-93 vs. komponentisiert in hub/top-utility-bar:1-55 → gemeinsame `<x-web.top-utility-bar>`.
|
||||
- **Tote, array-getriebene Legacy-Cards** (0 Produktiv-Verwendung, nur `web/examples/`): `presseecho-release-card`, `press-release-list-item`, `dossier-card`, `article-card`, `highlight-card`+`highlights-slider`, `main-navigation`. → nach Bestätigung mitsamt `web/examples/` entfernen.
|
||||
- **Inline-SVGs** (CTA-Pfeil `M2 6h8…` in 5 Dateien, Building-Icon, verified-Check) → kleines `components/web/icons/`-Set.
|
||||
- **Inline-Array-`@foreach`-Sektionen** in `veroeffentlichen.blade.php:196-211,227-249,265-280,293-310,383-405` → `<x-web.criteria-item>` + statische Arrays in View-Data.
|
||||
|
||||
### Niedrig
|
||||
- Layout-Shell `max-w-layout mx-auto px-8` ~20× in pressekonto.blade.php → `<x-web.container>` oder `.layout-container`-Utility (wo `.card`/`.badge` schon liegen).
|
||||
- Such-Form-Block doppelt in site-header (Desktop :110-130, Mobile :196-226) → `<x-web.search-form :variant>`.
|
||||
- Sidebar-Familie (`sidebar-widget` + `-categories/-companies/-newsletter/-rss`) ist **gut faktorisiert**, aktuell aber 0 Produktiv-Verwendung — vor Behalten prüfen.
|
||||
|
||||
---
|
||||
|
||||
## 7. Vorgeschlagene neue Bausteine
|
||||
|
||||
**Traits/Concerns** (`app/Livewire/Concerns/`, `app/Models/Concerns/`)
|
||||
- `InteractsWithPressReleaseScheduling`, `HandlesAdminPressReleaseActions`, `ManagesPressReleaseMedia`
|
||||
- `HasSortableTable`, `HasFullTextSearch`, `HasResettableFilters`, `ValidatesContactData`, `HasInlineFormModals`
|
||||
- `User`: `HasBillingProfile` / `InteractsWithQuota`
|
||||
|
||||
**Services** (`app/Services/**`)
|
||||
- `PressReleaseService::createDraft()` / `updateContent()` (Persistierung + reclassify/rescore)
|
||||
- `UserUpsertService`, `LegalRequestService`
|
||||
- `DomainContext` (konsolidiert Theme/Portal/Domain-Auflösung)
|
||||
- Zentralisierung in bestehenden Services: `VatResolver::normalize/isEuCountry`, `User::hasActivePaymentOption/latestLegacyInvoice/findByEmail`
|
||||
|
||||
**Query-Scopes / Enum-Methoden**
|
||||
- `Company::scopeAccessibleBy()`, `Company::scopeSearchByTerm()`, `Contact::scopeForCompany()` (vereinheitlicht), `UserPaymentOption`-Active-Scope
|
||||
- `Portal::badgeColor()`, `PressReleaseStatus::badgeClass()/color()`, `Category::localizedName()`, `Contact::displayName()`
|
||||
|
||||
**Policies/Gates**
|
||||
- `CompanyPolicy` durchgängig nutzen; `Gate::define('access-admin'|'access-customer')` + `can:`-Middleware
|
||||
|
||||
**Blade-Components**
|
||||
- Portal: `pr-char-meter`, `pr-tag-input`, `pr-presubmit-check`, `pr-publish-schedule`, `pr-contact-select`, `pr-boilerplate-panel`, `pr-table-row`, `pr-status-timeline`, `pr-rejection-banner`, `filter-chip`, `alert`, `page-header`, `stat-cards`, `table-panel`, `table-pagination`, `searchable-select`, `empty-state`, `portal-badge`, `status-badge`, `money`, `datetime`, `company-logo`, `step-guide`
|
||||
- Admin: `billing-fields`, `company-link-picker`, `role-checkboxes`
|
||||
- Auth/Shared: `password-input`, `form-field`, `session-status`, `contact-fields`, `auth.divider-actions`
|
||||
|
||||
---
|
||||
|
||||
### Empfohlenes Vorgehen
|
||||
1. **Risikoarme Extraktionen zuerst** (Traits H1/H5, Concerns 6, Components 7/8 oben) — reine Deduplikation ohne Verhaltensänderung, jeweils mit bestehenden Pest-Tests absichern.
|
||||
2. **Korrektheits-Divergenzen** (Eligibility 3.2, Theme-Vokabular 3.5, Security 3.6) — vor Refactor jeweils einen Test schreiben, der das aktuelle Soll-Verhalten festschreibt.
|
||||
3. **Persistierung in Services** (M1, UserUpsertService) — mittlerer Aufwand, größter architektonischer Gewinn.
|
||||
4. **Web-Frontend** separat mit dem User abstimmen.
|
||||
97
tests/Feature/ApiAccessEligibilityServiceTest.php
Normal file
97
tests/Feature/ApiAccessEligibilityServiceTest.php
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\UserPaymentOptionStatus;
|
||||
use App\Models\LegacyInvoice;
|
||||
use App\Models\User;
|
||||
use App\Models\UserPaymentOption;
|
||||
use App\Services\Api\ApiAccessEligibilityService;
|
||||
|
||||
function makeLegacyInvoice(User $user, string $status, string $invoiceDate): LegacyInvoice
|
||||
{
|
||||
return LegacyInvoice::query()->create([
|
||||
'legacy_portal' => 'businessportal24',
|
||||
'legacy_id' => fake()->unique()->numberBetween(1, 1_000_000),
|
||||
'user_id' => $user->id,
|
||||
'legacy_user_id' => $user->id,
|
||||
'number' => 'LEG-'.fake()->unique()->numberBetween(1, 1_000_000),
|
||||
'total_cents' => 4900,
|
||||
'status' => $status,
|
||||
'invoice_date' => $invoiceDate,
|
||||
'paid_at' => $status === 'paid' ? $invoiceDate : null,
|
||||
'imported_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
test('latestLegacyInvoice returns newest by invoice_date then id', function () {
|
||||
$user = User::factory()->create();
|
||||
makeLegacyInvoice($user, 'paid', '2024-01-01');
|
||||
$newest = makeLegacyInvoice($user, 'reminder', '2024-06-01');
|
||||
makeLegacyInvoice($user, 'paid', '2024-03-01');
|
||||
|
||||
expect($user->latestLegacyInvoice()->id)->toBe($newest->id);
|
||||
});
|
||||
|
||||
test('token gate grants access when latest legacy invoice is paid', function () {
|
||||
$user = User::factory()->create(['is_active' => true]);
|
||||
makeLegacyInvoice($user, 'paid', '2024-01-01');
|
||||
|
||||
expect((new ApiAccessEligibilityService)->canCreateToken($user))->toBeTrue();
|
||||
});
|
||||
|
||||
test('token gate denies when latest legacy invoice is unpaid even if an older one was paid', function () {
|
||||
$user = User::factory()->create(['is_active' => true]);
|
||||
makeLegacyInvoice($user, 'paid', '2024-01-01');
|
||||
makeLegacyInvoice($user, 'reminder', '2024-06-01');
|
||||
|
||||
expect((new ApiAccessEligibilityService)->canCreateToken($user))->toBeFalse();
|
||||
});
|
||||
|
||||
test('token gate denies when there is no legacy invoice and no active payment option', function () {
|
||||
$user = User::factory()->create(['is_active' => true]);
|
||||
|
||||
expect((new ApiAccessEligibilityService)->canCreateToken($user))->toBeFalse();
|
||||
});
|
||||
|
||||
test('hasActivePaymentOption is period-aware for active options', function () {
|
||||
$user = User::factory()->create(['is_active' => true]);
|
||||
|
||||
UserPaymentOption::factory()->for($user)->create([
|
||||
'status' => UserPaymentOptionStatus::Active->value,
|
||||
'current_period_start' => now()->subDay(),
|
||||
'current_period_end' => now()->addDay(),
|
||||
]);
|
||||
expect($user->hasActivePaymentOption())->toBeTrue();
|
||||
|
||||
$user->userPaymentOptions()->update([
|
||||
'current_period_start' => now()->subMonths(2),
|
||||
'current_period_end' => now()->subMonth(),
|
||||
]);
|
||||
expect($user->fresh()->hasActivePaymentOption())->toBeFalse();
|
||||
});
|
||||
|
||||
test('hasActivePaymentOption is period-aware for grandfathering', function () {
|
||||
$user = User::factory()->create(['is_active' => true]);
|
||||
|
||||
$option = UserPaymentOption::factory()->for($user)->create([
|
||||
'status' => UserPaymentOptionStatus::Grandfathered->value,
|
||||
'grandfathered_until' => now()->addMonth(),
|
||||
]);
|
||||
expect($user->hasActivePaymentOption())->toBeTrue();
|
||||
|
||||
$option->update(['grandfathered_until' => now()->subDay()]);
|
||||
expect($user->fresh()->hasActivePaymentOption())->toBeFalse();
|
||||
});
|
||||
|
||||
test('booking and quota gates are period-aware when enforcement is on', function () {
|
||||
config()->set('billing.enforce_booking', true);
|
||||
|
||||
$user = User::factory()->create(['is_active' => true]);
|
||||
UserPaymentOption::factory()->for($user)->create([
|
||||
'status' => UserPaymentOptionStatus::Active->value,
|
||||
'current_period_start' => now()->subMonths(2),
|
||||
'current_period_end' => now()->subMonth(),
|
||||
]);
|
||||
|
||||
expect($user->hasActiveBooking())->toBeFalse()
|
||||
->and($user->hasUnlimitedPressReleaseQuota())->toBeFalse();
|
||||
});
|
||||
59
tests/Feature/BasicAuthMiddlewareTest.php
Normal file
59
tests/Feature/BasicAuthMiddlewareTest.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Middleware\BasicAuthMiddleware;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('auth.basic.enabled', true);
|
||||
config()->set('auth.basic.user', 'staging');
|
||||
config()->set('auth.basic.password', 'secret');
|
||||
});
|
||||
|
||||
function handleBasicAuth(Request $request): Response
|
||||
{
|
||||
return (new BasicAuthMiddleware)->handle($request, fn () => response('ok'));
|
||||
}
|
||||
|
||||
test('rejects request without credentials', function () {
|
||||
$response = handleBasicAuth(Request::create('/dashboard', 'GET'));
|
||||
|
||||
expect($response->getStatusCode())->toBe(401)
|
||||
->and($response->headers->get('WWW-Authenticate'))->toBe('Basic');
|
||||
});
|
||||
|
||||
test('rejects request with wrong password', function () {
|
||||
$request = Request::create('/dashboard', 'GET', server: [
|
||||
'PHP_AUTH_USER' => 'staging',
|
||||
'PHP_AUTH_PW' => 'wrong',
|
||||
]);
|
||||
|
||||
expect(handleBasicAuth($request)->getStatusCode())->toBe(401);
|
||||
});
|
||||
|
||||
test('passes request with correct credentials', function () {
|
||||
$request = Request::create('/dashboard', 'GET', server: [
|
||||
'PHP_AUTH_USER' => 'staging',
|
||||
'PHP_AUTH_PW' => 'secret',
|
||||
]);
|
||||
|
||||
expect(handleBasicAuth($request)->getStatusCode())->toBe(200);
|
||||
});
|
||||
|
||||
test('skips gate entirely when disabled', function () {
|
||||
config()->set('auth.basic.enabled', false);
|
||||
|
||||
expect(handleBasicAuth(Request::create('/dashboard', 'GET'))->getStatusCode())->toBe(200);
|
||||
});
|
||||
|
||||
test('skips gate when credentials are not configured', function () {
|
||||
config()->set('auth.basic.user', null);
|
||||
config()->set('auth.basic.password', null);
|
||||
|
||||
expect(handleBasicAuth(Request::create('/dashboard', 'GET'))->getStatusCode())->toBe(200);
|
||||
});
|
||||
|
||||
test('skips basic auth for api and livewire routes', function () {
|
||||
expect(handleBasicAuth(Request::create('/api/v1/press-releases', 'GET'))->getStatusCode())->toBe(200)
|
||||
->and(handleBasicAuth(Request::create('/livewire/update', 'POST'))->getStatusCode())->toBe(200);
|
||||
});
|
||||
|
|
@ -125,6 +125,7 @@ test('a grandfathered legacy user sees the bestandsschutz package with portal an
|
|||
'user_id' => $user->id,
|
||||
'status' => UserPaymentOptionStatus::Grandfathered->value,
|
||||
'current_period_end' => now()->addMonths(3),
|
||||
'grandfathered_until' => now()->addYear(),
|
||||
'legacy_conditions' => [
|
||||
'name' => 'Presseverteiler Premium',
|
||||
'legacy_portal' => 'presseecho',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Services\Billing\InvoiceNumberGenerator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
test('numbers are sequential per circle and circles are independent', function () {
|
||||
$generator = app(InvoiceNumberGenerator::class);
|
||||
|
|
@ -16,3 +17,15 @@ test('an unknown circle is rejected', function () {
|
|||
expect(fn () => app(InvoiceNumberGenerator::class)->next('XXX'))
|
||||
->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
|
||||
test('an existing sequence row is not reset by the race-safe ensure-insert', function () {
|
||||
DB::table('invoice_number_sequences')->insert([
|
||||
'circle' => InvoiceNumberGenerator::CIRCLE_MANUAL,
|
||||
'next_number' => 5,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
expect(app(InvoiceNumberGenerator::class)->nextManualNumber())->toBe('MAN-00005');
|
||||
expect(DB::table('invoice_number_sequences')->where('circle', 'MAN')->count())->toBe(1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ function grandfatheredPost(User $user, string $portal, int $netCents = 100000):
|
|||
return UserPaymentOption::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'status' => UserPaymentOptionStatus::Grandfathered->value,
|
||||
'grandfathered_until' => now()->addYear(),
|
||||
'stripe_subscription_id' => null,
|
||||
'cancelled_at' => null,
|
||||
'legacy_conditions' => [
|
||||
|
|
|
|||
|
|
@ -162,6 +162,7 @@ test('a grandfathered bestandskunde does not see the booking-required banner', f
|
|||
UserPaymentOption::factory()->create([
|
||||
'user_id' => $customer->id,
|
||||
'status' => UserPaymentOptionStatus::Grandfathered->value,
|
||||
'grandfathered_until' => now()->addYear(),
|
||||
'stripe_subscription_id' => null,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ test('a grandfathered bestandskunde is labelled and counts as active', function
|
|||
UserPaymentOption::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'status' => UserPaymentOptionStatus::Grandfathered->value,
|
||||
'grandfathered_until' => now()->addYear(),
|
||||
'stripe_subscription_id' => null,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ test('a grandfathered legacy user has an unlimited quota', function () {
|
|||
UserPaymentOption::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'status' => UserPaymentOptionStatus::Grandfathered->value,
|
||||
'grandfathered_until' => now()->addYear(),
|
||||
]);
|
||||
|
||||
expect($user->pressReleaseQuotaRemaining())->toBeNull();
|
||||
|
|
|
|||
15
tests/Unit/PortalFromThemeTest.php
Normal file
15
tests/Unit/PortalFromThemeTest.php
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\Portal;
|
||||
|
||||
test('maps web themes to their portal', function () {
|
||||
expect(Portal::fromTheme('presseecho'))->toBe(Portal::Presseecho)
|
||||
->and(Portal::fromTheme('businessportal24'))->toBe(Portal::Businessportal24);
|
||||
});
|
||||
|
||||
test('admin and hub themes resolve to no portal filter', function () {
|
||||
expect(Portal::fromTheme('portal'))->toBeNull()
|
||||
->and(Portal::fromTheme('pressekonto'))->toBeNull()
|
||||
->and(Portal::fromTheme('main'))->toBeNull()
|
||||
->and(Portal::fromTheme(null))->toBeNull();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue