php-tenant/Models/User.php
Claude 275cba29d7
refactor: guard external relationships in User model with class_exists()
Moves all external module relationships (Page, Project, Domain, Pixel,
AnalyticsWebsite, AnalyticsGoal, PushWebsite, PushCampaign, PushSegment,
PushFlow, TrustCampaign, TrustNotification, Order) out of the User model
into dedicated opt-in traits that resolve model classes at runtime via
class_exists(). This prevents fatal errors when consuming apps do not
have those modules installed.

New traits:
- HasPageRelationships (pages, projects, domains, pixels, bio/sub-page entitlements)
- HasAnalyticsRelationships (analytics websites, goals)
- HasPushRelationships (push websites, campaigns, segments, flows)
- HasTrustRelationships (trust campaigns, notifications)
- HasOrderRelationships (orders, vanity URL entitlement)

The User model still uses all five traits so existing consumers see no
breaking changes — the relationships now return null instead of throwing
a class-not-found error when the backing module is absent.

Fixes #6

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:22:36 +00:00

395 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace Core\Tenant\Models;
use Core\Tenant\Concerns\HasAnalyticsRelationships;
use Core\Tenant\Concerns\HasOrderRelationships;
use Core\Tenant\Concerns\HasPageRelationships;
use Core\Tenant\Concerns\HasPushRelationships;
use Core\Tenant\Concerns\HasTrustRelationships;
use Core\Tenant\Database\Factories\UserFactory;
use Core\Tenant\Enums\UserTier;
use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Pennant\Concerns\HasFeatures;
class User extends Authenticatable implements MustVerifyEmail
{
use HasAnalyticsRelationships;
use HasFactory;
use HasFeatures;
use HasOrderRelationships;
use HasPageRelationships;
use HasPushRelationships;
use HasTrustRelationships;
use Notifiable;
/**
* Create a new factory instance for the model.
*/
protected static function newFactory(): UserFactory
{
return UserFactory::new();
}
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
'tier',
'tier_expires_at',
'referred_by',
'referral_count',
'referral_activated_at',
];
/**
* 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',
'password' => 'hashed',
'tier' => UserTier::class,
'tier_expires_at' => 'datetime',
'cached_stats' => 'array',
'stats_computed_at' => 'datetime',
'referral_activated_at' => 'datetime',
];
}
// ─────────────────────────────────────────────────────────────────────────
// Workspace Relationships
// ─────────────────────────────────────────────────────────────────────────
/**
* Get all workspaces this user has access to.
*/
public function workspaces(): BelongsToMany
{
return $this->belongsToMany(Workspace::class, 'user_workspace')
->withPivot(['role', 'is_default'])
->withTimestamps();
}
/**
* Alias for workspaces() - kept for backward compatibility.
*/
public function hostWorkspaces(): BelongsToMany
{
return $this->workspaces();
}
/**
* Get the workspaces owned by this user.
*/
public function ownedWorkspaces(): BelongsToMany
{
return $this->belongsToMany(Workspace::class, 'user_workspace')
->wherePivot('role', 'owner')
->withPivot(['role', 'is_default'])
->withTimestamps();
}
// ─────────────────────────────────────────────────────────────────────────
// Tier Helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* Get the user's tier.
*/
public function getTier(): UserTier
{
// Check if tier has expired
if ($this->tier_expires_at && $this->tier_expires_at->isPast()) {
return UserTier::FREE;
}
return $this->tier ?? UserTier::FREE;
}
/**
* Check if user is on a paid tier.
*/
public function isPaid(): bool
{
$tier = $this->getTier();
return $tier === UserTier::APOLLO || $tier === UserTier::HADES;
}
/**
* Check if user is on Hades tier.
*/
public function isHades(): bool
{
return $this->getTier() === UserTier::HADES;
}
/**
* Check if user is on Apollo tier.
*/
public function isApollo(): bool
{
return $this->getTier() === UserTier::APOLLO;
}
/**
* Check if user has a specific feature.
*/
public function hasFeature(string $feature): bool
{
return $this->getTier()->hasFeature($feature);
}
/**
* Get the maximum number of workspaces for this user.
*/
public function maxWorkspaces(): int
{
return $this->getTier()->maxWorkspaces();
}
/**
* Check if user can add more Host Hub workspaces.
*/
public function canAddHostWorkspace(): bool
{
$max = $this->maxWorkspaces();
if ($max === -1) {
return true; // Unlimited
}
return $this->hostWorkspaces()->count() < $max;
}
/**
* Get the user's default Host Hub workspace.
*/
public function defaultHostWorkspace(): ?Workspace
{
return $this->hostWorkspaces()
->wherePivot('is_default', true)
->first() ?? $this->hostWorkspaces()->first();
}
// ─────────────────────────────────────────────────────────────────────────
// Namespace Relationships
// ─────────────────────────────────────────────────────────────────────────
/**
* Get all namespaces owned directly by this user.
*/
public function namespaces(): MorphMany
{
return $this->morphMany(Namespace_::class, 'owner');
}
/**
* Get the user's default namespace.
*
* Priority:
* 1. User's default namespace (is_default = true)
* 2. First active user-owned namespace
* 3. First namespace from user's default workspace
*/
public function defaultNamespace(): ?Namespace_
{
// Try user's explicit default
$default = $this->namespaces()
->where('is_default', true)
->active()
->first();
if ($default) {
return $default;
}
// Try first user-owned namespace
$userOwned = $this->namespaces()
->active()
->ordered()
->first();
if ($userOwned) {
return $userOwned;
}
// Try namespace from user's default workspace
$workspace = $this->defaultHostWorkspace();
if ($workspace) {
return $workspace->namespaces()
->active()
->ordered()
->first();
}
return null;
}
/**
* Get all namespaces accessible by this user (owned + via workspaces).
*/
public function accessibleNamespaces(): Builder
{
return Namespace_::accessibleBy($this);
}
// ─────────────────────────────────────────────────────────────────────────
// Email Verification
// ─────────────────────────────────────────────────────────────────────────
/**
* Check if user's email has been verified.
* Hades accounts are always considered verified.
*/
public function hasVerifiedEmail(): bool
{
// Hades accounts bypass email verification
if ($this->isHades()) {
return true;
}
return $this->email_verified_at !== null;
}
/**
* Mark the user's email as verified.
*/
public function markEmailAsVerified(): bool
{
return $this->forceFill([
'email_verified_at' => $this->freshTimestamp(),
])->save();
}
/**
* Send the email verification notification.
*/
public function sendEmailVerificationNotification(): void
{
$this->notify(new VerifyEmail);
}
/**
* Get the email address that should be used for verification.
*/
public function getEmailForVerification(): string
{
return $this->email;
}
// ─────────────────────────────────────────────────────────────────────────
// Entitlement Relationships
// ─────────────────────────────────────────────────────────────────────────
/**
* Get all boosts owned by this user.
*/
public function boosts(): HasMany
{
return $this->hasMany(Boost::class);
}
// ─────────────────────────────────────────────────────────────────────────
// Referral Relationships
// ─────────────────────────────────────────────────────────────────────────
/**
* Get the user who referred this user.
*/
public function referrer(): BelongsTo
{
return $this->belongsTo(self::class, 'referred_by');
}
/**
* Get all users referred by this user.
*/
public function referrals(): HasMany
{
return $this->hasMany(self::class, 'referred_by');
}
/**
* Check if user has activated referrals.
*/
public function hasActivatedReferrals(): bool
{
return $this->referral_activated_at !== null;
}
/**
* Activate referrals for this user.
*/
public function activateReferrals(): void
{
if (! $this->hasActivatedReferrals()) {
$this->update(['referral_activated_at' => now()]);
}
}
/**
* Get referral ranking (1-based position among all users by referral count).
*/
public function getReferralRank(): int
{
if ($this->referral_count === 0) {
return 0; // Not ranked if no referrals
}
return self::where('referral_count', '>', $this->referral_count)->count() + 1;
}
// ─────────────────────────────────────────────────────────────────────────
// Orderable Interface
// ─────────────────────────────────────────────────────────────────────────
public function getBillingName(): ?string
{
return $this->name;
}
public function getBillingEmail(): string
{
return $this->email;
}
public function getBillingAddress(): ?array
{
return null;
}
public function getTaxCountry(): ?string
{
return null;
}
}