WS-8: N+1 entschaerfen und PM-Zugriffsmuster konsolidieren

- User::currentPlan() und hasUnlimitedPressReleaseQuota() pro Request
  memoisiert. Beide werden je Render mehrfach abgefragt (Status-Widget auf
  jeder Seite, Buchungsseite, Quota-Methoden) und loesten bisher jeweils
  eigene subscription-/Plan-/payment-option-Queries aus. fresh() setzt den
  Cache zurueck.
- show.blade.php: getMyPR() war NICHT memoisiert und wurde pro Request bis
  zu 7x mit voller Eager-Load-Query aufgerufen -> jetzt memoisiert. Firmen-
  Kennzahlen direkt per withCount() eager geladen statt nachtraeglichem
  loadCount().
- Dupliziertes Zugriffsmuster (withoutGlobalScopes + user_id/company_id) aus
  edit/show/index in PressRelease::scopeAccessibleBy(User) zentralisiert
  (Single Source of Truth fuer Kunden-PM-Zugriff), mit Tests abgesichert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-18 13:27:39 +00:00
parent c43ef6a5bd
commit 3da335388e
6 changed files with 139 additions and 38 deletions

View file

@ -184,6 +184,28 @@ class PressRelease extends Model
return $query->whereHas('boosts', fn (Builder $q) => $q->active());
}
/**
* Beschränkt auf PMs, die dem User gehören (als Autor) oder zu einer ihm
* zugeordneten Firma. Bewusst ohne Portal-GlobalScope: ein Kunde darf
* seine eigenen PMs portalübergreifend sehen und bearbeiten.
*
* Single Source of Truth für den Kunden-Zugriff auf PMs — genutzt von der
* Übersicht, Detail-, Bearbeiten- und Nachweis-Ansicht.
*/
public function scopeAccessibleBy(Builder $query, User $user): Builder
{
return $query->withoutGlobalScopes()
->where(function (Builder $q) use ($user): void {
$q->where('user_id', $user->id);
$companyIds = $user->accessibleCompanyIds();
if ($companyIds !== []) {
$q->orWhereIn('company_id', $companyIds);
}
});
}
/**
* Display-ready text. Returns sanitized HTML for Phase-7+ PMs and
* <p>/<br>-wrapped legacy plain text for older imports.

View file

@ -117,25 +117,45 @@ class User extends Authenticatable implements MustVerifyEmail
return (bool) $this->billingAddress?->isComplete();
}
/**
* Per-Request-Memoisierung: Tarif und Unlimited-Flag werden pro Render
* mehrfach abgefragt (u. a. vom immer sichtbaren Status-Widget und der
* Buchungsseite). Beide ändern sich innerhalb eines Requests nicht über
* In-App-Aktionen (Abo läuft über Stripe-Redirect, Bestandstarif über
* CLI), daher ist die Memoisierung sicher; `fresh()` liefert eine neue
* Instanz und setzt den Cache zurück.
*/
private bool $currentPlanResolved = false;
private ?Plan $cachedCurrentPlan = null;
private ?bool $cachedHasUnlimitedQuota = null;
/**
* Der Tarif des aktiven Stripe-Abos, aufgelöst über die in `plans`
* gepflegten Stripe-Preis-IDs. Null ohne (gültiges) Abo.
*/
public function currentPlan(): ?Plan
{
if ($this->currentPlanResolved) {
return $this->cachedCurrentPlan;
}
$this->currentPlanResolved = true;
$subscription = $this->subscription();
if (! $subscription?->valid()) {
return null;
return $this->cachedCurrentPlan = null;
}
$priceId = $subscription->stripe_price;
if (! $priceId) {
return null;
return $this->cachedCurrentPlan = null;
}
return Plan::query()
return $this->cachedCurrentPlan = Plan::query()
->where('stripe_price_id_monthly', $priceId)
->orWhere('stripe_price_id_yearly', $priceId)
->first();
@ -160,11 +180,15 @@ class User extends Authenticatable implements MustVerifyEmail
*/
public function hasUnlimitedPressReleaseQuota(): bool
{
if (! config('billing.enforce_booking')) {
return true;
if ($this->cachedHasUnlimitedQuota !== null) {
return $this->cachedHasUnlimitedQuota;
}
return $this->userPaymentOptions()
if (! config('billing.enforce_booking')) {
return $this->cachedHasUnlimitedQuota = true;
}
return $this->cachedHasUnlimitedQuota = $this->userPaymentOptions()
->whereIn('status', [
UserPaymentOptionStatus::Active->value,
UserPaymentOptionStatus::Grandfathered->value,