storing analytics data
This commit is contained in:
@@ -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
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user