presseportale/app/Models/User.php
Kevin Adametz 301bca3a90 auth: automatische Firmenrechte mit Herkunft und Entzug, Nachweis vor Sicherheitsaktionen (Audit Block 1, Deploy C)
F10: company_user.granted_via unterscheidet automatisch vergebene
Rechte (E-Mail-Sync) von manuellen und aus dem Altbestand. Beim Login
senkt der Sync automatische responsible-Rechte ohne aktuellen E-Mail-
Treffer auf member (E2) — Mitarbeiterwechsel nehmen den Schreibzugriff
mit, Lesezugriff bleibt. Das Admin-Formular markiert nur neue oder
geänderte Zeilen als manual, unveränderte behalten ihre Herkunft.

Bestandsprüfung: php artisan company-memberships:audit listet
responsible-Zuordnungen ohne E-Mail-Grundlage (Tabelle ab Go-Live, CSV
vollständig) und markiert mit --classify die erklärbaren als auto. Auf
Live nach der Migration einmal mit --classify laufen lassen; nicht
erklärbare Zeilen bleiben manual und damit geschützt.

F03 (E3): Zwei-Faktor deaktivieren, Wiederherstellungs-Codes erneuern
und die E-Mail-Änderung verlangen einen aktuellen App-Code oder einen
Wiederherstellungs-Code (der dabei verbraucht wird); ohne 2FA das
Passwort. Reine Magic-Link-Konten ohne Passwort (der Altbestand) können
die Adresse weiter ändern — die neue Adresse muss ihren Bestätigungslink
ohnehin einlösen. Fünf Fehlversuche pro Minute.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 10:19:28 +00:00

627 lines
18 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 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',
];
/**
* 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).
*
* Bewusst weiterhin über `LOWER(email)`: E-Mails werden nicht normalisiert
* gespeichert, daher ist der case-insensitive Vergleich Pflicht. Ein
* indexfreundlicher Direktvergleich setzt eine Normalisierung beim
* Schreiben voraus (separater, datenseitiger Schritt).
*/
public static function findByEmail(string $email): ?self
{
$email = mb_strtolower(trim($email));
if ($email === '') {
return null;
}
return static::query()->whereRaw('LOWER(email) = ?', [$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) - (int) $this->press_release_quota_used_this_month,
);
return $planRemaining + $this->openSinglePurchaseCount();
}
/**
* 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);
}
}