published() ->with([ 'user:id,name', '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']); }, ]); $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() ->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() ->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 = ContentType::where('slug', strtolower($slug))->first(); if (! $contentType) { $e = new ModelNotFoundException(); $e->setModel(ContentType::class, [$slug]); throw $e; } $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 { if (empty($slugs)) { $e = new ModelNotFoundException(); $e->setModel(Category::class); throw $e; } $parts = array_values(array_map('strtolower', $slugs)); $contentTypeSlug = array_shift($parts); $contentType = ContentType::where('slug', $contentTypeSlug)->first(); if (! $contentType) { $e = new ModelNotFoundException(); $e->setModel(ContentType::class, [$contentTypeSlug]); throw $e; } if (empty($parts)) { $e = new ModelNotFoundException(); $e->setModel(Category::class, []); throw $e; } // Resolve the category path from roots downward within the content type. $current = Category::where('content_type_id', $contentType->id) ->whereNull('parent_id') ->where('slug', array_shift($parts)) ->first(); if (! $current) { $e = new ModelNotFoundException(); $e->setModel(Category::class, $slugs); throw $e; } foreach ($parts as $slug) { $current = $current->children()->where('slug', $slug)->first(); if (! $current) { $e = new ModelNotFoundException(); $e->setModel(Category::class, $slugs); throw $e; } } $categoryIds = $this->categoryAndDescendantIds($current); $query = $this->browseQuery($sort) ->whereHas('categories', function ($q) use ($categoryIds) { $q->whereIn('categories.id', $categoryIds); }); return $query->cursorPaginate($perPage); } /** * 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; } /** * Get featured artworks ordered by featured_at DESC, optionally filtered by type. * Uses artwork_features table and applies public/approved/published filters. */ public function getFeaturedArtworks(?int $type, int $perPage = 39): LengthAwarePaginator { $query = Artwork::query() ->select('artworks.*') ->join('artwork_features as af', 'af.artwork_id', '=', 'artworks.id') ->public() ->published() ->when($type !== null, function ($q) use ($type) { $q->where('af.type', $type); }) ->with([ 'user:id,name,username', 'categories' => function ($q) { $q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order'); }, ]) ->orderByDesc('af.featured_at') ->orderByDesc('artworks.published_at'); return $query->paginate($perPage)->withQueryString(); } /** * 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): CursorPaginator { $query = Artwork::where('user_id', $userId) ->with([ 'user:id,name,username,level,rank', '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(); } else { // Owner: include all non-deleted items (do not force published/approved) $query->whereNull('deleted_at'); } return $query->cursorPaginate($perPage); } }