Current state
This commit is contained in:
292
app/Services/ArtworkService.php
Normal file
292
app/Services/ArtworkService.php
Normal file
@@ -0,0 +1,292 @@
|
||||
<?php
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\ArtworkFeature;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Contracts\Pagination\CursorPaginator;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* ArtworkService
|
||||
*
|
||||
* Business logic for retrieving artworks. Controllers should remain thin and
|
||||
* delegate to this service. This service never returns JSON or accesses
|
||||
* the request() helper directly.
|
||||
*/
|
||||
class ArtworkService
|
||||
{
|
||||
protected int $cacheTtl = 3600; // seconds
|
||||
|
||||
/**
|
||||
* 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): CursorPaginator
|
||||
{
|
||||
$query = Artwork::public()
|
||||
->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']);
|
||||
},
|
||||
])
|
||||
->orderByDesc('published_at');
|
||||
|
||||
// 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): CursorPaginator
|
||||
{
|
||||
$contentType = ContentType::where('slug', strtolower($slug))->first();
|
||||
|
||||
if (! $contentType) {
|
||||
$e = new ModelNotFoundException();
|
||||
$e->setModel(ContentType::class, [$slug]);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$query = Artwork::public()
|
||||
->published()
|
||||
->whereHas('categories', function ($q) use ($contentType) {
|
||||
$q->where('categories.content_type_id', $contentType->id);
|
||||
})
|
||||
->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']);
|
||||
},
|
||||
])
|
||||
->orderByDesc('published_at');
|
||||
|
||||
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<int, string> $slugs
|
||||
*/
|
||||
public function getArtworksByCategoryPath(array $slugs, int $perPage): 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;
|
||||
}
|
||||
}
|
||||
|
||||
$query = Artwork::public()
|
||||
->published()
|
||||
->whereHas('categories', function ($q) use ($current) {
|
||||
$q->where('categories.id', $current->id);
|
||||
})
|
||||
->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']);
|
||||
},
|
||||
])
|
||||
->orderByDesc('published_at');
|
||||
|
||||
return $query->cursorPaginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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',
|
||||
'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',
|
||||
'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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user