Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View File

@@ -0,0 +1,431 @@
<?php
namespace App\Services;
use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\User;
use App\Services\ContentTypes\ContentTypeSlugResolver;
use App\Services\Maturity\ArtworkMaturityService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Contracts\Pagination\CursorPaginator;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
/**
* 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
public function __construct(
private readonly ContentTypeSlugResolver $contentTypeResolver,
private readonly ArtworkMaturityService $maturity,
)
{
}
/**
* Relations used by the featured artwork surfaces.
*
* @return array<int|string, mixed>
*/
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<int|string, mixed>
*/
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<int, string> $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 = $this->resolveContentTypeOrFail((string) $contentTypeSlug);
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<int, int>
*/
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
{
return 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());
})
->when($type !== null, function ($q) use ($type) {
$q->where('af.type', $type);
});
}
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 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);
}
}