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

@@ -6,6 +6,8 @@ namespace App\Services;
use App\Models\Artwork;
use App\Models\Tag;
use App\Services\ArtworkSearchService;
use App\Services\UserPreferenceService;
use App\Support\AvatarUrl;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
@@ -23,7 +25,11 @@ final class HomepageService
{
private const CACHE_TTL = 300; // 5 minutes
public function __construct(private readonly ArtworkService $artworks) {}
public function __construct(
private readonly ArtworkService $artworks,
private readonly ArtworkSearchService $search,
private readonly UserPreferenceService $prefs,
) {}
// ─────────────────────────────────────────────────────────────────────────
// Public aggregator
@@ -44,6 +50,36 @@ final class HomepageService
];
}
/**
* Personalized homepage data for an authenticated user.
*
* Sections:
* 1. from_following artworks from creators you follow
* 2. trending same trending feed as guests
* 3. by_tags artworks matching user's top tags
* 4. by_categories fresh uploads in user's favourite categories
* 5. tags / creators / news shared with guest homepage
*/
public function allForUser(\App\Models\User $user): array
{
$prefs = $this->prefs->build($user);
return [
'hero' => $this->getHeroArtwork(),
'from_following' => $this->getFollowingFeed($user, $prefs),
'trending' => $this->getTrending(),
'by_tags' => $this->getByTags($prefs['top_tags'] ?? []),
'by_categories' => $this->getByCategories($prefs['top_categories'] ?? []),
'tags' => $this->getPopularTags(),
'creators' => $this->getCreatorSpotlight(),
'news' => $this->getNews(),
'preferences' => [
'top_tags' => $prefs['top_tags'] ?? [],
'top_categories' => $prefs['top_categories'] ?? [],
],
];
}
// ─────────────────────────────────────────────────────────────────────────
// Sections
// ─────────────────────────────────────────────────────────────────────────
@@ -72,54 +108,61 @@ final class HomepageService
}
/**
* Trending: up to 12 artworks ordered by award score, views, downloads, recent activity.
* Trending: up to 12 artworks sorted by pre-computed trending_score_7d.
*
* Award score = SUM(weight × medal_value) where gold=3, silver=2, bronze=1.
* Uses correlated subqueries to avoid GROUP BY issues with MySQL strict mode.
* Uses Meilisearch sorted by the pre-computed score (updated every 30 min).
* Falls back to DB ORDER BY trending_score_7d if Meilisearch is unavailable.
* Spec: no heavy joins in the hot path.
*/
public function getTrending(int $limit = 12): array
{
return Cache::remember("homepage.trending.{$limit}", self::CACHE_TTL, function () use ($limit): array {
$ids = DB::table('artworks')
->select('id')
->selectRaw(
'(SELECT COALESCE(SUM(weight * CASE medal'
. ' WHEN \'gold\' THEN 3'
. ' WHEN \'silver\' THEN 2'
. ' ELSE 1 END), 0)'
. ' FROM artwork_awards WHERE artwork_awards.artwork_id = artworks.id) AS award_score'
)
->selectRaw('COALESCE((SELECT views FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) AS stat_views')
->selectRaw('COALESCE((SELECT downloads FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) AS stat_downloads')
->where('is_public', true)
->where('is_approved', true)
->whereNull('deleted_at')
->whereNotNull('published_at')
->where('published_at', '>=', now()->subDays(30))
->orderByDesc('award_score')
->orderByDesc('stat_views')
->orderByDesc('stat_downloads')
->orderByDesc('published_at')
->limit($limit)
->pluck('id');
try {
$results = Artwork::search('')
->options([
'filter' => 'is_public = true AND is_approved = true',
'sort' => ['trending_score_7d:desc', 'trending_score_24h:desc', 'views:desc'],
])
->paginate($limit, 'page', 1);
if ($ids->isEmpty()) {
return [];
$results->getCollection()->load(['user:id,name,username', 'user.profile:user_id,avatar_hash']);
if ($results->isEmpty()) {
return $this->getTrendingFromDb($limit);
}
return $results->getCollection()
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
} catch (\Throwable $e) {
Log::warning('HomepageService::getTrending Meilisearch unavailable, DB fallback', [
'error' => $e->getMessage(),
]);
return $this->getTrendingFromDb($limit);
}
$indexed = Artwork::with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
->whereIn('id', $ids)
->get()
->keyBy('id');
return $ids
->filter(fn ($id) => $indexed->has($id))
->map(fn ($id) => $this->serializeArtwork($indexed[$id]))
->values()
->all();
});
}
/**
* DB-only fallback for trending (Meilisearch unavailable).
* Uses pre-computed trending_score_7d column no correlated subqueries.
*/
private function getTrendingFromDb(int $limit): array
{
return Artwork::public()
->published()
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
->orderByDesc('trending_score_7d')
->orderByDesc('trending_score_24h')
->limit($limit)
->get()
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
}
/**
* Fresh uploads: latest 12 approved public artworks.
*/
@@ -268,6 +311,84 @@ final class HomepageService
});
}
// ─────────────────────────────────────────────────────────────────────────
// Personalized sections (auth only)
// ─────────────────────────────────────────────────────────────────────────
/**
* Latest artworks from creators the user follows (max 12).
*/
public function getFollowingFeed(\App\Models\User $user, array $prefs): array
{
$followingIds = $prefs['followed_creators'] ?? [];
if (empty($followingIds)) {
return [];
}
return Cache::remember(
"homepage.following.{$user->id}",
60, // short TTL personal data
function () use ($followingIds): array {
$artworks = Artwork::public()
->published()
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
->whereIn('user_id', $followingIds)
->orderByDesc('published_at')
->limit(12)
->get();
return $artworks->map(fn ($a) => $this->serializeArtwork($a))->values()->all();
}
);
}
/**
* Artworks matching the user's top tags (max 12).
* Powered by Meilisearch.
*/
public function getByTags(array $tagSlugs): array
{
if (empty($tagSlugs)) {
return [];
}
try {
$results = $this->search->discoverByTags($tagSlugs, 12);
return $results->getCollection()
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
} catch (\Throwable $e) {
Log::warning('HomepageService::getByTags failed', ['error' => $e->getMessage()]);
return [];
}
}
/**
* Fresh artworks in the user's favourite categories (max 12).
* Powered by Meilisearch.
*/
public function getByCategories(array $categorySlugs): array
{
if (empty($categorySlugs)) {
return [];
}
try {
$results = $this->search->discoverByCategories($categorySlugs, 12);
return $results->getCollection()
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
} catch (\Throwable $e) {
Log::warning('HomepageService::getByCategories failed', ['error' => $e->getMessage()]);
return [];
}
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────