Files
SkinbaseNova/app/Models/User.php
2026-03-28 19:15:39 +01:00

372 lines
11 KiB
PHP
Raw Permalink 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 Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\HasMany;
use App\Models\SocialAccount;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\Notification;
use App\Models\Achievement;
use App\Models\UserAchievement;
use App\Models\UserActivity;
use App\Models\UserXpLog;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\DB;
use Laravel\Scout\Searchable;
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable, SoftDeletes;
use Searchable {
Searchable::bootSearchable as private bootScoutSearchable;
}
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'username',
'username_changed_at',
'last_username_change_at',
'onboarding_step',
'name',
'email',
'last_verification_sent_at',
'verification_send_count_24h',
'verification_send_window_started_at',
'is_active',
'needs_password_reset',
'cover_hash',
'cover_ext',
'cover_position',
'trust_score',
'bot_risk_score',
'bot_flags',
'last_bot_activity_at',
'spam_reports',
'approved_posts',
'flagged_posts',
'xp',
'level',
'rank',
'password',
'role',
'nova_featured_creator',
'allow_messages_from',
];
/**
* 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',
'last_visit_at' => 'datetime',
'last_verification_sent_at' => 'datetime',
'verification_send_window_started_at' => 'datetime',
'verification_send_count_24h' => 'integer',
'username_changed_at' => 'datetime',
'last_username_change_at' => 'datetime',
'deleted_at' => 'datetime',
'cover_position' => 'integer',
'trust_score' => 'integer',
'bot_risk_score' => 'integer',
'bot_flags' => 'array',
'last_bot_activity_at' => 'datetime',
'spam_reports' => 'integer',
'approved_posts' => 'integer',
'flagged_posts' => 'integer',
'xp' => 'integer',
'level' => 'integer',
'rank' => 'string',
'nova_featured_creator' => 'boolean',
'password' => 'hashed',
'allow_messages_from' => 'string',
];
}
public function novaCards(): HasMany
{
return $this->hasMany(NovaCard::class);
}
public function artworks(): HasMany
{
return $this->hasMany(Artwork::class);
}
public function collections(): HasMany
{
return $this->hasMany(Collection::class)->latest('updated_at');
}
public function savedCollectionLists(): HasMany
{
return $this->hasMany(CollectionSavedList::class, 'user_id')->orderBy('title');
}
public function socialAccounts(): HasMany
{
return $this->hasMany(SocialAccount::class);
}
public function profile(): HasOne
{
return $this->hasOne(UserProfile::class, 'user_id');
}
public function dashboardPreference(): HasOne
{
return $this->hasOne(DashboardPreference::class, 'user_id');
}
public function country(): BelongsTo
{
return $this->belongsTo(Country::class);
}
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');
}
public function xpLogs(): HasMany
{
return $this->hasMany(UserXpLog::class, 'user_id');
}
public function userAchievements(): HasMany
{
return $this->hasMany(UserAchievement::class, 'user_id');
}
public function userActivities(): HasMany
{
return $this->hasMany(UserActivity::class, 'user_id');
}
public function achievements(): BelongsToMany
{
return $this->belongsToMany(Achievement::class, 'user_achievements', 'user_id', 'achievement_id')
->withPivot('unlocked_at');
}
// ── 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');
}
/**
* Skinbase notifications are keyed by user_id (non-polymorphic table).
*/
public function notifications(): HasMany
{
return $this->hasMany(Notification::class, 'user_id')->latest();
}
public function unreadNotifications(): HasMany
{
return $this->notifications()->whereNull('read_at');
}
/**
* 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');
}
public function posts(): HasMany
{
return $this->hasMany(Post::class)->orderByDesc('created_at');
}
public function stories(): HasMany
{
return $this->hasMany(Story::class, 'creator_id')->orderByDesc('published_at');
}
// ─── Meilisearch ──────────────────────────────────────────────────────────
/**
* User indexing is handled explicitly through IndexUserJob so unrelated
* model writes do not enqueue Scout sync jobs implicitly.
*/
protected static function bootSearchable(): void
{
}
/**
* 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(),
];
}
}