feat: ship creator journey v2 and profile updates
This commit is contained in:
@@ -4,6 +4,9 @@ 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;
|
||||
@@ -11,6 +14,7 @@ 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
|
||||
@@ -23,6 +27,30 @@ 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.
|
||||
*
|
||||
@@ -32,7 +60,7 @@ class ArtworkService
|
||||
{
|
||||
return [
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_url',
|
||||
'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')
|
||||
@@ -48,6 +76,7 @@ class ArtworkService
|
||||
{
|
||||
$query = Artwork::public()
|
||||
->published()
|
||||
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
|
||||
->with($this->browseRelations());
|
||||
|
||||
$normalizedSort = strtolower(trim($sort));
|
||||
@@ -122,6 +151,7 @@ class ArtworkService
|
||||
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);
|
||||
@@ -141,6 +171,7 @@ class ArtworkService
|
||||
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();
|
||||
@@ -165,13 +196,7 @@ class ArtworkService
|
||||
*/
|
||||
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;
|
||||
}
|
||||
$contentType = $this->resolveContentTypeOrFail($slug);
|
||||
|
||||
$query = $this->browseQuery($sort)
|
||||
->whereHas('categories', function ($q) use ($contentType) {
|
||||
@@ -198,12 +223,7 @@ class ArtworkService
|
||||
$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;
|
||||
}
|
||||
$contentType = $this->resolveContentTypeOrFail((string) $contentTypeSlug);
|
||||
|
||||
if (empty($parts)) {
|
||||
$e = new ModelNotFoundException();
|
||||
@@ -274,30 +294,102 @@ class ArtworkService
|
||||
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.
|
||||
*/
|
||||
public function getFeaturedArtworks(?int $type, int $perPage = 39): LengthAwarePaginator
|
||||
private function featuredBaseQuery(?int $type): Builder
|
||||
{
|
||||
$query = Artwork::query()
|
||||
return Artwork::query()
|
||||
->select('artworks.*')
|
||||
->join('artwork_features as af', 'af.artwork_id', '=', 'artworks.id')
|
||||
->public()
|
||||
->published()
|
||||
->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);
|
||||
})
|
||||
->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');
|
||||
},
|
||||
])
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
return $query->paginate($perPage)->withQueryString();
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -310,11 +402,13 @@ class ArtworkService
|
||||
* @param int $perPage
|
||||
* @return CursorPaginator
|
||||
*/
|
||||
public function getArtworksByUser(int $userId, bool $isOwner, int $perPage = 24): 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')
|
||||
@@ -326,6 +420,7 @@ class ArtworkService
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user