*/ use HasFactory, Notifiable, SoftDeletes, Searchable; /** * The attributes that are mass assignable. * * @var list */ protected $fillable = [ 'username', 'username_changed_at', 'onboarding_step', 'name', 'email', 'last_verification_sent_at', 'verification_send_count_24h', 'verification_send_window_started_at', 'is_active', 'needs_password_reset', 'password', 'role', 'allow_messages_from', ]; /** * The attributes that should be hidden for serialization. * * @var list */ protected $hidden = [ 'password', 'remember_token', ]; /** * Get the attributes that should be cast. * * @return array */ protected function casts(): array { return [ 'email_verified_at' => 'datetime', 'last_verification_sent_at' => 'datetime', 'verification_send_window_started_at' => 'datetime', 'verification_send_count_24h' => 'integer', 'username_changed_at' => 'datetime', 'deleted_at' => 'datetime', 'password' => 'hashed', 'allow_messages_from' => 'string', ]; } public function artworks(): HasMany { return $this->hasMany(Artwork::class); } public function profile(): HasOne { return $this->hasOne(UserProfile::class, 'user_id'); } public function statistics(): HasOne { return $this->hasOne(UserStatistic::class, 'user_id'); } /** Users that follow this user */ public function followers(): BelongsToMany { return $this->belongsToMany( User::class, 'user_followers', 'user_id', 'follower_id' )->withPivot('created_at'); } /** Users that this user follows */ public function following(): BelongsToMany { return $this->belongsToMany( User::class, 'user_followers', 'follower_id', 'user_id' )->withPivot('created_at'); } public function profileComments(): HasMany { return $this->hasMany(ProfileComment::class, 'profile_user_id'); } // ── Messaging ──────────────────────────────────────────────────────────── public function conversations(): BelongsToMany { return $this->belongsToMany(Conversation::class, 'conversation_participants') ->withPivot(['role', 'last_read_at', 'is_muted', 'is_archived', 'is_pinned', 'pinned_at', 'joined_at', 'left_at']) ->wherePivotNull('left_at') ->orderByPivot('joined_at', 'desc'); } public function conversationParticipants(): HasMany { return $this->hasMany(ConversationParticipant::class); } public function sentMessages(): HasMany { return $this->hasMany(Message::class, 'sender_id'); } /** * Check if this user allows receiving messages from the given user. */ public function allowsMessagesFrom(User $sender): bool { $pref = $this->allow_messages_from ?? 'everyone'; return match ($pref) { 'everyone' => true, 'followers' => $this->followers()->where('follower_id', $sender->id)->exists(), 'mutual_followers' => $this->followers()->where('follower_id', $sender->id)->exists() && $this->following()->where('user_id', $sender->id)->exists(), 'nobody' => false, default => true, }; } // ──────────────────────────────────────────────────────────────────────── /** Artworks this user has added to their favourites. */ public function favouriteArtworks(): BelongsToMany { return $this->belongsToMany(Artwork::class, 'artwork_favourites', 'user_id', 'artwork_id') ->withPivot('legacy_id') ->withTimestamps(); } public function hasRole(string $role): bool { return strtolower((string) ($this->role ?? '')) === strtolower($role); } // ─── Follow helpers ─────────────────────────────────────────────────────── /** * Whether $viewerId is following this user. * Uses a single indexed lookup – safe to call on every profile render. */ public function isFollowedBy(int $viewerId): bool { if ($viewerId === $this->id) { return false; } return DB::table('user_followers') ->where('user_id', $this->id) ->where('follower_id', $viewerId) ->exists(); } /** * Cached follower count from user_statistics. * Returns 0 if the statistics row does not exist yet. */ public function getFollowersCountAttribute(): int { return (int) ($this->statistics?->followers_count ?? 0); } /** * Cached following count from user_statistics. */ public function getFollowingCountAttribute(): int { return (int) ($this->statistics?->following_count ?? 0); } public function isAdmin(): bool { return $this->hasRole('admin'); } public function isModerator(): bool { return $this->hasRole('moderator'); } // ─── Meilisearch ────────────────────────────────────────────────────────── /** * Only index active users (not soft-deleted, is_active = true). */ public function shouldBeSearchable(): bool { return (bool) $this->is_active && ! $this->trashed(); } /** * Data indexed in Meilisearch. * Includes all v2 stat counters for top-creator sorting. */ public function toSearchableArray(): array { $stats = $this->statistics; return [ 'id' => $this->id, 'username' => strtolower((string) ($this->username ?? '')), 'name' => $this->name, // Upload activity 'uploads_count' => (int) ($stats?->uploads_count ?? 0), // Creator-received metrics 'downloads_received_count' => (int) ($stats?->downloads_received_count ?? 0), 'artwork_views_received_count' => (int) ($stats?->artwork_views_received_count ?? 0), 'awards_received_count' => (int) ($stats?->awards_received_count ?? 0), 'favorites_received_count' => (int) ($stats?->favorites_received_count ?? 0), 'comments_received_count' => (int) ($stats?->comments_received_count ?? 0), 'reactions_received_count' => (int) ($stats?->reactions_received_count ?? 0), // Social 'followers_count' => (int) ($stats?->followers_count ?? 0), 'following_count' => (int) ($stats?->following_count ?? 0), 'created_at' => $this->created_at?->toISOString(), ]; } }