'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'], }; } }