Block 3 — Sichtbarkeit, Löschung, Cache, Eingaben F04: withoutGlobalScopes() ohne Argument entfernte neben dem PortalScope auch den Soft-Delete-Filter. Öffentliche, Kunden-, API-, Vorschau- und Medienpfade sowie die PressRelease-Helfer schreiben jetzt withoutGlobalScope(PortalScope::class). Zwei Stellen, die der Audit nicht kannte: die öffentliche Meldungsseite zeigte gelöschte Firmenkontakte mit Name, Mail und Telefon; das Pressefach listete gelöschte Kontakte weiter und ließ sie bearbeiten. Ein Architekturtest friert die 39 bewusst verbliebenen Stellen (Admin, Importer, Befehle, Slug-Eindeutigkeit) ein. Der Vorschau-Test prüfte bisher forceDelete statt delete. F22: LOWER(email) machte auf companies (110k Zeilen) bei jedem Login einen Vollscan; die Kollation ist ohnehin case-insensitiv. EmailQuery vergleicht auf MySQL direkt über den Index, auf SQLite (Tests) weiter per LOWER. Neuer Index contacts_email_idx. F13: Firmen-Observer verwirft die öffentlichen Seiten-Caches bei Löschung, Wiederherstellung und is_active-Wechsel auf jedem Weg; die Newsroom-Seite prüft Sichtbarkeit vor dem Cache-Treffer; Kontakt- Änderungen im Pressefach flushen. Der Tombstone-Test wärmte den Cache vorher nicht und traf den Fehler nie. F12: Gemeldete URLs werden über Host, Ausgabe und Slug aufgelöst (ReportedUrlResolver); fremde Hosts zählen nicht, bei Doppelveröffentlichung gewinnt die Erstquelle. F11: per_page 1–100, status als Enum, language nur de/en, Kategorie muss aktiv sein und zum Ziel-Portal passen. Legacy-Aliasse behalten ihren Vertrag. F23: Rückgabewert von put() wird geprüft; Logo erst speichern, dann Datenbank, dann altes Logo löschen. Fehler enden als Feldfehler, nicht als verlorenes Logo. Block 4 — KI-Prüfung und Scheduler F07: Klassifikation und Score tragen die Prüfsumme des Inhalts bei Einplanung. Weicht sie beim Speichern ab, wird das Ergebnis verworfen (ki_audits.discarded_at) und ein neuer Lauf eingeplant. Der Scheduler veröffentlicht keine Meldung, deren jüngste Klassifikation nicht zum aktuellen Text passt, sondern plant eine Prüfung mit Routing ein. F18: Kontingent an den Kalendermonat gebunden (users.press_release_quota_period). Verbrauch aus einem früheren Monat zählt null, auch ohne Reset-Cron; der Reset ist idempotent und lässt den laufenden Monat in Ruhe. F20: Der Publikations-Scheduler liefert bei technischen Fehlern einen Fehler-Exit-Code, damit der Failure-Hook greift. F17: KI-Jobs mit $timeout = 120, retry_after 150; Runbook ergänzt. F19: Aggregation der Aufrufe unter einem Cache-Lock für alle Einstiege. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
650 lines
19 KiB
PHP
650 lines
19 KiB
PHP
<?php
|
||
|
||
namespace App\Models;
|
||
|
||
use App\Enums\EmailNotificationType;
|
||
use App\Enums\Portal;
|
||
use App\Enums\RegistrationType;
|
||
use App\Enums\Tier;
|
||
use App\Support\EmailQuery;
|
||
use Database\Factories\UserFactory;
|
||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||
use Illuminate\Notifications\Notifiable;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Str;
|
||
use Laravel\Cashier\Billable;
|
||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||
use Laravel\Sanctum\HasApiTokens;
|
||
use Spatie\Permission\Traits\HasRoles;
|
||
|
||
class User extends Authenticatable implements MustVerifyEmail
|
||
{
|
||
/** @use HasFactory<UserFactory> */
|
||
use Billable, HasApiTokens, HasFactory, HasRoles, Notifiable, SoftDeletes, TwoFactorAuthenticatable;
|
||
|
||
/**
|
||
* The attributes that are mass assignable.
|
||
*
|
||
* @var list<string>
|
||
*/
|
||
protected $fillable = [
|
||
'name',
|
||
'agency_name',
|
||
'email',
|
||
'portal',
|
||
'registration_type',
|
||
'language',
|
||
'is_active',
|
||
// 'is_super_admin' bewusst NICHT mass-assignable: nur über autorisiertes
|
||
// forceFill() setzbar (Super-Admin-Rang), sonst Privilege-Escalation-Landmine.
|
||
'last_login_at',
|
||
'last_login_ip',
|
||
'gdpr_consent_at',
|
||
'last_seen_at',
|
||
'legacy_portal',
|
||
'legacy_id',
|
||
'password',
|
||
'oauth_provider',
|
||
'oauth_provider_id',
|
||
'press_release_quota_used_this_month',
|
||
'press_release_quota_period',
|
||
];
|
||
|
||
/**
|
||
* The attributes that should be hidden for serialization.
|
||
*
|
||
* @var list<string>
|
||
*/
|
||
protected $hidden = [
|
||
'password',
|
||
'remember_token',
|
||
];
|
||
|
||
/**
|
||
* Get the attributes that should be cast.
|
||
*
|
||
* @return array<string, string>
|
||
*/
|
||
protected function casts(): array
|
||
{
|
||
return [
|
||
'email_verified_at' => 'datetime',
|
||
'portal' => Portal::class,
|
||
'registration_type' => RegistrationType::class,
|
||
'is_active' => 'boolean',
|
||
'is_super_admin' => 'boolean',
|
||
'last_login_at' => 'datetime',
|
||
'gdpr_consent_at' => 'datetime',
|
||
'last_seen_at' => 'datetime',
|
||
'deleted_at' => 'datetime',
|
||
'password' => 'hashed',
|
||
'press_release_quota_used_this_month' => 'integer',
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Adresse für die Stripe-Customer-Anlage (Cashier-Hook). Stripe Tax
|
||
* braucht eine gültige Kundenadresse — falls lokal eine
|
||
* Rechnungsadresse gepflegt ist, wird sie direkt mitgegeben; sonst
|
||
* speichert der Checkout die dort erfasste Adresse (customer_update).
|
||
*
|
||
* @return array<string, string|null>|null
|
||
*/
|
||
public function stripeAddress(): ?array
|
||
{
|
||
$address = $this->billingAddress;
|
||
|
||
if (! $address) {
|
||
return null;
|
||
}
|
||
|
||
return [
|
||
'line1' => $address->address1,
|
||
'line2' => $address->address2,
|
||
'postal_code' => $address->postal_code,
|
||
'city' => $address->city,
|
||
'country' => $address->country_code,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Name für den Stripe-Customer (Cashier-Hook): Rechnungsempfänger ist
|
||
* die Firma bzw. der Name aus der Rechnungsadresse — nicht der
|
||
* Login-Anzeigename (QA-Befund 07.07.: falscher Empfänger auf der
|
||
* Stripe-Rechnung).
|
||
*/
|
||
public function stripeName(): ?string
|
||
{
|
||
$address = $this->billingAddress;
|
||
|
||
return $address?->company ?: ($address?->name ?: $this->name);
|
||
}
|
||
|
||
/**
|
||
* Sprache für Stripe-Belege (Cashier-Hook): Rechnungen, Quittungen und
|
||
* Mahn-Mails von Stripe folgen der Kontosprache statt Stripes
|
||
* Englisch-Default.
|
||
*
|
||
* @return array<int, string>
|
||
*/
|
||
public function stripePreferredLocales(): ?array
|
||
{
|
||
return [$this->language === 'en' ? 'en' : 'de-DE'];
|
||
}
|
||
|
||
/**
|
||
* Buchungs-Voraussetzung (Entscheidung 12.06.2026): Tarif- und
|
||
* Einzel-PM-Checkouts erfordern eine vollständige Rechnungsadresse.
|
||
*/
|
||
public function hasCompleteBillingAddress(): bool
|
||
{
|
||
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;
|
||
|
||
private ?int $cachedOpenSinglePurchaseCount = null;
|
||
|
||
/**
|
||
* @var list<int>|null
|
||
*/
|
||
private ?array $cachedAccessibleCompanyIds = null;
|
||
|
||
/**
|
||
* Findet einen User case-insensitive anhand der E-Mail. Single Source of
|
||
* Truth für die E-Mail-basierte Auflösung (Magic-Link, Social-Login,
|
||
* Kontakt-Zugang).
|
||
*
|
||
* Case-insensitiv über den Unique-Index (siehe EmailQuery): die Kollation
|
||
* der Spalte vergleicht ohne Groß-/Kleinschreibung, `LOWER()` würde den
|
||
* Index nur ausschalten (Audit F22).
|
||
*/
|
||
public static function findByEmail(string $email): ?self
|
||
{
|
||
$email = mb_strtolower(trim($email));
|
||
|
||
if ($email === '') {
|
||
return null;
|
||
}
|
||
|
||
return EmailQuery::whereEmail(static::query(), $email)->first();
|
||
}
|
||
|
||
/**
|
||
* 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 $this->cachedCurrentPlan = null;
|
||
}
|
||
|
||
$priceId = $subscription->stripe_price;
|
||
|
||
if (! $priceId) {
|
||
return $this->cachedCurrentPlan = null;
|
||
}
|
||
|
||
return $this->cachedCurrentPlan = Plan::query()
|
||
->where('stripe_price_id_monthly', $priceId)
|
||
->orWhere('stripe_price_id_yearly', $priceId)
|
||
->first();
|
||
}
|
||
|
||
/**
|
||
* Abrechnungs-Tier für die Credit-Preisableitung. Ohne aktives Abo gilt
|
||
* `Tier::Einzel` (Pay-per-Release).
|
||
*/
|
||
public function currentTier(): Tier
|
||
{
|
||
return Tier::fromPlanSlug($this->currentPlan()?->slug);
|
||
}
|
||
|
||
/**
|
||
* Hat dieser User ein unbegrenztes PM-Kontingent?
|
||
*
|
||
* Entscheidung 12.06.2026: Bestandskunden (aktive/grandfathered
|
||
* Legacy-Vereinbarung) behalten ihren Bestandsschutz unverändert —
|
||
* das Alt-Produkt sah unbegrenzte PMs vor. Solange der Launch-Schalter
|
||
* `billing.enforce_booking` aus ist, gilt das Kontingent für niemanden.
|
||
*/
|
||
public function hasUnlimitedPressReleaseQuota(): bool
|
||
{
|
||
if ($this->cachedHasUnlimitedQuota !== null) {
|
||
return $this->cachedHasUnlimitedQuota;
|
||
}
|
||
|
||
if (! config('billing.enforce_booking')) {
|
||
return $this->cachedHasUnlimitedQuota = true;
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
/**
|
||
* Verbleibendes PM-Kontingent: Rest des Plan-Monatskontingents plus
|
||
* bezahlte, noch nicht eingelöste Einzel-/Extra-PM-Käufe.
|
||
* Null bedeutet unbegrenzt.
|
||
*/
|
||
public function pressReleaseQuotaRemaining(): ?int
|
||
{
|
||
if ($this->hasUnlimitedPressReleaseQuota()) {
|
||
return null;
|
||
}
|
||
|
||
$planRemaining = max(
|
||
0,
|
||
($this->currentPlan()?->press_release_quota ?? 0) - $this->pressReleaseQuotaUsedThisMonth(),
|
||
);
|
||
|
||
return $planRemaining + $this->openSinglePurchaseCount();
|
||
}
|
||
|
||
/**
|
||
* Kalendermonat, für den der Verbrauchszähler gilt (YYYY-MM).
|
||
*/
|
||
public static function currentQuotaPeriod(): string
|
||
{
|
||
return now()->format('Y-m');
|
||
}
|
||
|
||
/**
|
||
* Verbrauch des LAUFENDEN Monats. Steht der Zähler auf einem früheren
|
||
* Monat (oder ohne Monat aus der Zeit vor der Periodenbindung), zählt er
|
||
* null – unabhängig davon, ob der Reset-Cron gelaufen ist (Audit F18).
|
||
*/
|
||
public function pressReleaseQuotaUsedThisMonth(): int
|
||
{
|
||
if ($this->press_release_quota_period !== null && $this->press_release_quota_period !== self::currentQuotaPeriod()) {
|
||
return 0;
|
||
}
|
||
|
||
return (int) $this->press_release_quota_used_this_month;
|
||
}
|
||
|
||
/**
|
||
* Bezahlte, noch nicht eingelöste Einmalkäufe (Einzel-/Extra-PM), die zur
|
||
* Einreichung berechtigen. Per-Request memoisiert, weil das Status-Widget
|
||
* den Wert je Render mehrfach abfragt (Kontingent, Buchungslabel, Gate).
|
||
*/
|
||
public function openSinglePurchaseCount(): int
|
||
{
|
||
if ($this->cachedOpenSinglePurchaseCount !== null) {
|
||
return $this->cachedOpenSinglePurchaseCount;
|
||
}
|
||
|
||
return $this->cachedOpenSinglePurchaseCount = $this->singlePurchases()
|
||
->grantingSubmission()
|
||
->count();
|
||
}
|
||
|
||
/**
|
||
* Gesamtes PM-Kontingent (Plan-Monatskontingent plus offene Einmalkäufe)
|
||
* für die Anzeige „verbleibend / gesamt". Null bedeutet unbegrenzt.
|
||
*/
|
||
public function pressReleaseQuotaTotal(): ?int
|
||
{
|
||
if ($this->hasUnlimitedPressReleaseQuota()) {
|
||
return null;
|
||
}
|
||
|
||
return ($this->currentPlan()?->press_release_quota ?? 0)
|
||
+ $this->openSinglePurchaseCount();
|
||
}
|
||
|
||
/**
|
||
* Submit-Gate aus dem Decision-Update §5.1: Einreichen zur Prüfung
|
||
* erfordert eine aktive Buchung.
|
||
*
|
||
* Hybrides Modell: Eine Buchung ist entweder ein aktives Stripe-Abo
|
||
* (Cashier, STR-Kreis), ein bezahlter Einmalkauf (Einzel-PM/Extra-PM)
|
||
* oder eine laufende Legacy-Zahlungsvereinbarung (manueller MAN-Kreis).
|
||
* Solange `billing.enforce_booking` deaktiviert ist, bleibt das Gate
|
||
* offen (Launch-Schalter).
|
||
*/
|
||
public function hasActiveBooking(): bool
|
||
{
|
||
if (! config('billing.enforce_booking')) {
|
||
return true;
|
||
}
|
||
|
||
if ($this->subscribed()) {
|
||
return true;
|
||
}
|
||
|
||
if ($this->openSinglePurchaseCount() > 0) {
|
||
return true;
|
||
}
|
||
|
||
return $this->hasActivePaymentOption();
|
||
}
|
||
|
||
/**
|
||
* Get the user's initials
|
||
*/
|
||
public function initials(): string
|
||
{
|
||
return Str::of($this->name)
|
||
->explode(' ')
|
||
->map(fn (string $name) => Str::of($name)->substr(0, 1))
|
||
->implode('');
|
||
}
|
||
|
||
/**
|
||
* @return HasOne<Profile, $this>
|
||
*/
|
||
public function profile(): HasOne
|
||
{
|
||
return $this->hasOne(Profile::class);
|
||
}
|
||
|
||
/**
|
||
* @return HasMany<MagicLink, $this>
|
||
*/
|
||
public function magicLinks(): HasMany
|
||
{
|
||
return $this->hasMany(MagicLink::class);
|
||
}
|
||
|
||
/**
|
||
* @return HasMany<Company, $this>
|
||
*/
|
||
public function ownedCompanies(): HasMany
|
||
{
|
||
return $this->hasMany(Company::class, 'owner_user_id');
|
||
}
|
||
|
||
/**
|
||
* @return BelongsToMany<Company, $this>
|
||
*/
|
||
public function companies(): BelongsToMany
|
||
{
|
||
return $this->belongsToMany(Company::class)
|
||
->withPivot('role', 'granted_via')
|
||
->withTimestamps();
|
||
}
|
||
|
||
/**
|
||
* @return BelongsToMany<Contact, $this>
|
||
*/
|
||
public function contacts(): BelongsToMany
|
||
{
|
||
return $this->belongsToMany(Contact::class)
|
||
->withTimestamps();
|
||
}
|
||
|
||
/**
|
||
* @return HasMany<PressRelease, $this>
|
||
*/
|
||
public function pressReleases(): HasMany
|
||
{
|
||
return $this->hasMany(PressRelease::class);
|
||
}
|
||
|
||
/**
|
||
* @return HasMany<NewsletterSubscription, $this>
|
||
*/
|
||
public function newsletterSubscriptions(): HasMany
|
||
{
|
||
return $this->hasMany(NewsletterSubscription::class);
|
||
}
|
||
|
||
/**
|
||
* @return HasMany<EmailNotificationOptOut, $this>
|
||
*/
|
||
public function emailNotificationOptOuts(): HasMany
|
||
{
|
||
return $this->hasMany(EmailNotificationOptOut::class);
|
||
}
|
||
|
||
/**
|
||
* Versand-Gate für Benachrichtigungs-Mails: Vor jedem Versand eines
|
||
* abbestellbaren Mail-Typs prüfen, ob der Empfänger ihn abbestellt hat
|
||
* (One-Click-Unsubscribe aus der Mail oder Präferenzen im Profil).
|
||
*/
|
||
public function wantsEmailNotification(EmailNotificationType $type): bool
|
||
{
|
||
return ! $this->emailNotificationOptOuts()
|
||
->where('type', $type->value)
|
||
->exists();
|
||
}
|
||
|
||
public function optOutOfEmailNotification(EmailNotificationType $type): void
|
||
{
|
||
$this->emailNotificationOptOuts()->firstOrCreate(['type' => $type->value]);
|
||
}
|
||
|
||
public function optIntoEmailNotification(EmailNotificationType $type): void
|
||
{
|
||
$this->emailNotificationOptOuts()->where('type', $type->value)->delete();
|
||
}
|
||
|
||
/**
|
||
* @return HasOne<BillingAddress, $this>
|
||
*/
|
||
public function billingAddress(): HasOne
|
||
{
|
||
return $this->hasOne(BillingAddress::class);
|
||
}
|
||
|
||
/**
|
||
* @return HasMany<UserPaymentOption, $this>
|
||
*/
|
||
public function userPaymentOptions(): HasMany
|
||
{
|
||
return $this->hasMany(UserPaymentOption::class);
|
||
}
|
||
|
||
/**
|
||
* @return HasMany<SinglePurchase, $this>
|
||
*/
|
||
public function singlePurchases(): HasMany
|
||
{
|
||
return $this->hasMany(SinglePurchase::class);
|
||
}
|
||
|
||
/**
|
||
* @return HasOne<CreditWallet, $this>
|
||
*/
|
||
public function creditWallet(): HasOne
|
||
{
|
||
return $this->hasOne(CreditWallet::class);
|
||
}
|
||
|
||
/**
|
||
* @return HasMany<CreditTransaction, $this>
|
||
*/
|
||
public function creditTransactions(): HasMany
|
||
{
|
||
return $this->hasMany(CreditTransaction::class);
|
||
}
|
||
|
||
/**
|
||
* @return HasMany<ReviewCheck, $this>
|
||
*/
|
||
public function reviewChecks(): HasMany
|
||
{
|
||
return $this->hasMany(ReviewCheck::class);
|
||
}
|
||
|
||
/**
|
||
* @return HasMany<CreditTopup, $this>
|
||
*/
|
||
public function creditTopups(): HasMany
|
||
{
|
||
return $this->hasMany(CreditTopup::class);
|
||
}
|
||
|
||
/**
|
||
* Aktuelles Credit-Guthaben (1 Credit = 1 €). 0, solange keine Wallet
|
||
* angelegt wurde.
|
||
*/
|
||
public function creditBalance(): int
|
||
{
|
||
return (int) ($this->creditWallet?->balance_credits ?? 0);
|
||
}
|
||
|
||
/**
|
||
* Lokale Rechnungen (STR- und MAN-Kreis). Überschreibt bewusst die
|
||
* gleichnamige Cashier-Methode — Stripe-Rechnungen werden beim
|
||
* Webhook-Sync (9E) in diese Tabelle gespiegelt.
|
||
*
|
||
* @return HasMany<Invoice, $this>
|
||
*/
|
||
public function invoices(): HasMany
|
||
{
|
||
return $this->hasMany(Invoice::class);
|
||
}
|
||
|
||
/**
|
||
* @return HasMany<LegacyInvoice, $this>
|
||
*/
|
||
public function legacyInvoices(): HasMany
|
||
{
|
||
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();
|
||
}
|
||
|
||
/**
|
||
* @return HasMany<UserFilterPreset, $this>
|
||
*/
|
||
public function filterPresets(): HasMany
|
||
{
|
||
return $this->hasMany(UserFilterPreset::class);
|
||
}
|
||
|
||
public function canAccessAdmin(): bool
|
||
{
|
||
if (! $this->is_active) {
|
||
return false;
|
||
}
|
||
|
||
if ($this->is_super_admin) {
|
||
return true;
|
||
}
|
||
|
||
return $this->hasAnyRole(['admin', 'editor']);
|
||
}
|
||
|
||
public function canAccessCustomer(): bool
|
||
{
|
||
if (! $this->is_active) {
|
||
return false;
|
||
}
|
||
|
||
return $this->hasAnyRole(['admin', 'editor', 'customer']);
|
||
}
|
||
|
||
/**
|
||
* Firmen, auf die dieser User Zugriff hat: als Owner (owner_user_id) oder
|
||
* als Mitglied über den company_user-Pivot. Basis für das Firmen-Scoping
|
||
* von Pressemitteilungen (Zugriff = Autor ODER Firmenzugehörigkeit).
|
||
*
|
||
* @return list<int>
|
||
*/
|
||
public function accessibleCompanyIds(): array
|
||
{
|
||
if ($this->cachedAccessibleCompanyIds !== null) {
|
||
return $this->cachedAccessibleCompanyIds;
|
||
}
|
||
|
||
// toBase() statt der Relation-pluck(): Eloquents Builder::pluck()
|
||
// hydratisiert sonst pro ID ein vollständiges Company-Model (bei
|
||
// Power-Usern mit zehntausenden Firmen ein massives Speicher-/Zeit-Leck).
|
||
// toBase() wendet die Global Scopes weiter an, liefert aber nur die
|
||
// nackten ID-Werte ohne Model-Hydration.
|
||
return $this->cachedAccessibleCompanyIds = $this->ownedCompanies()->toBase()->pluck('companies.id')
|
||
->merge($this->companies()->toBase()->pluck('companies.id'))
|
||
->unique()
|
||
->map(fn ($id): int => (int) $id)
|
||
->values()
|
||
->all();
|
||
}
|
||
|
||
public function canAccessCompany(?int $companyId): bool
|
||
{
|
||
if ($companyId === null) {
|
||
return false;
|
||
}
|
||
|
||
return in_array($companyId, $this->accessibleCompanyIds(), true);
|
||
}
|
||
|
||
/**
|
||
* Synchronisiert die Firmenzuordnungen samt Pivot-Rolle. Fehlt zu einer
|
||
* Firma eine Rolle, greift 'member'. Gemeinsamer Persistenz-Kern des
|
||
* Anlegen- und Bearbeiten-Formulars (admin/users).
|
||
*
|
||
* @param list<int|string> $companyIds
|
||
* @param array<int|string, string> $roles companyId => role
|
||
*/
|
||
public function syncCompaniesWithRoles(array $companyIds, array $roles): void
|
||
{
|
||
// Herkunft (Audit F10): Was der Admin neu anlegt oder in der Rolle
|
||
// ändert, ist `manual` und bleibt vom automatischen Entzug unberührt.
|
||
// Unveränderte Zeilen behalten ihre Herkunft – sonst würde jedes
|
||
// Speichern des Formulars alle automatischen Rechte zementieren.
|
||
$current = DB::table('company_user')
|
||
->where('user_id', $this->getKey())
|
||
->pluck('role', 'company_id')
|
||
->mapWithKeys(fn (string $role, int|string $companyId): array => [(int) $companyId => $role]);
|
||
|
||
$payload = [];
|
||
|
||
foreach ($companyIds as $companyId) {
|
||
$role = $roles[$companyId] ?? 'member';
|
||
$payload[$companyId] = ['role' => $role];
|
||
|
||
if (! isset($current[(int) $companyId]) || $current[(int) $companyId] !== $role) {
|
||
$payload[$companyId]['granted_via'] = 'manual';
|
||
}
|
||
}
|
||
|
||
$this->companies()->sync($payload);
|
||
}
|
||
}
|