Files
SkinbaseNova/app/Models/User.php
2026-03-20 21:17:26 +01:00

337 lines
10 KiB
PHP
Raw 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\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, Searchable;
/**
* 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',
'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_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',
'password' => 'hashed',
'allow_messages_from' => 'string',
];
}
public function artworks(): HasMany
{
return $this->hasMany(Artwork::class);
}
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 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 ──────────────────────────────────────────────────────────
/**
* 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(),
];
}
}