426 lines
17 KiB
PHP
426 lines
17 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
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;
|
||
use Illuminate\Support\Facades\Log;
|
||
use Illuminate\Database\QueryException;
|
||
|
||
/**
|
||
* HomepageService
|
||
*
|
||
* Aggregates all data sections needed for the Nova homepage.
|
||
* All results are cached for CACHE_TTL seconds.
|
||
* Controllers stay thin — only call the aggregator.
|
||
*/
|
||
final class HomepageService
|
||
{
|
||
private const CACHE_TTL = 300; // 5 minutes
|
||
|
||
public function __construct(
|
||
private readonly ArtworkService $artworks,
|
||
private readonly ArtworkSearchService $search,
|
||
private readonly UserPreferenceService $prefs,
|
||
) {}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
// Public aggregator
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Return all homepage section data as a single array ready to JSON-encode.
|
||
*/
|
||
public function all(): array
|
||
{
|
||
return [
|
||
'hero' => $this->getHeroArtwork(),
|
||
'trending' => $this->getTrending(),
|
||
'fresh' => $this->getFreshUploads(),
|
||
'tags' => $this->getPopularTags(),
|
||
'creators' => $this->getCreatorSpotlight(),
|
||
'news' => $this->getNews(),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Hero artwork: first item from the featured list.
|
||
*/
|
||
public function getHeroArtwork(): ?array
|
||
{
|
||
return Cache::remember('homepage.hero', self::CACHE_TTL, function (): ?array {
|
||
$result = $this->artworks->getFeaturedArtworks(null, 1);
|
||
|
||
/** @var \Illuminate\Database\Eloquent\Model|\null $artwork */
|
||
if ($result instanceof \Illuminate\Pagination\LengthAwarePaginator) {
|
||
$artwork = $result->getCollection()->first();
|
||
} elseif ($result instanceof \Illuminate\Support\Collection) {
|
||
$artwork = $result->first();
|
||
} elseif (is_array($result)) {
|
||
$artwork = $result[0] ?? null;
|
||
} else {
|
||
$artwork = null;
|
||
}
|
||
|
||
return $artwork ? $this->serializeArtwork($artwork, 'lg') : null;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Trending: up to 12 artworks sorted by pre-computed trending_score_7d.
|
||
*
|
||
* 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 {
|
||
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);
|
||
|
||
$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);
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
public function getFreshUploads(int $limit = 12): array
|
||
{
|
||
return Cache::remember("homepage.fresh.{$limit}", self::CACHE_TTL, function () use ($limit): array {
|
||
$artworks = Artwork::public()
|
||
->published()
|
||
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
|
||
->orderByDesc('published_at')
|
||
->limit($limit)
|
||
->get();
|
||
|
||
return $artworks->map(fn ($a) => $this->serializeArtwork($a))->values()->all();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Top 12 popular tags by usage_count.
|
||
*/
|
||
public function getPopularTags(int $limit = 12): array
|
||
{
|
||
return Cache::remember("homepage.tags.{$limit}", self::CACHE_TTL, function () use ($limit): array {
|
||
return Tag::query()
|
||
->where('is_active', true)
|
||
->orderByDesc('usage_count')
|
||
->limit($limit)
|
||
->get(['id', 'name', 'slug', 'usage_count'])
|
||
->map(fn ($t) => [
|
||
'id' => $t->id,
|
||
'name' => $t->name,
|
||
'slug' => $t->slug,
|
||
'count' => (int) $t->usage_count,
|
||
])
|
||
->values()
|
||
->all();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Creator spotlight: top 6 creators by weekly uploads, awards, and engagement.
|
||
* "Weekly uploads" drives ranking per spec; ties broken by total awards then views.
|
||
*/
|
||
public function getCreatorSpotlight(int $limit = 6): array
|
||
{
|
||
return Cache::remember("homepage.creators.{$limit}", self::CACHE_TTL, function () use ($limit): array {
|
||
try {
|
||
$since = now()->subWeek();
|
||
|
||
$rows = DB::table('artworks')
|
||
->join('users as u', 'u.id', '=', 'artworks.user_id')
|
||
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
||
->leftJoin('artwork_awards as aw', 'aw.artwork_id', '=', 'artworks.id')
|
||
->leftJoin('artwork_stats as s', 's.artwork_id', '=', 'artworks.id')
|
||
->select(
|
||
'u.id',
|
||
'u.name',
|
||
'u.username',
|
||
'up.avatar_hash',
|
||
DB::raw('COUNT(DISTINCT artworks.id) as upload_count'),
|
||
DB::raw('SUM(CASE WHEN artworks.published_at >= \'' . $since->toDateTimeString() . '\' THEN 1 ELSE 0 END) as weekly_uploads'),
|
||
DB::raw('COALESCE(SUM(s.views), 0) as total_views'),
|
||
DB::raw('COUNT(DISTINCT aw.id) as total_awards')
|
||
)
|
||
->where('artworks.is_public', true)
|
||
->where('artworks.is_approved', true)
|
||
->whereNull('artworks.deleted_at')
|
||
->whereNotNull('artworks.published_at')
|
||
->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash')
|
||
->orderByDesc('weekly_uploads')
|
||
->orderByDesc('total_awards')
|
||
->orderByDesc('total_views')
|
||
->limit($limit)
|
||
->get();
|
||
|
||
$userIds = $rows->pluck('id')->all();
|
||
|
||
// Pick one random artwork thumbnail per creator for the card background.
|
||
$thumbsByUser = Artwork::public()
|
||
->published()
|
||
->whereIn('user_id', $userIds)
|
||
->whereNotNull('hash')
|
||
->whereNotNull('thumb_ext')
|
||
->inRandomOrder()
|
||
->get(['id', 'user_id', 'hash', 'thumb_ext'])
|
||
->groupBy('user_id');
|
||
|
||
return $rows->map(function ($u) use ($thumbsByUser) {
|
||
$artworkForBg = $thumbsByUser->get($u->id)?->first();
|
||
$bgThumb = $artworkForBg ? $artworkForBg->thumbUrl('md') : null;
|
||
|
||
return [
|
||
'id' => $u->id,
|
||
'name' => $u->name,
|
||
'uploads' => (int) $u->upload_count,
|
||
'weekly_uploads' => (int) $u->weekly_uploads,
|
||
'views' => (int) $u->total_views,
|
||
'awards' => (int) $u->total_awards,
|
||
'url' => $u->username ? '/@' . $u->username : '/profile/' . $u->id,
|
||
'avatar' => AvatarUrl::forUser((int) $u->id, $u->avatar_hash ?: null, 128),
|
||
'bg_thumb' => $bgThumb,
|
||
];
|
||
})->values()->all();
|
||
} catch (QueryException $e) {
|
||
Log::warning('HomepageService::getCreatorSpotlight DB error', [
|
||
'exception' => $e->getMessage(),
|
||
]);
|
||
|
||
return [];
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Latest 5 news posts from the forum news category.
|
||
*/
|
||
public function getNews(int $limit = 5): array
|
||
{
|
||
return Cache::remember("homepage.news.{$limit}", self::CACHE_TTL, function () use ($limit): array {
|
||
try {
|
||
$items = DB::table('forum_threads as t')
|
||
->leftJoin('forum_categories as c', 'c.id', '=', 't.category_id')
|
||
->select('t.id', 't.title', 't.created_at', 't.slug as thread_slug')
|
||
->where(function ($q) {
|
||
$q->where('t.category_id', 2876)
|
||
->orWhereIn('c.slug', ['news', 'forum-news']);
|
||
})
|
||
->whereNull('t.deleted_at')
|
||
->orderByDesc('t.created_at')
|
||
->limit($limit)
|
||
->get();
|
||
|
||
return $items->map(fn ($row) => [
|
||
'id' => $row->id,
|
||
'title' => $row->title,
|
||
'date' => $row->created_at,
|
||
'url' => '/forum/thread/' . $row->id . '-' . ($row->thread_slug ?? 'post'),
|
||
])->values()->all();
|
||
} catch (QueryException $e) {
|
||
Log::warning('HomepageService::getNews DB error', [
|
||
'exception' => $e->getMessage(),
|
||
]);
|
||
|
||
return [];
|
||
}
|
||
});
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
// 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
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
|
||
private function serializeArtwork(Artwork $artwork, string $preferSize = 'md'): array
|
||
{
|
||
$thumbMd = $artwork->thumbUrl('md');
|
||
$thumbLg = $artwork->thumbUrl('lg');
|
||
$thumb = $preferSize === 'lg' ? ($thumbLg ?? $thumbMd) : ($thumbMd ?? $thumbLg);
|
||
|
||
$authorId = $artwork->user_id;
|
||
$authorName = $artwork->user?->name ?? 'Artist';
|
||
$authorUsername = $artwork->user?->username ?? '';
|
||
$avatarHash = $artwork->user?->profile?->avatar_hash ?? null;
|
||
$authorAvatar = AvatarUrl::forUser((int) $authorId, $avatarHash, 40);
|
||
|
||
return [
|
||
'id' => $artwork->id,
|
||
'title' => $artwork->title ?? 'Untitled',
|
||
'slug' => $artwork->slug,
|
||
'author' => $authorName,
|
||
'author_id' => $authorId,
|
||
'author_username' => $authorUsername,
|
||
'author_avatar' => $authorAvatar,
|
||
'thumb' => $thumb,
|
||
'thumb_md' => $thumbMd,
|
||
'thumb_lg' => $thumbLg,
|
||
'url' => '/art/' . $artwork->id . '/' . ($artwork->slug ?? ''),
|
||
'width' => $artwork->width,
|
||
'height' => $artwork->height,
|
||
'published_at' => $artwork->published_at?->toIso8601String(),
|
||
];
|
||
}
|
||
}
|