presseportale/app/Models/User.php
Kevin Adametz 72432fdb82 web/api/ki: Soft Deletes wieder wirksam, Caches bei Firmenwechsel, KI-Ergebnis an Inhalt gebunden (Audit Block 3+4, Deploy D)
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>
2026-09-18 10:40:48 +00:00

650 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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);
}
}