262 lines
8.4 KiB
PHP
262 lines
8.4 KiB
PHP
<?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\HasOne;
|
||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||
use App\Models\SocialAccount;
|
||
use App\Models\Conversation;
|
||
use App\Models\ConversationParticipant;
|
||
use App\Models\Message;
|
||
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',
|
||
'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<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',
|
||
'deleted_at' => 'datetime',
|
||
'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 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');
|
||
}
|
||
|
||
public function posts(): HasMany
|
||
{
|
||
return $this->hasMany(Post::class)->orderByDesc('created_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(),
|
||
];
|
||
}
|
||
}
|