*/ private function featuredRelations(): array { return [ 'user:id,name,username', 'user.profile:user_id,avatar_hash', 'awardStat:artwork_id,gold_count,silver_count,bronze_count,score_total,score_7d,score_30d,last_medaled_at,updated_at', 'categories' => function ($q) { $q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order'); }, ]; } /** * Lightweight relations needed to render browse/list cards. * * @return array */ private function browseRelations(): array { return [ 'user:id,name,username', 'user.profile:user_id,avatar_hash', 'group:id,name,slug,avatar_path', 'categories' => function ($q) { $q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order') ->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']); }, ]; } /** * Shared browse query used by /browse, content-type pages, and category pages. */ private function browseQuery(string $sort = 'latest'): Builder { $query = Artwork::public() ->published() ->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user())) ->with($this->browseRelations()); $normalizedSort = strtolower(trim($sort)); if ($normalizedSort === 'oldest') { return $query->orderBy('published_at', 'asc'); } return $query->orderByDesc('published_at'); } /** * Fetch a single public artwork by slug. * Applies visibility rules (public + approved + not-deleted). * * @param string $slug * @return Artwork * @throws ModelNotFoundException */ public function getPublicArtworkBySlug(string $slug): Artwork { $key = 'artwork:' . $slug; $artwork = Cache::remember($key, $this->cacheTtl, function () use ($slug) { $a = Artwork::where('slug', $slug) ->public() ->published() ->first(); if (! $a) { return null; } // Load lightweight relations for presentation; do NOT eager-load stats here. $a->load(['translations', 'categories']); return $a; }); if (! $artwork) { $e = new ModelNotFoundException(); $e->setModel(Artwork::class, [$slug]); throw $e; } return $artwork; } /** * Clear artwork cache by model instance. */ public function clearArtworkCache(Artwork $artwork): void { $this->clearArtworkCacheBySlug($artwork->slug); } /** * Clear artwork cache by slug. */ public function clearArtworkCacheBySlug(string $slug): void { Cache::forget('artwork:' . $slug); } /** * Get artworks for a given category, applying visibility rules and cursor pagination. * Returns a CursorPaginator so controllers/resources can render paginated feeds. * * @param Category $category * @param int $perPage * @return CursorPaginator */ public function getCategoryArtworks(Category $category, int $perPage = 24): CursorPaginator { $query = Artwork::public()->published() ->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user())) ->with($this->browseRelations()) ->whereHas('categories', function ($q) use ($category) { $q->where('categories.id', $category->id); }) ->orderByDesc('published_at'); // Important: do NOT eager-load artwork_stats in listings return $query->cursorPaginate($perPage); } /** * Return the latest public artworks up to $limit. * * @param int $limit * @return \Illuminate\Support\Collection|EloquentCollection */ public function getLatestArtworks(int $limit = 10): Collection { return Artwork::public()->published() ->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user())) ->orderByDesc('published_at') ->limit($limit) ->get(); } /** * Browse all public, approved, published artworks with pagination. * Uses new authoritative tables only (no legacy joins) and eager-loads * lightweight relations needed for presentation. */ public function browsePublicArtworks(int $perPage = 24, string $sort = 'latest'): CursorPaginator { $query = $this->browseQuery($sort); // Use cursor pagination for high-load browse feeds (SEO handled via canonical URLs). return $query->cursorPaginate($perPage); } /** * Browse artworks scoped to a content type slug using keyset pagination. * Applies public + approved + published filters. */ public function getArtworksByContentType(string $slug, int $perPage, string $sort = 'latest'): CursorPaginator { $contentType = $this->resolveContentTypeOrFail($slug); $query = $this->browseQuery($sort) ->whereHas('categories', function ($q) use ($contentType) { $q->where('categories.content_type_id', $contentType->id); }); return $query->cursorPaginate($perPage); } /** * Browse artworks for a category path (content type slug + nested category slugs). * Uses slug-only resolution and keyset pagination. * * @param array $slugs */ public function getArtworksByCategoryPath(array $slugs, int $perPage, string $sort = 'latest'): CursorPaginator { $current = $this->resolveCategoryByPath($slugs); $categoryIds = $this->categoryAndDescendantIds($current); $query = $this->browseQuery($sort) ->whereHas('categories', function ($q) use ($categoryIds) { $q->whereIn('categories.id', $categoryIds); }); return $query->cursorPaginate($perPage); } /** * Resolve a category path within a content type using one category query. * * @param array $slugs * @throws ModelNotFoundException */ public function resolveCategoryByPath(array $slugs): Category { if (empty($slugs)) { $e = new ModelNotFoundException(); $e->setModel(Category::class); throw $e; } $parts = array_values(array_map('strtolower', $slugs)); $contentTypeSlug = array_shift($parts); $contentType = $this->resolveContentTypeOrFail((string) $contentTypeSlug); if (empty($parts)) { $e = new ModelNotFoundException(); $e->setModel(Category::class, []); throw $e; } $categories = Category::query() ->where('content_type_id', $contentType->id) ->get(); $categoriesByParent = []; foreach ($categories as $category) { $parentId = $category->parent_id !== null ? (int) $category->parent_id : 0; $categoriesByParent[$parentId][strtolower((string) $category->slug)] = $category; $category->setRelation('contentType', $contentType); } $current = null; $parentId = 0; foreach ($parts as $slug) { $next = $categoriesByParent[$parentId][$slug] ?? null; if (! $next instanceof Category) { $e = new ModelNotFoundException(); $e->setModel(Category::class, $slugs); throw $e; } if ($current instanceof Category) { $next->setRelation('parent', $current); } $current = $next; $parentId = (int) $current->id; } if (! $current instanceof Category) { $e = new ModelNotFoundException(); $e->setModel(Category::class, $slugs); throw $e; } return $current; } /** * Collect category id plus all descendant category ids. * * @return array */ private function categoryAndDescendantIds(Category $category): array { $allIds = [(int) $category->id]; $frontier = [(int) $category->id]; while (! empty($frontier)) { $children = Category::whereIn('parent_id', $frontier) ->pluck('id') ->map(static fn ($id): int => (int) $id) ->all(); if (empty($children)) { break; } $newIds = array_values(array_diff($children, $allIds)); if (empty($newIds)) { break; } $allIds = array_values(array_unique(array_merge($allIds, $newIds))); $frontier = $newIds; } return $allIds; } private function resolveContentTypeOrFail(string $slug): ContentType { $resolution = $this->contentTypeResolver->resolve($slug); if (! $resolution->found() || $resolution->contentType === null) { $e = new ModelNotFoundException(); $e->setModel(ContentType::class, [$slug]); throw $e; } return $resolution->contentType; } /** * Get featured artworks ordered by featured_at DESC, optionally filtered by type. * Uses artwork_features table and applies public/approved/published filters. */ private function featuredBaseQuery(?int $type): Builder { $query = Artwork::query() ->select('artworks.*') ->join('artwork_features as af', 'af.artwork_id', '=', 'artworks.id') ->leftJoin('artwork_medal_stats as aas', 'aas.artwork_id', '=', 'artworks.id') ->where('af.is_active', true) ->whereNull('af.deleted_at') ->where(function ($query): void { $query->whereNull('af.expires_at') ->orWhere('af.expires_at', '>', now()); }); if ($type !== null && $this->featuredTypeColumnExists()) { $query->where('af.type', $type); } return $query; } private function applyFeaturedEligibilityFilters(Builder $query): void { $query->public() ->published() ->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user())) ->withoutMissingThumbnails(); } private function applyFeaturedOrdering(Builder $query): Builder { if (Schema::hasColumn('artwork_features', 'force_hero')) { $query->orderByDesc('af.force_hero'); } return $query ->orderByDesc('af.priority') ->orderByRaw('COALESCE(aas.score_30d, 0) DESC') ->orderByDesc('af.featured_at') ->orderByDesc('artworks.published_at'); } private function featuredSelectionQuery(?int $type): Builder { $query = $this->featuredBaseQuery($type); $this->applyFeaturedEligibilityFilters($query); return $this->applyFeaturedOrdering($query); } private function featuredTypeColumnExists(): bool { if ($this->featureTypeColumnExists === null) { $this->featureTypeColumnExists = Schema::hasColumn('artwork_features', 'type'); } return $this->featureTypeColumnExists; } private function featuredHeroSelectionQuery(?int $type): Builder { $query = $this->featuredBaseQuery($type); if (Schema::hasColumn('artwork_features', 'force_hero')) { $query->where(function (Builder $selection): void { $selection->where('af.force_hero', true) ->orWhere(function (Builder $eligible): void { $this->applyFeaturedEligibilityFilters($eligible); }); }); } else { $this->applyFeaturedEligibilityFilters($query); } return $this->applyFeaturedOrdering($query); } public function getFeaturedArtworks(?int $type, int $perPage = 39): LengthAwarePaginator { return $this->featuredSelectionQuery($type) ->with($this->featuredRelations()) ->paginate($perPage) ->withQueryString(); } public function getFeaturedArtworkWinner(?int $type = null): ?Artwork { $artwork = $this->featuredHeroSelectionQuery($type) ->with($this->featuredRelations()) ->first(); return $artwork instanceof Artwork ? $artwork : null; } /** * Get artworks belonging to a specific user. * If the requester is the owner, return all non-deleted artworks for that user. * Public visitors only see public + approved + published artworks. * * @param int $userId * @param bool $isOwner * @param int $perPage * @return CursorPaginator */ public function getArtworksByUser(int $userId, bool $isOwner, int $perPage = 24, ?User $viewer = null): CursorPaginator { $query = Artwork::where('user_id', $userId) ->with([ 'user:id,name,username,level,rank', 'user.profile:user_id,avatar_hash', 'group:id,name,slug,avatar_path', 'stats:artwork_id,views,downloads,favorites', 'categories' => function ($q) { $q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order') ->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']); }, ]) ->orderByDesc('published_at'); if (! $isOwner) { // Apply public visibility constraints for non-owners $query->public()->published(); $this->maturity->applyViewerFilter($query, $viewer); } else { // Owner: include all non-deleted items (do not force published/approved) $query->whereNull('deleted_at'); } return $query->cursorPaginate($perPage); } }