storing analytics data

This commit is contained in:
2026-02-27 09:46:51 +01:00
parent 15b7b77d20
commit f0cca76eb3
57 changed files with 3478 additions and 466 deletions

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkSearchService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Cache;
/**
* GET /api/art/{id}/similar
*
* Returns up to 12 similar artworks based on:
* 1. Tag overlap (primary signal)
* 2. Same category
* 3. Similar orientation
*
* Uses Meilisearch via ArtworkSearchService for fast retrieval.
* Current artwork and its creator are excluded from results.
*/
final class SimilarArtworksController extends Controller
{
private const LIMIT = 12;
private const CACHE_TTL = 300; // 5 minutes
public function __construct(private readonly ArtworkSearchService $search) {}
public function __invoke(int $id): JsonResponse
{
$artwork = Artwork::public()
->published()
->with(['tags:id,slug', 'categories:id,slug'])
->find($id);
if (! $artwork) {
return response()->json(['error' => 'Artwork not found'], 404);
}
$cacheKey = "api.similar.{$artwork->id}";
$items = Cache::remember($cacheKey, self::CACHE_TTL, function () use ($artwork) {
return $this->findSimilar($artwork);
});
return response()->json(['data' => $items]);
}
private function findSimilar(Artwork $artwork): array
{
$tagSlugs = $artwork->tags->pluck('slug')->values()->all();
$categorySlugs = $artwork->categories->pluck('slug')->values()->all();
$orientation = $this->orientation($artwork);
// Build Meilisearch filter: exclude self and same creator
$filterParts = [
'is_public = true',
'is_approved = true',
'id != ' . $artwork->id,
'author_id != ' . $artwork->user_id,
];
// Filter by same orientation (landscape/portrait) — improves visual coherence
if ($orientation !== 'square') {
$filterParts[] = 'orientation = "' . $orientation . '"';
}
// Priority 1: tag overlap (OR match across tags)
if ($tagSlugs !== []) {
$tagFilter = implode(' OR ', array_map(
fn (string $t): string => 'tags = "' . addslashes($t) . '"',
$tagSlugs
));
$filterParts[] = '(' . $tagFilter . ')';
} elseif ($categorySlugs !== []) {
// Fallback to category if no tags
$catFilter = implode(' OR ', array_map(
fn (string $c): string => 'category = "' . addslashes($c) . '"',
$categorySlugs
));
$filterParts[] = '(' . $catFilter . ')';
}
$results = Artwork::search('')
->options([
'filter' => implode(' AND ', $filterParts),
'sort' => ['trending_score_7d:desc', 'likes:desc'],
])
->paginate(self::LIMIT);
return $results->getCollection()
->map(fn (Artwork $a): array => [
'id' => $a->id,
'title' => $a->title,
'slug' => $a->slug,
'thumb' => $a->thumbUrl('md'),
'url' => '/art/' . $a->id . '/' . $a->slug,
'author_id' => $a->user_id,
'orientation' => $this->orientation($a),
'width' => $a->width,
'height' => $a->height,
])
->values()
->all();
}
private function orientation(Artwork $artwork): string
{
if (! $artwork->width || ! $artwork->height) {
return 'square';
}
return match (true) {
$artwork->width > $artwork->height => 'landscape',
$artwork->height > $artwork->width => 'portrait',
default => 'square',
};
}
}