265 lines
9.0 KiB
PHP
265 lines
9.0 KiB
PHP
<?php
|
||
|
||
namespace App\Models;
|
||
|
||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||
use Illuminate\Notifications\Notifiable;
|
||
use Laravel\Sanctum\HasApiTokens;
|
||
|
||
class User extends Authenticatable
|
||
{
|
||
use HasApiTokens, HasFactory, Notifiable;
|
||
|
||
protected $fillable = [
|
||
'name', 'username', 'email', 'password', 'avatar',
|
||
'social_provider', 'social_id',
|
||
'bio', 'banner_image', 'website', 'twitter', 'instagram', 'discord',
|
||
'profile_color', 'show_watchlist', 'show_activity',
|
||
'role', 'membership', 'premium_expires_at',
|
||
'is_banned', 'ban_reason', 'banned_at',
|
||
'gif_avatar', 'comment_bg', 'comment_glow', 'username_color', 'username_effect',
|
||
'profile_frame', 'profile_badge', 'admin_badge', 'profile_bg', 'profile_theme',
|
||
'profile_music_url', 'comment_signature', 'entry_effect', 'animated_banner',
|
||
];
|
||
|
||
protected $hidden = ['password', 'remember_token'];
|
||
|
||
protected function casts(): array
|
||
{
|
||
return [
|
||
'email_verified_at' => 'datetime',
|
||
'premium_expires_at' => 'datetime',
|
||
'banned_at' => 'datetime',
|
||
'is_banned' => 'boolean',
|
||
'show_watchlist' => 'boolean',
|
||
'show_activity' => 'boolean',
|
||
'animated_banner' => 'boolean',
|
||
'password' => 'hashed',
|
||
];
|
||
}
|
||
|
||
/**
|
||
* /u/{username} gibi URL'lerde username veya ID ile çözümleme.
|
||
* custom_profile_url perki olan kullanıcılar /u/kullanici-adi şeklinde erişilebilir.
|
||
*/
|
||
public function resolveRouteBinding($value, $field = null): ?self
|
||
{
|
||
if (is_numeric($value)) {
|
||
return static::find($value);
|
||
}
|
||
return static::where('username', $value)
|
||
->whereNotNull('username')
|
||
->first();
|
||
}
|
||
|
||
public function isAdmin(): bool
|
||
{
|
||
return $this->role === 'admin';
|
||
}
|
||
|
||
public function isModerator(): bool
|
||
{
|
||
return in_array($this->role, ['admin', 'moderator']);
|
||
}
|
||
|
||
public function moderatorPermissions()
|
||
{
|
||
return $this->hasMany(ModeratorPermission::class);
|
||
}
|
||
|
||
public function activityLogs()
|
||
{
|
||
return $this->hasMany(UserActivityLog::class);
|
||
}
|
||
|
||
/** Returns cached permission set for this user. Admins have all permissions. */
|
||
public function can_mod(string $permission): bool
|
||
{
|
||
if ($this->isAdmin()) return true;
|
||
if ($this->role !== 'moderator') return false;
|
||
|
||
$key = "mod_perms_{$this->id}";
|
||
$perms = cache()->remember($key, 300, fn() =>
|
||
ModeratorPermission::where('user_id', $this->id)->pluck('permission')->all()
|
||
);
|
||
return in_array($permission, $perms);
|
||
}
|
||
|
||
/** Flush cached permissions (call after saving changes). */
|
||
public function flushPermCache(): void
|
||
{
|
||
cache()->forget("mod_perms_{$this->id}");
|
||
}
|
||
|
||
/** isPremium() sonucunu istek başına önbellekle — sayfa başına onlarca kez çağrılıyor */
|
||
private ?bool $_isPremiumCache = null;
|
||
|
||
public function isPremium(): bool
|
||
{
|
||
if ($this->_isPremiumCache !== null) {
|
||
return $this->_isPremiumCache;
|
||
}
|
||
|
||
// 1. yol: membership alanı 'premium' ve süresi dolmamış
|
||
$viaMembership = $this->membership === 'premium'
|
||
&& ($this->premium_expires_at === null || $this->premium_expires_at->isFuture());
|
||
if ($viaMembership) {
|
||
return $this->_isPremiumCache = true;
|
||
}
|
||
|
||
// 2. yol: aktif abonelik var ama membership alanı senkronize değil
|
||
// (2 farklı premium yolu — biri güncellenmezse kullanıcı yine premium sayılır)
|
||
try {
|
||
$viaSubscription = $this->subscriptions()
|
||
->where('status', 'active')
|
||
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
|
||
->exists();
|
||
} catch (\Throwable) {
|
||
$viaSubscription = false;
|
||
}
|
||
|
||
return $this->_isPremiumCache = $viaSubscription;
|
||
}
|
||
|
||
/**
|
||
* Kullanıcının aktif planında belirli bir perk var mı?
|
||
* Ücretsiz kullanıcılarda her zaman false döner.
|
||
*/
|
||
public function hasPerk(string $key): bool
|
||
{
|
||
if (!$this->isPremium()) return false;
|
||
|
||
// Ücretsiz mod: tüm perkler herkese açık
|
||
if (self::freeModeActive()) return true;
|
||
|
||
$sub = $this->subscriptions()
|
||
->where('status', 'active')
|
||
->where(fn($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
|
||
->latest()
|
||
->with('plan')
|
||
->first();
|
||
|
||
if (!$sub?->plan) return false;
|
||
|
||
$perks = $sub->plan->perks ?? [];
|
||
return !empty($perks[$key]);
|
||
}
|
||
|
||
/** premium_free_mode ayarını 5dk cache'leyerek okur */
|
||
public static function freeModeActive(): bool
|
||
{
|
||
return cache()->remember('premium_free_mode', 300, fn() =>
|
||
\App\Models\Setting::get('premium_free_mode', '0')
|
||
) === '1';
|
||
}
|
||
|
||
/**
|
||
* Kullanıcının aktif avatar URL'si: GIF avatar varsa ve hasPerk('gif_avatar') ise döner.
|
||
*/
|
||
public function effectiveAvatar(): ?string
|
||
{
|
||
if ($this->gif_avatar && $this->hasPerk('gif_avatar')) {
|
||
return $this->gif_avatar;
|
||
}
|
||
return $this->avatar;
|
||
}
|
||
|
||
public function subscriptions()
|
||
{
|
||
return $this->hasMany(Subscription::class);
|
||
}
|
||
|
||
public function comments()
|
||
{
|
||
return $this->hasMany(Comment::class);
|
||
}
|
||
|
||
// ── Social ────────────────────────────────────────────────────────────────
|
||
|
||
public function followers()
|
||
{
|
||
return $this->belongsToMany(User::class, 'user_follows', 'following_id', 'follower_id')
|
||
->withPivot('created_at');
|
||
}
|
||
|
||
public function following()
|
||
{
|
||
return $this->belongsToMany(User::class, 'user_follows', 'follower_id', 'following_id')
|
||
->withPivot('created_at');
|
||
}
|
||
|
||
public function isFollowing(int $userId): bool
|
||
{
|
||
return \App\Models\UserFollow::where('follower_id', $this->id)
|
||
->where('following_id', $userId)
|
||
->exists();
|
||
}
|
||
|
||
public function conversations()
|
||
{
|
||
return $this->belongsToMany(Conversation::class, 'conversation_participants')
|
||
->withPivot('last_read_at');
|
||
}
|
||
|
||
public function totalUnreadMessages(): int
|
||
{
|
||
return $this->conversations()
|
||
->with(['messages' => fn($q) => $q->where('user_id', '!=', $this->id)])
|
||
->get()
|
||
->sum(fn($c) => $c->unreadCountFor($this->id));
|
||
}
|
||
|
||
// Anime zevk uyum skoru (0-100)
|
||
public function compatibilityWith(User $other): int
|
||
{
|
||
$myIds = \App\Models\Watchlist::where('user_id', $this->id)->pluck('anime_id');
|
||
$theirIds = \App\Models\Watchlist::where('user_id', $other->id)->pluck('anime_id');
|
||
|
||
if ($myIds->isEmpty() || $theirIds->isEmpty()) return 0;
|
||
|
||
$mySet = $myIds->unique()->values();
|
||
$theirSet = $theirIds->unique()->values();
|
||
$intersection = $mySet->intersect($theirSet)->count();
|
||
$union = $mySet->merge($theirSet)->unique()->count();
|
||
|
||
$jaccard = $union > 0 ? $intersection / $union : 0;
|
||
|
||
// Rating similarity bonus
|
||
$myRatings = \DB::table('anime_ratings')->where('user_id', $this->id)->pluck('rating', 'anime_id');
|
||
$theirRatings = \DB::table('anime_ratings')->where('user_id', $other->id)->pluck('rating', 'anime_id');
|
||
$commonAnimes = $myRatings->keys()->intersect($theirRatings->keys());
|
||
|
||
$ratingScore = 0;
|
||
if ($commonAnimes->count() > 0) {
|
||
$diffs = $commonAnimes->map(fn($id) => abs($myRatings[$id] - $theirRatings[$id]) / 10);
|
||
$ratingScore = 1 - $diffs->avg();
|
||
}
|
||
|
||
$score = $jaccard * 0.6 + $ratingScore * 0.4;
|
||
return (int) round(min($score * 100, 100));
|
||
}
|
||
|
||
/**
|
||
* İzleme saatine göre rank bilgisi döner.
|
||
* watch_rank perki yoksa null döner.
|
||
*/
|
||
public function watchRank(): ?array
|
||
{
|
||
if (!$this->hasPerk('watch_rank')) return null;
|
||
|
||
$totalSeconds = \App\Models\ContinueWatching::where('user_id', $this->id)
|
||
->sum('seconds_watched');
|
||
$hours = $totalSeconds / 3600;
|
||
|
||
return match(true) {
|
||
$hours >= 500 => ['label' => 'Efsane', 'color' => '#ff2d7d', 'icon' => 'bi-trophy-fill'],
|
||
$hours >= 250 => ['label' => 'Usta', 'color' => '#ffd700', 'icon' => 'bi-star-fill'],
|
||
$hours >= 100 => ['label' => 'Bağımlı', 'color' => '#b84dff', 'icon' => 'bi-heart-fill'],
|
||
$hours >= 50 => ['label' => 'Hayran', 'color' => '#00f5ff', 'icon' => 'bi-eye-fill'],
|
||
$hours >= 20 => ['label' => 'İzleyici', 'color' => '#00d4a4', 'icon' => 'bi-play-circle-fill'],
|
||
default => ['label' => 'Acemi', 'color' => '#9ca3af', 'icon' => 'bi-controller'],
|
||
};
|
||
}
|
||
}
|