feedService = $feedService; } /** * Return trending posts for the given viewer. * * @param int|null $viewerId * @param int $page * @param int $perPage * @return array{data: array, meta: array} */ public function getTrending(?int $viewerId, int $page = 1, int $perPage = 20): array { $rankedIds = $this->getRankedIds(); // Paginate from the ranked ID list $total = count($rankedIds); $pageIds = array_slice($rankedIds, ($page - 1) * $perPage, $perPage); if (empty($pageIds)) { return ['data' => [], 'meta' => ['total' => $total, 'current_page' => $page, 'last_page' => (int) ceil($total / $perPage) ?: 1, 'per_page' => $perPage]]; } // Load posts preserving ranked order $posts = Post::with($this->feedService->publicEagerLoads()) ->whereIn('id', $pageIds) ->get() ->keyBy('id'); $ordered = array_filter(array_map(fn ($id) => $posts->get($id), $pageIds)); $data = array_values(array_map( fn ($post) => $this->feedService->formatPost($post, $viewerId), $ordered, )); return [ 'data' => $data, 'meta' => [ 'total' => $total, 'current_page' => $page, 'last_page' => (int) ceil($total / $perPage) ?: 1, 'per_page' => $perPage, ], ]; } /** * Get or compute the ranked post-ID list from cache. * * @return int[] */ public function getRankedIds(): array { return Cache::remember(self::CACHE_KEY, self::CACHE_TTL, function () { return $this->computeRankedIds(); }); } /** Force a cache refresh (called by the CLI command). */ public function refresh(): array { Cache::forget(self::CACHE_KEY); return $this->getRankedIds(); } // ───────────────────────────────────────────────────────────────────────── private function computeRankedIds(): array { $cutoff = now()->subDays(self::WINDOW_DAYS); $rows = DB::table('posts') ->leftJoin( DB::raw('(SELECT post_id, COUNT(*) as unique_reactors FROM post_reactions GROUP BY post_id) pr'), 'posts.id', '=', 'pr.post_id', ) ->where('posts.status', Post::STATUS_PUBLISHED) ->where('posts.visibility', Post::VISIBILITY_PUBLIC) ->where('posts.created_at', '>=', $cutoff) ->whereNull('posts.deleted_at') ->select([ 'posts.id', 'posts.user_id', 'posts.reactions_count', 'posts.comments_count', 'posts.created_at', DB::raw('COALESCE(pr.unique_reactors, 0) as unique_reactors'), ]) ->get(); $now = now()->timestamp; $scored = $rows->map(function ($row) use ($now) { $hoursSince = ($now - strtotime($row->created_at)) / 3600; $base = ($row->reactions_count * 3) + ($row->comments_count * 5) + ($row->unique_reactors * 4); $score = $base * exp(-$hoursSince / 24); return ['id' => $row->id, 'user_id' => $row->user_id, 'score' => $score]; })->sortByDesc('score'); // Apply author diversity: max MAX_PER_AUTHOR posts per author $authorCount = []; $result = []; foreach ($scored as $item) { $uid = $item['user_id']; $authorCount[$uid] = ($authorCount[$uid] ?? 0) + 1; if ($authorCount[$uid] <= self::MAX_PER_AUTHOR) { $result[] = $item['id']; } } return $result; } }