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>
395 lines
12 KiB
PHP
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;
|
|
}
|
|
}
|