Add @property, @property-read, @method, and @mixin PHPDoc annotations to the seven core Eloquent models for IDE autocompletion support. Models annotated: - Workspace: all columns, relationships, scopes (active, ordered) - User: all columns, relationships, factory - WorkspaceMember: relationship props, scope methods (forWorkspace, forUser, withRole, inTeam, owners) - WorkspaceInvitation: all columns, relationships, scopes (pending, expired, accepted) - Namespace_: all columns, relationships, scopes (active, ordered, ownedByUser, ownedByWorkspace, accessibleBy) - Package: all columns, relationships, scopes (active, public, base, addons, purchasable, free, ordered) - Feature: all columns, relationships, scopes (active, inCategory, root) Fixes #31 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
634 lines
19 KiB
PHP
634 lines
19 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Core\Tenant\Models;
|
|
|
|
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;
|
|
|
|
/**
|
|
* User model - authenticatable tenant user.
|
|
*
|
|
* @property int $id
|
|
* @property string $name
|
|
* @property string $email
|
|
* @property \Illuminate\Support\Carbon|null $email_verified_at
|
|
* @property string $password
|
|
* @property string|null $remember_token
|
|
* @property \Core\Tenant\Enums\UserTier $tier
|
|
* @property \Illuminate\Support\Carbon|null $tier_expires_at
|
|
* @property int|null $referred_by
|
|
* @property int $referral_count
|
|
* @property \Illuminate\Support\Carbon|null $referral_activated_at
|
|
* @property array|null $cached_stats
|
|
* @property \Illuminate\Support\Carbon|null $stats_computed_at
|
|
* @property \Illuminate\Support\Carbon|null $created_at
|
|
* @property \Illuminate\Support\Carbon|null $updated_at
|
|
* @property-read \Illuminate\Database\Eloquent\Collection<int, Workspace> $workspaces
|
|
* @property-read \Illuminate\Database\Eloquent\Collection<int, Workspace> $ownedWorkspaces
|
|
* @property-read \Illuminate\Database\Eloquent\Collection<int, Namespace_> $namespaces
|
|
* @property-read \Illuminate\Database\Eloquent\Collection<int, Boost> $boosts
|
|
* @property-read User|null $referrer
|
|
* @property-read \Illuminate\Database\Eloquent\Collection<int, User> $referrals
|
|
* @property-read \Illuminate\Notifications\DatabaseNotificationCollection<int, \Illuminate\Notifications\DatabaseNotification> $notifications
|
|
*
|
|
* @method static \Illuminate\Database\Eloquent\Builder<static>|User newModelQuery()
|
|
* @method static \Illuminate\Database\Eloquent\Builder<static>|User newQuery()
|
|
* @method static \Illuminate\Database\Eloquent\Builder<static>|User query()
|
|
* @method static \Core\Tenant\Database\Factories\UserFactory factory($count = null, $state = [])
|
|
*
|
|
* @mixin \Eloquent
|
|
*/
|
|
class User extends Authenticatable implements MustVerifyEmail
|
|
{
|
|
use HasFactory, HasFeatures, 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',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Page Relationships
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Get all pages owned by this user.
|
|
*/
|
|
public function pages(): HasMany
|
|
{
|
|
return $this->hasMany(Page::class);
|
|
}
|
|
|
|
/**
|
|
* Get all page projects (folders) owned by this user.
|
|
*/
|
|
public function pageProjects(): HasMany
|
|
{
|
|
return $this->hasMany(Project::class);
|
|
}
|
|
|
|
/**
|
|
* Get all custom domains owned by this user.
|
|
*/
|
|
public function pageDomains(): HasMany
|
|
{
|
|
return $this->hasMany(Domain::class);
|
|
}
|
|
|
|
/**
|
|
* Get all tracking pixels owned by this user.
|
|
*/
|
|
public function pagePixels(): HasMany
|
|
{
|
|
return $this->hasMany(Pixel::class);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Analytics Relationships
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Get all analytics websites owned by this user.
|
|
*/
|
|
public function analyticsWebsites(): HasMany
|
|
{
|
|
return $this->hasMany(AnalyticsWebsite::class);
|
|
}
|
|
|
|
/**
|
|
* Get all analytics goals owned by this user.
|
|
*/
|
|
public function analyticsGoals(): HasMany
|
|
{
|
|
return $this->hasMany(AnalyticsGoal::class);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Push Notification Relationships
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Get all push websites owned by this user.
|
|
*/
|
|
public function pushWebsites(): HasMany
|
|
{
|
|
return $this->hasMany(PushWebsite::class);
|
|
}
|
|
|
|
/**
|
|
* Get all push campaigns owned by this user.
|
|
*/
|
|
public function pushCampaigns(): HasMany
|
|
{
|
|
return $this->hasMany(PushCampaign::class);
|
|
}
|
|
|
|
/**
|
|
* Get all push segments owned by this user.
|
|
*/
|
|
public function pushSegments(): HasMany
|
|
{
|
|
return $this->hasMany(PushSegment::class);
|
|
}
|
|
|
|
/**
|
|
* Get all push flows owned by this user.
|
|
*/
|
|
public function pushFlows(): HasMany
|
|
{
|
|
return $this->hasMany(PushFlow::class);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Trust Widget Relationships
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Get all trust campaigns owned by this user.
|
|
*/
|
|
public function trustCampaigns(): HasMany
|
|
{
|
|
return $this->hasMany(TrustCampaign::class);
|
|
}
|
|
|
|
/**
|
|
* Get all trust notifications owned by this user.
|
|
*/
|
|
public function trustNotifications(): HasMany
|
|
{
|
|
return $this->hasMany(TrustNotification::class);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Entitlement Relationships
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Get all boosts owned by this user.
|
|
*/
|
|
public function boosts(): HasMany
|
|
{
|
|
return $this->hasMany(Boost::class);
|
|
}
|
|
|
|
/**
|
|
* Get all orders placed by this user.
|
|
*/
|
|
public function orders(): HasMany
|
|
{
|
|
return $this->hasMany(Order::class);
|
|
}
|
|
|
|
/**
|
|
* Check if user can claim a vanity URL.
|
|
*
|
|
* Requires either:
|
|
* - A paid subscription (Creator/Agency package)
|
|
* - A one-time vanity URL boost purchase
|
|
*/
|
|
public function canClaimVanityUrl(): bool
|
|
{
|
|
// Check for vanity URL boost
|
|
$hasBoost = $this->boosts()
|
|
->where('feature_code', 'bio.vanity_url')
|
|
->where('status', Boost::STATUS_ACTIVE)
|
|
->exists();
|
|
|
|
if ($hasBoost) {
|
|
return true;
|
|
}
|
|
|
|
// Check for paid subscription (Creator or Agency package)
|
|
// An order with total > 0 and status = 'paid' indicates a paid subscription
|
|
$hasPaidSubscription = $this->orders()
|
|
->where('status', 'paid')
|
|
->where('total', '>', 0)
|
|
->whereHas('items', function ($query) {
|
|
$query->whereIn('item_code', ['creator', 'agency']);
|
|
})
|
|
->exists();
|
|
|
|
return $hasPaidSubscription;
|
|
}
|
|
|
|
/**
|
|
* Get the user's bio.pages entitlement (base + boosts).
|
|
*/
|
|
public function getBioPagesLimit(): int
|
|
{
|
|
// Base: 1 page for all tiers
|
|
$base = 1;
|
|
|
|
// Add from boosts
|
|
$boostPages = $this->boosts()
|
|
->where('feature_code', 'bio.pages')
|
|
->where('status', Boost::STATUS_ACTIVE)
|
|
->sum('limit_value');
|
|
|
|
return $base + (int) $boostPages;
|
|
}
|
|
|
|
/**
|
|
* Check if user can create more bio pages.
|
|
*/
|
|
public function canCreateBioPage(): bool
|
|
{
|
|
return $this->pages()->rootPages()->count() < $this->getBioPagesLimit();
|
|
}
|
|
|
|
/**
|
|
* Get remaining bio page slots.
|
|
*/
|
|
public function remainingBioPageSlots(): int
|
|
{
|
|
return max(0, $this->getBioPagesLimit() - $this->pages()->rootPages()->count());
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Sub-Page Entitlements
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Get the user's sub-page limit (0 base + boosts).
|
|
*/
|
|
public function getSubPagesLimit(): int
|
|
{
|
|
// Base: 0 sub-pages (free tier)
|
|
$base = 0;
|
|
|
|
// Add from boosts
|
|
$boostPages = $this->boosts()
|
|
->where('feature_code', 'webpage.sub_pages')
|
|
->where('status', Boost::STATUS_ACTIVE)
|
|
->sum('limit_value');
|
|
|
|
return $base + (int) $boostPages;
|
|
}
|
|
|
|
/**
|
|
* Get the total sub-pages count across all root pages.
|
|
*/
|
|
public function getSubPagesCount(): int
|
|
{
|
|
return $this->pages()->subPages()->count();
|
|
}
|
|
|
|
/**
|
|
* Check if user can create more sub-pages.
|
|
*/
|
|
public function canCreateSubPage(): bool
|
|
{
|
|
return $this->getSubPagesCount() < $this->getSubPagesLimit();
|
|
}
|
|
|
|
/**
|
|
* Get remaining sub-page slots.
|
|
*/
|
|
public function remainingSubPageSlots(): int
|
|
{
|
|
return max(0, $this->getSubPagesLimit() - $this->getSubPagesCount());
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// 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;
|
|
}
|
|
}
|