Files
SkinbaseNova/app/Http/Controllers/Api/RankController.php

145 lines
4.8 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\ArtworkListResource;
use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use App\Services\RankingService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
/**
* RankController
*
* Serves pre-computed ranked artwork lists.
*
* Endpoints:
* GET /api/rank/global?type=trending|new_hot|best
* GET /api/rank/category/{id}?type=trending|new_hot|best
* GET /api/rank/type/{contentType}?type=trending|new_hot|best
*/
class RankController extends Controller
{
public function __construct(private readonly RankingService $ranking) {}
/**
* GET /api/rank/global
*
* Returns: { data: [...], meta: { list_type, computed_at, model_version, fallback } }
*/
public function global(Request $request): AnonymousResourceCollection|JsonResponse
{
$listType = $this->resolveListType($request);
$result = $this->ranking->getList('global', null, $listType);
return $this->buildResponse($result, $listType);
}
/**
* GET /api/rank/category/{id}
*/
public function byCategory(Request $request, int $id): AnonymousResourceCollection|JsonResponse
{
if (! Category::where('id', $id)->where('is_active', true)->exists()) {
return response()->json(['message' => 'Category not found.'], 404);
}
$listType = $this->resolveListType($request);
$result = $this->ranking->getList('category', $id, $listType);
return $this->buildResponse($result, $listType);
}
/**
* GET /api/rank/type/{contentType}
*
* {contentType} is accepted as either a slug (string) or numeric id.
*/
public function byContentType(Request $request, string $contentType): AnonymousResourceCollection|JsonResponse
{
$ct = is_numeric($contentType)
? ContentType::find((int) $contentType)
: ContentType::where('slug', $contentType)->first();
if ($ct === null) {
return response()->json(['message' => 'Content type not found.'], 404);
}
$listType = $this->resolveListType($request);
$result = $this->ranking->getList('content_type', $ct->id, $listType);
return $this->buildResponse($result, $listType);
}
// ── Private helpers ────────────────────────────────────────────────────
/**
* Validate and normalise the ?type query param.
* Defaults to 'trending'.
*/
private function resolveListType(Request $request): string
{
$allowed = ['trending', 'new_hot', 'best'];
$type = $request->query('type', 'trending');
return in_array($type, $allowed, true) ? $type : 'trending';
}
/**
* Hydrate artwork IDs into Eloquent models (no N+1) and wrap in resources.
*
* @param array{ids: int[], computed_at: string|null, model_version: string, fallback: bool} $result
*/
private function buildResponse(array $result, string $listType = 'trending'): AnonymousResourceCollection
{
$ids = $result['ids'];
$artworks = collect();
if (! empty($ids)) {
// Single whereIn query — no N+1
$keyed = Artwork::whereIn('id', $ids)
->with([
'user:id,name',
'categories' => function ($q): void {
$q->select(
'categories.id',
'categories.content_type_id',
'categories.parent_id',
'categories.name',
'categories.slug',
'categories.sort_order'
)->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
},
])
->get()
->keyBy('id');
// Restore the ranked order
$artworks = collect($ids)
->filter(fn ($id) => $keyed->has($id))
->map(fn ($id) => $keyed[$id]);
}
$collection = ArtworkListResource::collection($artworks);
// Attach ranking meta as additional data
$collection->additional([
'meta' => [
'list_type' => $listType,
'computed_at' => $result['computed_at'],
'model_version' => $result['model_version'],
'fallback' => $result['fallback'],
'count' => $artworks->count(),
],
]);
return $collection;
}
}