Files
SkinbaseNova/app/Services/HomepageService.php

606 lines
25 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Tag;
use App\Services\ArtworkSearchService;
use App\Services\Recommendation\RecommendationService;
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,
private readonly RecommendationService $reco,
) {}
// ─────────────────────────────────────────────────────────────────────────
// Public aggregator
// ─────────────────────────────────────────────────────────────────────────
/**
* Return all homepage section data as a single array ready to JSON-encode.
*/
public function all(): array
{
return [
'hero' => $this->getHeroArtwork(),
'rising' => $this->getRising(),
'trending' => $this->getTrending(),
'fresh' => $this->getFreshUploads(),
'tags' => $this->getPopularTags(),
'creators' => $this->getCreatorSpotlight(),
'news' => $this->getNews(),
];
}
/**
* Personalized homepage data for an authenticated user.
*
* Sections:
* 1. user_data welcome row counts (messages, notifications, new followers)
* 2. from_following artworks from creators you follow
* 3. trending same trending feed as guests
* 4. by_tags artworks matching user's top tags (Trending For You)
* 5. by_categories fresh uploads in user's favourite categories
* 6. suggested_creators creators the user might want to follow
* 7. tags / creators / news shared with guest homepage
*/
public function allForUser(\App\Models\User $user): array
{
$prefs = $this->prefs->build($user);
return [
'is_logged_in' => true,
'user_data' => $this->getUserData($user),
'hero' => $this->getHeroArtwork(),
'for_you' => $this->getForYouPreview($user),
'from_following' => $this->getFollowingFeed($user, $prefs),
'rising' => $this->getRising(),
'trending' => $this->getTrending(),
'fresh' => $this->getFreshUploads(),
'by_tags' => $this->getByTags($prefs['top_tags'] ?? []),
'by_categories' => $this->getByCategories($prefs['top_categories'] ?? []),
'suggested_creators' => $this->getSuggestedCreators($user, $prefs),
'tags' => $this->getPopularTags(),
'creators' => $this->getCreatorSpotlight(),
'news' => $this->getNews(),
'preferences' => [
'top_tags' => $prefs['top_tags'] ?? [],
'top_categories' => $prefs['top_categories'] ?? [],
],
];
}
/**
* "For You" homepage preview: first 12 results from the Phase 1 personalised feed.
*
* Uses RecommendationService which handles Meilisearch retrieval, PHP reranking,
* diversity controls, and its own Redis cache layer.
*/
public function getForYouPreview(\App\Models\User $user, int $limit = 12): array
{
try {
return $this->reco->forYouPreview($user, $limit);
} catch (\Throwable $e) {
Log::warning('HomepageService::getForYouPreview failed', ['error' => $e->getMessage()]);
return [];
}
}
// ─────────────────────────────────────────────────────────────────────────
// 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;
});
}
/**
* Rising Now: up to 10 artworks sorted by heat_score (updated every 15 min).
*
* Surfaces artworks with the fastest recent engagement growth.
* Falls back to DB ORDER BY heat_score if Meilisearch is unavailable.
*/
public function getRising(int $limit = 10): array
{
$cutoff = now()->subDays(30)->toDateString();
return Cache::remember("homepage.rising.{$limit}", 120, function () use ($limit, $cutoff): array {
try {
$results = Artwork::search('')
->options([
'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"',
'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'created_at:desc'],
])
->paginate($limit, 'page', 1);
$results->getCollection()->load(['user:id,name,username', 'user.profile:user_id,avatar_hash']);
if ($results->isEmpty()) {
return $this->getRisingFromDb($limit);
}
return $results->getCollection()
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
} catch (\Throwable $e) {
Log::warning('HomepageService::getRising Meilisearch unavailable, DB fallback', [
'error' => $e->getMessage(),
]);
return $this->getRisingFromDb($limit);
}
});
}
/**
* DB-only fallback for rising (Meilisearch unavailable).
*/
private function getRisingFromDb(int $limit): array
{
return Artwork::public()
->published()
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->where('artworks.published_at', '>=', now()->subDays(30))
->orderByDesc('artwork_stats.heat_score')
->orderByDesc('artwork_stats.engagement_velocity')
->limit($limit)
->get()
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
}
/**
* Trending: up to 12 artworks sorted by Ranking V2 `ranking_score`.
*
* Uses Meilisearch sorted by the V2 score (updated every 30 min).
* Falls back to DB ORDER BY ranking_score if Meilisearch is unavailable.
* Spec §6: ranking_score, last 30 days, highlight high-velocity artworks.
*/
public function getTrending(int $limit = 10): array
{
$cutoff = now()->subDays(30)->toDateString();
return Cache::remember("homepage.trending.{$limit}", self::CACHE_TTL, function () use ($limit, $cutoff): array {
try {
$results = Artwork::search('')
->options([
'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"',
'sort' => ['ranking_score:desc', 'engagement_velocity: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).
* Joins artwork_stats to sort by V2 ranking_score.
*/
private function getTrendingFromDb(int $limit): array
{
return Artwork::public()
->published()
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->where('artworks.published_at', '>=', now()->subDays(30))
->orderByDesc('artwork_stats.ranking_score')
->orderByDesc('artwork_stats.engagement_velocity')
->limit($limit)
->get()
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
}
/**
* Fresh uploads: latest 12 approved public artworks.
*/
public function getFreshUploads(int $limit = 10): 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)
// ─────────────────────────────────────────────────────────────────────────
/**
* Welcome-row counts: unread messages, unread notifications, new followers.
* Returns quickly from DB using simple COUNTs; never throws.
*/
public function getUserData(\App\Models\User $user): array
{
try {
$unreadMessages = DB::table('conversations as c')
->join('conversation_participants as cp', 'cp.conversation_id', '=', 'c.id')
->join('messages as m', 'm.conversation_id', '=', 'c.id')
->where('cp.user_id', $user->id)
->where('m.user_id', '!=', $user->id)
->whereColumn('m.created_at', '>', 'cp.last_read_at')
->distinct('c.id')
->count('c.id');
} catch (\Throwable) {
$unreadMessages = 0;
}
try {
$unreadNotifications = DB::table('notifications')
->where('user_id', $user->id)
->whereNull('read_at')
->count();
} catch (\Throwable) {
$unreadNotifications = 0;
}
return [
'id' => $user->id,
'name' => $user->name,
'username' => $user->username,
'avatar' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash ?? null, 64),
'messages_unread' => (int) $unreadMessages,
'notifications_unread' => (int) $unreadNotifications,
'followers_count' => (int) ($user->statistics?->followers_count ?? 0),
];
}
/**
* Suggested creators: active public uploaders NOT already followed by the user,
* ranked by follower count. Optionally filtered to the user's top categories.
*/
public function getSuggestedCreators(\App\Models\User $user, array $prefs, int $limit = 8): array
{
return Cache::remember(
"homepage.suggested.{$user->id}",
300,
function () use ($user, $prefs, $limit): array {
try {
$followingIds = $prefs['followed_creators'] ?? [];
$query = DB::table('users as u')
->join('user_profiles as up', 'up.user_id', '=', 'u.id')
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
->select(
'u.id',
'u.name',
'u.username',
'up.avatar_hash',
DB::raw('COALESCE(us.followers_count, 0) as followers_count'),
DB::raw('COALESCE(us.artworks_count, 0) as artworks_count'),
)
->where('u.id', '!=', $user->id)
->whereNotIn('u.id', array_merge($followingIds, [$user->id]))
->where('u.is_active', true)
->orderByDesc('followers_count')
->orderByDesc('artworks_count')
->limit($limit);
$rows = $query->get();
return $rows->map(fn ($u) => [
'id' => $u->id,
'name' => $u->name,
'username' => $u->username,
'url' => $u->username ? '/@' . $u->username : '/profile/' . $u->id,
'avatar' => AvatarUrl::forUser((int) $u->id, $u->avatar_hash ?: null, 64),
'followers_count' => (int) $u->followers_count,
'artworks_count' => (int) $u->artworks_count,
])->values()->all();
} catch (\Throwable $e) {
Log::warning('HomepageService::getSuggestedCreators failed', ['error' => $e->getMessage()]);
return [];
}
}
);
}
/**
* 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(10)
->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(),
];
}
}