Wire admin studio SSR and search infrastructure
This commit is contained in:
@@ -11,6 +11,7 @@ use App\Models\ForumPost;
|
||||
use App\Models\ForumThread;
|
||||
use App\Models\User;
|
||||
use App\Models\UserActivity;
|
||||
use App\Models\WorldRewardGrant;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
@@ -20,7 +21,7 @@ use Illuminate\Support\Str;
|
||||
final class UserActivityService
|
||||
{
|
||||
public const DEFAULT_PER_PAGE = 20;
|
||||
private const FEED_SCHEMA_VERSION = 2;
|
||||
private const FEED_SCHEMA_VERSION = 3;
|
||||
|
||||
private const FILTER_ALL = 'all';
|
||||
private const FILTER_UPLOADS = 'uploads';
|
||||
@@ -65,6 +66,11 @@ final class UserActivityService
|
||||
return $this->log($userId, UserActivity::TYPE_ACHIEVEMENT, UserActivity::ENTITY_ACHIEVEMENT, $achievementId, $meta);
|
||||
}
|
||||
|
||||
public function logWorldReward(int $userId, int $worldRewardGrantId, array $meta = []): ?UserActivity
|
||||
{
|
||||
return $this->log($userId, UserActivity::TYPE_WORLD_REWARD, UserActivity::ENTITY_WORLD_REWARD, $worldRewardGrantId, $meta);
|
||||
}
|
||||
|
||||
public function logForumPost(int $userId, int $threadId, array $meta = []): ?UserActivity
|
||||
{
|
||||
return $this->log($userId, UserActivity::TYPE_FORUM_POST, UserActivity::ENTITY_FORUM_THREAD, $threadId, $meta);
|
||||
@@ -220,6 +226,14 @@ final class UserActivityService
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$worldRewardIds = $rows
|
||||
->filter(fn (UserActivity $activity): bool => $activity->entity_type === UserActivity::ENTITY_WORLD_REWARD)
|
||||
->pluck('entity_id')
|
||||
->map(fn (mixed $id): int => (int) $id)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'artworks' => empty($artworkIds)
|
||||
? collect()
|
||||
@@ -245,7 +259,7 @@ final class UserActivityService
|
||||
? collect()
|
||||
: User::query()
|
||||
->with('profile:user_id,avatar_hash')
|
||||
->withCount('artworks')
|
||||
->with('statistics:user_id,uploads_count')
|
||||
->whereIn('id', $userIds)
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
@@ -276,6 +290,13 @@ final class UserActivityService
|
||||
->whereHas('thread', fn ($query) => $query->where('visibility', 'public')->whereNull('deleted_at'))
|
||||
->get()
|
||||
->keyBy('id'),
|
||||
'world_rewards' => empty($worldRewardIds)
|
||||
? collect()
|
||||
: WorldRewardGrant::query()
|
||||
->with(['world', 'artwork'])
|
||||
->whereIn('id', $worldRewardIds)
|
||||
->get()
|
||||
->keyBy('id'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -299,6 +320,7 @@ final class UserActivityService
|
||||
UserActivity::TYPE_REPLY => $this->formatCommentActivity($base, $activity, $related),
|
||||
UserActivity::TYPE_FOLLOW => $this->formatFollowActivity($base, $activity, $related),
|
||||
UserActivity::TYPE_ACHIEVEMENT => $this->formatAchievementActivity($base, $activity, $related),
|
||||
UserActivity::TYPE_WORLD_REWARD => $this->formatWorldRewardActivity($base, $activity, $related),
|
||||
UserActivity::TYPE_FORUM_POST,
|
||||
UserActivity::TYPE_FORUM_REPLY => $this->formatForumActivity($base, $activity, $related),
|
||||
default => null,
|
||||
@@ -374,6 +396,37 @@ final class UserActivityService
|
||||
];
|
||||
}
|
||||
|
||||
private function formatWorldRewardActivity(array $base, UserActivity $activity, array $related): ?array
|
||||
{
|
||||
/** @var WorldRewardGrant|null $grant */
|
||||
$grant = $related['world_rewards']->get((int) $activity->entity_id);
|
||||
if (! $grant || ! $grant->world) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
...$base,
|
||||
'world_reward' => [
|
||||
'id' => (int) $grant->id,
|
||||
'reward_type' => $grant->reward_type->value,
|
||||
'reward_label' => $grant->reward_type->label(),
|
||||
'badge_label' => trim($grant->world->title . ' ' . $grant->reward_type->label()),
|
||||
'tone' => $grant->reward_type->tone(),
|
||||
'world' => [
|
||||
'id' => (int) $grant->world->id,
|
||||
'title' => (string) $grant->world->title,
|
||||
'url' => $grant->world->publicUrl(),
|
||||
],
|
||||
'artwork' => $grant->artwork ? [
|
||||
'id' => (int) $grant->artwork->id,
|
||||
'title' => (string) ($grant->artwork->title ?? 'Artwork'),
|
||||
'url' => route('art.show', ['id' => (int) $grant->artwork->id, 'slug' => $grant->artwork->slug ?: Str::slug((string) $grant->artwork->title)]),
|
||||
] : null,
|
||||
'note' => (string) ($grant->note ?? ''),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function formatForumActivity(array $base, UserActivity $activity, array $related): ?array
|
||||
{
|
||||
if ($activity->type === UserActivity::TYPE_FORUM_POST) {
|
||||
@@ -510,7 +563,7 @@ final class UserActivityService
|
||||
return ['label' => 'Moderator', 'tone' => 'amber'];
|
||||
}
|
||||
|
||||
if ((int) ($user->artworks_count ?? 0) > 0) {
|
||||
if ((int) ($user->statistics?->uploads_count ?? $user->artworks_count ?? 0) > 0) {
|
||||
return ['label' => 'Creator', 'tone' => 'sky'];
|
||||
}
|
||||
|
||||
@@ -533,6 +586,7 @@ final class UserActivityService
|
||||
UserActivity::TYPE_FAVOURITE,
|
||||
UserActivity::TYPE_FOLLOW,
|
||||
UserActivity::TYPE_ACHIEVEMENT,
|
||||
UserActivity::TYPE_WORLD_REWARD,
|
||||
UserActivity::TYPE_FORUM_POST,
|
||||
UserActivity::TYPE_FORUM_REPLY,
|
||||
],
|
||||
|
||||
82
app/Services/ArtworkOriginalFileLocator.php
Normal file
82
app/Services/ArtworkOriginalFileLocator.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Services\Uploads\UploadStorageService;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
final class ArtworkOriginalFileLocator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UploadStorageService $storage,
|
||||
) {}
|
||||
|
||||
public function resolveLocalPath(Artwork $artwork): string
|
||||
{
|
||||
$objectPath = $this->resolveObjectPath($artwork);
|
||||
$prefix = $this->originalObjectPrefix();
|
||||
|
||||
if ($objectPath !== '' && str_starts_with($objectPath, $prefix)) {
|
||||
$suffix = substr($objectPath, strlen($prefix));
|
||||
$root = rtrim($this->storage->localOriginalsRoot(), DIRECTORY_SEPARATOR);
|
||||
|
||||
return $root . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, (string) $suffix);
|
||||
}
|
||||
|
||||
$hash = strtolower((string) $artwork->hash);
|
||||
$ext = strtolower(ltrim((string) $artwork->file_ext, '.'));
|
||||
|
||||
if (! $this->isValidHash($hash) || $ext === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$root = rtrim($this->storage->localOriginalsRoot(), DIRECTORY_SEPARATOR);
|
||||
|
||||
return $root
|
||||
. DIRECTORY_SEPARATOR . substr($hash, 0, 2)
|
||||
. DIRECTORY_SEPARATOR . substr($hash, 2, 2)
|
||||
. DIRECTORY_SEPARATOR . $hash . '.' . $ext;
|
||||
}
|
||||
|
||||
public function resolveObjectPath(Artwork $artwork): string
|
||||
{
|
||||
$relative = trim((string) $artwork->file_path, '/');
|
||||
$prefix = $this->originalObjectPrefix();
|
||||
|
||||
if ($relative !== '' && str_starts_with($relative, $prefix)) {
|
||||
return $relative;
|
||||
}
|
||||
|
||||
$hash = strtolower((string) $artwork->hash);
|
||||
$ext = strtolower(ltrim((string) $artwork->file_ext, '.'));
|
||||
|
||||
if (! $this->isValidHash($hash) || $ext === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $this->storage->objectPathForVariant('original', $hash, $hash . '.' . $ext);
|
||||
}
|
||||
|
||||
public function resolveObjectUrl(Artwork $artwork): ?string
|
||||
{
|
||||
$objectPath = $this->resolveObjectPath($artwork);
|
||||
if ($objectPath === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Storage::disk($this->storage->objectDiskName())->url($objectPath);
|
||||
}
|
||||
|
||||
private function originalObjectPrefix(): string
|
||||
{
|
||||
return trim($this->storage->objectBasePrefix(), '/') . '/original/';
|
||||
}
|
||||
|
||||
private function isValidHash(string $hash): bool
|
||||
{
|
||||
return $hash !== '' && preg_match('/^[a-f0-9]+$/', $hash) === 1;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace App\Services;
|
||||
use App\Jobs\DeleteArtworkFromIndexJob;
|
||||
use App\Jobs\IndexArtworkJob;
|
||||
use App\Models\Artwork;
|
||||
use Closure;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
@@ -43,19 +44,63 @@ final class ArtworkSearchIndexer
|
||||
/**
|
||||
* Rebuild the entire artworks index in background chunks.
|
||||
* Run via: php artisan artworks:search-rebuild
|
||||
*
|
||||
* @param Closure(int, int, int, int, int, int): void|null $onChunk
|
||||
* @return array{total:int, dispatched:int, chunks:int}
|
||||
*/
|
||||
public function rebuildAll(int $chunkSize = 500): void
|
||||
public function rebuildAll(int $chunkSize = 500, ?Closure $onChunk = null, bool $reverse = false, ?int $limit = null): array
|
||||
{
|
||||
Artwork::with(['user', 'tags', 'categories', 'stats', 'awardStat'])
|
||||
$query = Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->orderBy('id')
|
||||
->chunk($chunkSize, function ($artworks): void {
|
||||
->published();
|
||||
|
||||
if ($reverse) {
|
||||
$query->orderByDesc('id');
|
||||
} else {
|
||||
$query->orderBy('id');
|
||||
}
|
||||
|
||||
if ($limit !== null) {
|
||||
$query->limit($limit);
|
||||
}
|
||||
|
||||
$total = (clone $query)->count();
|
||||
|
||||
$dispatched = 0;
|
||||
$chunks = 0;
|
||||
|
||||
$query
|
||||
->with(['user', 'tags', 'categories', 'stats', 'awardStat'])
|
||||
->chunk($chunkSize, function ($artworks) use (&$chunks, &$dispatched, $total, $onChunk): void {
|
||||
$chunks++;
|
||||
|
||||
$count = $artworks->count();
|
||||
$firstId = (int) ($artworks->first()?->id ?? 0);
|
||||
$lastId = (int) ($artworks->last()?->id ?? 0);
|
||||
|
||||
foreach ($artworks as $artwork) {
|
||||
IndexArtworkJob::dispatch($artwork->id);
|
||||
$dispatched++;
|
||||
}
|
||||
|
||||
if ($onChunk !== null) {
|
||||
$onChunk($chunks, $count, $dispatched, $total, $firstId, $lastId);
|
||||
}
|
||||
});
|
||||
|
||||
Log::info('ArtworkSearchIndexer::rebuildAll — jobs dispatched');
|
||||
Log::info('ArtworkSearchIndexer::rebuildAll — jobs dispatched', [
|
||||
'total' => $total,
|
||||
'dispatched' => $dispatched,
|
||||
'chunks' => $chunks,
|
||||
'chunk_size' => $chunkSize,
|
||||
'reverse' => $reverse,
|
||||
'limit' => $limit,
|
||||
]);
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'dispatched' => $dispatched,
|
||||
'chunks' => $chunks,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ final class ArtworkSearchService
|
||||
}
|
||||
|
||||
if (! empty($filters['category'])) {
|
||||
$filterParts[] = 'category = "' . addslashes((string) $filters['category']) . '"';
|
||||
$filterParts[] = $this->categoryFilterClause((string) $filters['category']);
|
||||
}
|
||||
|
||||
if (! empty($filters['orientation'])) {
|
||||
@@ -90,7 +90,7 @@ final class ArtworkSearchService
|
||||
return $results;
|
||||
}
|
||||
|
||||
$page = max(1, (int) request()->get('page', 1));
|
||||
$page = $this->currentPage();
|
||||
$candidateCount = $this->determineSearchCandidatePoolSize($perPage, $page);
|
||||
$fallbackResults = Artwork::search($q ?: '')
|
||||
->options($options)
|
||||
@@ -108,7 +108,7 @@ final class ArtworkSearchService
|
||||
|
||||
public function searchWithThumbnailPreference(array $options, int $perPage, bool $excludeMissing = false, ?int $page = null): LengthAwarePaginator
|
||||
{
|
||||
$page = max(1, $page ?? (int) request()->get('page', 1));
|
||||
$page = max(1, $page ?? $this->currentPage());
|
||||
$candidateCount = $this->determineSearchCandidatePoolSize($perPage, $page);
|
||||
$results = Artwork::search('')
|
||||
->options($this->viewerAwareOptions($options))
|
||||
@@ -139,7 +139,7 @@ final class ArtworkSearchService
|
||||
}
|
||||
|
||||
$sort = in_array($sort, self::TAG_SORTS, true) ? $sort : 'popular';
|
||||
$cacheKey = "search.tag.{$slug}.{$sort}.{$perPage}.{$this->viewerCacheSegment()}.page." . request()->get('page', 1);
|
||||
$cacheKey = "search.tag.{$slug}.{$sort}.{$perPage}.{$this->viewerCacheSegment()}.page." . $this->currentPage();
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($tag, $perPage, $sort) {
|
||||
$query = Artwork::query()
|
||||
@@ -180,12 +180,12 @@ final class ArtworkSearchService
|
||||
*/
|
||||
public function byCategory(string $cat, int $perPage = 24, array $filters = []): LengthAwarePaginator
|
||||
{
|
||||
$page = (int) request()->get('page', 1);
|
||||
$page = $this->currentPage();
|
||||
$cacheKey = "search.cat.catalog-visible.v2.{$cat}.{$this->viewerCacheSegment()}.page." . $page;
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($cat, $perPage, $page) {
|
||||
return $this->searchWithThumbnailPreference([
|
||||
'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($cat) . '"',
|
||||
'filter' => self::BASE_FILTER . ' AND ' . $this->categoryFilterClause($cat),
|
||||
'sort' => ['created_at:desc'],
|
||||
], $perPage, false, $page);
|
||||
});
|
||||
@@ -226,15 +226,15 @@ final class ArtworkSearchService
|
||||
public function categoryPageSort(string $categorySlug, string $sort = 'trending', int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
$sort = array_key_exists($sort, self::CATEGORY_SORT_FIELDS) ? $sort : 'trending';
|
||||
$page = (int) request()->get('page', 1);
|
||||
$page = $this->currentPage();
|
||||
$ttl = self::CATEGORY_SORT_TTL[$sort] ?? self::CACHE_TTL;
|
||||
$cacheKey = "category.catalog-visible.v2.{$categorySlug}.{$sort}.{$this->viewerCacheSegment()}.{$page}";
|
||||
|
||||
return Cache::remember($cacheKey, $ttl, function () use ($categorySlug, $sort, $perPage) {
|
||||
return $this->searchWithThumbnailPreference([
|
||||
'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($categorySlug) . '"',
|
||||
'filter' => self::BASE_FILTER . ' AND ' . $this->categoryFilterClause($categorySlug),
|
||||
'sort' => self::CATEGORY_SORT_FIELDS[$sort],
|
||||
], $perPage, false, (int) request()->get('page', 1));
|
||||
], $perPage, false, $this->currentPage());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -247,15 +247,15 @@ final class ArtworkSearchService
|
||||
public function contentTypePageSort(string $contentTypeSlug, string $sort = 'trending', int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
$sort = array_key_exists($sort, self::CATEGORY_SORT_FIELDS) ? $sort : 'trending';
|
||||
$page = (int) request()->get('page', 1);
|
||||
$page = $this->currentPage();
|
||||
$ttl = self::CATEGORY_SORT_TTL[$sort] ?? self::CACHE_TTL;
|
||||
$cacheKey = "content_type.catalog-visible.v2.{$contentTypeSlug}.{$sort}.{$this->viewerCacheSegment()}.{$page}";
|
||||
|
||||
return Cache::remember($cacheKey, $ttl, function () use ($contentTypeSlug, $sort, $perPage) {
|
||||
return $this->searchWithThumbnailPreference([
|
||||
'filter' => self::BASE_FILTER . ' AND content_type = "' . addslashes($contentTypeSlug) . '"',
|
||||
'filter' => self::BASE_FILTER . ' AND ' . $this->contentTypeFilterClause($contentTypeSlug),
|
||||
'sort' => self::CATEGORY_SORT_FIELDS[$sort],
|
||||
], $perPage, false, (int) request()->get('page', 1));
|
||||
], $perPage, false, $this->currentPage());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -295,7 +295,7 @@ final class ArtworkSearchService
|
||||
*/
|
||||
public function popular(int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
return Cache::remember('search.popular.' . $this->viewerCacheSegment() . '.page.' . request()->get('page', 1), self::CACHE_TTL, function () use ($perPage) {
|
||||
return Cache::remember('search.popular.' . $this->viewerCacheSegment() . '.page.' . $this->currentPage(), self::CACHE_TTL, function () use ($perPage) {
|
||||
return Artwork::search('')
|
||||
->options($this->viewerAwareOptions([
|
||||
'filter' => self::BASE_FILTER,
|
||||
@@ -310,7 +310,7 @@ final class ArtworkSearchService
|
||||
*/
|
||||
public function recent(int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
return Cache::remember('search.recent.' . $this->viewerCacheSegment() . '.page.' . request()->get('page', 1), self::CACHE_TTL, function () use ($perPage) {
|
||||
return Cache::remember('search.recent.' . $this->viewerCacheSegment() . '.page.' . $this->currentPage(), self::CACHE_TTL, function () use ($perPage) {
|
||||
return Artwork::search('')
|
||||
->options($this->viewerAwareOptions([
|
||||
'filter' => self::BASE_FILTER,
|
||||
@@ -330,7 +330,7 @@ final class ArtworkSearchService
|
||||
*/
|
||||
public function discoverTrending(int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
$page = (int) request()->get('page', 1);
|
||||
$page = $this->currentPage();
|
||||
$windowDays = $this->timeWindow->getTrendingWindowDays(30);
|
||||
$cutoff = now()->subDays($windowDays)->toDateString();
|
||||
// Include window in cache key so adaptive expansions surface immediately
|
||||
@@ -352,7 +352,7 @@ final class ArtworkSearchService
|
||||
*/
|
||||
public function discoverRising(int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
$page = (int) request()->get('page', 1);
|
||||
$page = $this->currentPage();
|
||||
$windowDays = $this->timeWindow->getTrendingWindowDays(30);
|
||||
$cutoff = now()->subDays($windowDays)->toDateString();
|
||||
$cacheKey = "discover.rising.{$windowDays}d.{$this->viewerCacheSegment()}.{$page}";
|
||||
@@ -370,7 +370,7 @@ final class ArtworkSearchService
|
||||
*/
|
||||
public function discoverFresh(int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
$page = (int) request()->get('page', 1);
|
||||
$page = $this->currentPage();
|
||||
return Cache::remember("discover.fresh.{$this->viewerCacheSegment()}.{$page}", self::CACHE_TTL, function () use ($perPage) {
|
||||
return $this->searchWithThumbnailPreference([
|
||||
'filter' => self::BASE_FILTER,
|
||||
@@ -384,7 +384,7 @@ final class ArtworkSearchService
|
||||
*/
|
||||
public function discoverTopRated(int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
$page = (int) request()->get('page', 1);
|
||||
$page = $this->currentPage();
|
||||
return Cache::remember("discover.top-rated.{$this->viewerCacheSegment()}.{$page}", self::CACHE_TTL, function () use ($perPage) {
|
||||
return $this->searchWithThumbnailPreference([
|
||||
'filter' => self::BASE_FILTER,
|
||||
@@ -398,7 +398,7 @@ final class ArtworkSearchService
|
||||
*/
|
||||
public function discoverMostDownloaded(int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
$page = (int) request()->get('page', 1);
|
||||
$page = $this->currentPage();
|
||||
return Cache::remember("discover.most-downloaded.{$this->viewerCacheSegment()}.{$page}", self::CACHE_TTL, function () use ($perPage) {
|
||||
return $this->searchWithThumbnailPreference([
|
||||
'filter' => self::BASE_FILTER,
|
||||
@@ -441,6 +441,11 @@ final class ArtworkSearchService
|
||||
return $options;
|
||||
}
|
||||
|
||||
private function currentPage(): int
|
||||
{
|
||||
return max(1, (int) request()->query('page', 1));
|
||||
}
|
||||
|
||||
private function shouldFallbackToViewerVisibilityFiltering(LengthAwarePaginator $results): bool
|
||||
{
|
||||
if ($results->total() > 0) {
|
||||
@@ -468,7 +473,7 @@ final class ArtworkSearchService
|
||||
}
|
||||
|
||||
$catFilter = implode(' OR ', array_map(
|
||||
fn (string $c): string => 'category = "' . addslashes($c) . '"',
|
||||
fn (string $c): string => $this->categoryFilterClause($c),
|
||||
array_slice($categorySlugs, 0, 3)
|
||||
));
|
||||
|
||||
@@ -494,6 +499,20 @@ final class ArtworkSearchService
|
||||
return in_array($field, $allowed, true) ? [$field, $dir] : [null, 'desc'];
|
||||
}
|
||||
|
||||
private function categoryFilterClause(string $categorySlug): string
|
||||
{
|
||||
$quoted = addslashes($categorySlug);
|
||||
|
||||
return '(category = "' . $quoted . '" OR categories = "' . $quoted . '")';
|
||||
}
|
||||
|
||||
private function contentTypeFilterClause(string $contentTypeSlug): string
|
||||
{
|
||||
$quoted = addslashes($contentTypeSlug);
|
||||
|
||||
return '(content_type = "' . $quoted . '" OR content_types = "' . $quoted . '")';
|
||||
}
|
||||
|
||||
private function rerankSearchCollectionByThumbnailHealth(Collection $items, bool $excludeMissing): Collection
|
||||
{
|
||||
if ($items->isEmpty()) {
|
||||
|
||||
@@ -214,43 +214,7 @@ class ArtworkService
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
$current = $this->resolveCategoryByPath($slugs);
|
||||
|
||||
$categoryIds = $this->categoryAndDescendantIds($current);
|
||||
|
||||
@@ -262,6 +226,69 @@ class ArtworkService
|
||||
return $query->cursorPaginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a category path within a content type using one category query.
|
||||
*
|
||||
* @param array<int, string> $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.
|
||||
*
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Services\Artworks;
|
||||
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Models\Artwork;
|
||||
use App\Jobs\IndexArtworkJob;
|
||||
use App\Services\Activity\UserActivityService;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -131,15 +132,28 @@ class ArtworkPublicationService
|
||||
|
||||
private function syncSearch(Artwork $artwork): void
|
||||
{
|
||||
if (! method_exists($artwork, 'searchable')) {
|
||||
$artworkId = (int) $artwork->id;
|
||||
|
||||
$sync = function () use ($artworkId): void {
|
||||
try {
|
||||
IndexArtworkJob::dispatchSync($artworkId);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::error('ArtworkPublicationService immediate Meilisearch sync failed; queueing fallback job.', [
|
||||
'artwork_id' => $artworkId,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
IndexArtworkJob::dispatch($artworkId);
|
||||
}
|
||||
};
|
||||
|
||||
if (DB::transactionLevel() > 0) {
|
||||
DB::afterCommit($sync);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$artwork->searchable();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("PublishScheduled: scout reindex failed for #{$artwork->id}: {$e->getMessage()}");
|
||||
}
|
||||
$sync();
|
||||
}
|
||||
|
||||
private function recordActivity(Artwork $artwork): void
|
||||
|
||||
211
app/Services/CategoryDirectoryService.php
Normal file
211
app/Services/CategoryDirectoryService.php
Normal file
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Category;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class CategoryDirectoryService
|
||||
{
|
||||
public function getDirectoryPayload(string $search = '', string $sort = 'popular', int $page = 1, int $perPage = 24): array
|
||||
{
|
||||
$search = trim($search);
|
||||
$sort = $this->normalizeSort($sort);
|
||||
$page = max(1, $page);
|
||||
$perPage = min(60, max(12, $perPage));
|
||||
|
||||
$categories = collect(Cache::remember('categories.directory.v1', 3600, function (): array {
|
||||
$publishedArtworkScope = DB::table('artwork_category as artwork_category')
|
||||
->join('artworks as artworks', 'artworks.id', '=', 'artwork_category.artwork_id')
|
||||
->leftJoin('artwork_stats as artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->whereColumn('artwork_category.category_id', 'categories.id')
|
||||
->where('artworks.is_public', true)
|
||||
->where('artworks.is_approved', true)
|
||||
->whereNull('artworks.deleted_at');
|
||||
|
||||
$categories = Category::query()
|
||||
->select([
|
||||
'categories.id',
|
||||
'categories.content_type_id',
|
||||
'categories.parent_id',
|
||||
'categories.name',
|
||||
'categories.slug',
|
||||
])
|
||||
->selectSub(
|
||||
(clone $publishedArtworkScope)->selectRaw('COUNT(DISTINCT artworks.id)'),
|
||||
'artwork_count'
|
||||
)
|
||||
->selectSub(
|
||||
(clone $publishedArtworkScope)
|
||||
->whereNotNull('artworks.hash')
|
||||
->whereNotNull('artworks.thumb_ext')
|
||||
->orderByDesc(DB::raw('COALESCE(artwork_stats.views, 0)'))
|
||||
->orderByDesc(DB::raw('COALESCE(artwork_stats.favorites, 0)'))
|
||||
->orderByDesc(DB::raw('COALESCE(artwork_stats.downloads, 0)'))
|
||||
->orderByDesc(DB::raw('COALESCE(artworks.published_at, artworks.created_at)'))
|
||||
->orderByDesc('artworks.id')
|
||||
->limit(1)
|
||||
->select('artworks.hash'),
|
||||
'cover_hash'
|
||||
)
|
||||
->selectSub(
|
||||
(clone $publishedArtworkScope)
|
||||
->whereNotNull('artworks.hash')
|
||||
->whereNotNull('artworks.thumb_ext')
|
||||
->orderByDesc(DB::raw('COALESCE(artwork_stats.views, 0)'))
|
||||
->orderByDesc(DB::raw('COALESCE(artwork_stats.favorites, 0)'))
|
||||
->orderByDesc(DB::raw('COALESCE(artwork_stats.downloads, 0)'))
|
||||
->orderByDesc(DB::raw('COALESCE(artworks.published_at, artworks.created_at)'))
|
||||
->orderByDesc('artworks.id')
|
||||
->limit(1)
|
||||
->select('artworks.thumb_ext'),
|
||||
'cover_ext'
|
||||
)
|
||||
->selectSub(
|
||||
(clone $publishedArtworkScope)
|
||||
->selectRaw('COALESCE(SUM(COALESCE(artwork_stats.views, 0) + (COALESCE(artwork_stats.favorites, 0) * 3) + (COALESCE(artwork_stats.downloads, 0) * 2)), 0)'),
|
||||
'popular_score'
|
||||
)
|
||||
->with(['contentType:id,name,slug'])
|
||||
->active()
|
||||
->orderBy('categories.name')
|
||||
->get();
|
||||
|
||||
return $this->transformCategories($categories);
|
||||
}));
|
||||
|
||||
$filtered = $this->filterAndSortCategories($categories, $search, $sort);
|
||||
$total = $filtered->count();
|
||||
$lastPage = max(1, (int) ceil($total / $perPage));
|
||||
$currentPage = min($page, $lastPage);
|
||||
$offset = ($currentPage - 1) * $perPage;
|
||||
$pageItems = $filtered->slice($offset, $perPage)->values();
|
||||
$popularCategories = $this->filterAndSortCategories($categories, '', 'popular')->take(4)->values();
|
||||
|
||||
return [
|
||||
'data' => $pageItems->all(),
|
||||
'meta' => [
|
||||
'current_page' => $currentPage,
|
||||
'last_page' => $lastPage,
|
||||
'per_page' => $perPage,
|
||||
'total' => $total,
|
||||
],
|
||||
'summary' => [
|
||||
'total_categories' => $categories->count(),
|
||||
'total_artworks' => $categories->sum(static fn (array $category): int => (int) ($category['artwork_count'] ?? 0)),
|
||||
],
|
||||
'popular_categories' => $search === '' ? $popularCategories->all() : [],
|
||||
'request' => [
|
||||
'query' => $search,
|
||||
'sort' => $sort,
|
||||
'page' => $currentPage,
|
||||
'per_page' => $perPage,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeSort(string $sort): string
|
||||
{
|
||||
return in_array($sort, ['popular', 'az', 'artworks'], true) ? $sort : 'popular';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, array<string, mixed>> $categories
|
||||
* @return Collection<int, array<string, mixed>>
|
||||
*/
|
||||
private function filterAndSortCategories(Collection $categories, string $search, string $sort): Collection
|
||||
{
|
||||
$filtered = $categories;
|
||||
|
||||
if ($search !== '') {
|
||||
$needle = mb_strtolower($search);
|
||||
|
||||
$filtered = $filtered->filter(static function (array $category) use ($needle): bool {
|
||||
return str_contains(mb_strtolower((string) ($category['name'] ?? '')), $needle);
|
||||
});
|
||||
}
|
||||
|
||||
return $filtered->sort(static function (array $left, array $right) use ($sort): int {
|
||||
if ($sort === 'az') {
|
||||
return strcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
|
||||
}
|
||||
|
||||
if ($sort === 'artworks') {
|
||||
$countCompare = ((int) ($right['artwork_count'] ?? 0)) <=> ((int) ($left['artwork_count'] ?? 0));
|
||||
|
||||
return $countCompare !== 0
|
||||
? $countCompare
|
||||
: strcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
|
||||
}
|
||||
|
||||
$scoreCompare = ((int) ($right['popular_score'] ?? 0)) <=> ((int) ($left['popular_score'] ?? 0));
|
||||
if ($scoreCompare !== 0) {
|
||||
return $scoreCompare;
|
||||
}
|
||||
|
||||
$countCompare = ((int) ($right['artwork_count'] ?? 0)) <=> ((int) ($left['artwork_count'] ?? 0));
|
||||
if ($countCompare !== 0) {
|
||||
return $countCompare;
|
||||
}
|
||||
|
||||
return strcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
|
||||
})->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Category> $categories
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function transformCategories(Collection $categories): array
|
||||
{
|
||||
$categoryMap = $categories->keyBy('id');
|
||||
$pathCache = [];
|
||||
|
||||
$buildPath = function (Category $category) use (&$buildPath, &$pathCache, $categoryMap): string {
|
||||
if (isset($pathCache[$category->id])) {
|
||||
return $pathCache[$category->id];
|
||||
}
|
||||
|
||||
if ($category->parent_id && $categoryMap->has($category->parent_id)) {
|
||||
$pathCache[$category->id] = $buildPath($categoryMap->get($category->parent_id)) . '/' . $category->slug;
|
||||
|
||||
return $pathCache[$category->id];
|
||||
}
|
||||
|
||||
$pathCache[$category->id] = $category->slug;
|
||||
|
||||
return $pathCache[$category->id];
|
||||
};
|
||||
|
||||
return $categories
|
||||
->map(static function (Category $category) use ($buildPath): array {
|
||||
$contentTypeSlug = strtolower((string) ($category->contentType?->slug ?? 'categories'));
|
||||
$path = $buildPath($category);
|
||||
$coverImage = null;
|
||||
|
||||
if (! empty($category->cover_hash) && ! empty($category->cover_ext)) {
|
||||
$coverImage = ThumbnailService::fromHash((string) $category->cover_hash, (string) $category->cover_ext, 'md');
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int) $category->id,
|
||||
'name' => (string) $category->name,
|
||||
'slug' => (string) $category->slug,
|
||||
'url' => '/' . $contentTypeSlug . '/' . $path,
|
||||
'content_type' => [
|
||||
'name' => (string) ($category->contentType?->name ?? 'Categories'),
|
||||
'slug' => $contentTypeSlug,
|
||||
],
|
||||
'cover_image' => $coverImage ?: 'https://files.skinbase.org/default/missing_md.webp',
|
||||
'artwork_count' => (int) ($category->artwork_count ?? 0),
|
||||
'popular_score' => (int) ($category->popular_score ?? 0),
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -149,7 +149,7 @@ final class CommunityActivityService
|
||||
$query
|
||||
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
|
||||
->with('profile:user_id,avatar_hash')
|
||||
->withCount('artworks');
|
||||
->with('statistics:user_id,uploads_count');
|
||||
},
|
||||
])
|
||||
->whereHas('actor', fn ($query) => $query->where('is_active', true)->whereNull('deleted_at'))
|
||||
@@ -210,7 +210,7 @@ final class CommunityActivityService
|
||||
: User::query()
|
||||
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
|
||||
->with('profile:user_id,avatar_hash')
|
||||
->withCount('artworks')
|
||||
->with('statistics:user_id,uploads_count')
|
||||
->whereIn('id', $targetUserIds)
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
@@ -242,7 +242,7 @@ final class CommunityActivityService
|
||||
$query
|
||||
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
|
||||
->with('profile:user_id,avatar_hash')
|
||||
->withCount('artworks');
|
||||
->with('statistics:user_id,uploads_count');
|
||||
},
|
||||
'artwork' => function ($query) {
|
||||
$query->select('id', 'user_id', 'title', 'slug', 'hash', 'thumb_ext', 'published_at', 'deleted_at', 'is_public', 'is_approved');
|
||||
@@ -271,7 +271,7 @@ final class CommunityActivityService
|
||||
$query
|
||||
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
|
||||
->with('profile:user_id,avatar_hash')
|
||||
->withCount('artworks');
|
||||
->with('statistics:user_id,uploads_count');
|
||||
},
|
||||
'comment' => function ($query) {
|
||||
$query
|
||||
@@ -281,7 +281,7 @@ final class CommunityActivityService
|
||||
$userQuery
|
||||
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
|
||||
->with('profile:user_id,avatar_hash')
|
||||
->withCount('artworks');
|
||||
->with('statistics:user_id,uploads_count');
|
||||
},
|
||||
'artwork' => function ($artworkQuery) {
|
||||
$artworkQuery->select('id', 'user_id', 'title', 'slug', 'hash', 'thumb_ext', 'published_at', 'deleted_at', 'is_public', 'is_approved');
|
||||
@@ -408,13 +408,13 @@ final class CommunityActivityService
|
||||
$query
|
||||
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
|
||||
->with('profile:user_id,avatar_hash')
|
||||
->withCount('artworks');
|
||||
->with('statistics:user_id,uploads_count');
|
||||
},
|
||||
'mentionedUser' => function ($query) {
|
||||
$query
|
||||
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
|
||||
->with('profile:user_id,avatar_hash')
|
||||
->withCount('artworks');
|
||||
->with('statistics:user_id,uploads_count');
|
||||
},
|
||||
'comment' => function ($query) {
|
||||
$query
|
||||
@@ -424,7 +424,7 @@ final class CommunityActivityService
|
||||
$userQuery
|
||||
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
|
||||
->with('profile:user_id,avatar_hash')
|
||||
->withCount('artworks');
|
||||
->with('statistics:user_id,uploads_count');
|
||||
},
|
||||
'artwork' => function ($artworkQuery) {
|
||||
$artworkQuery->select('id', 'user_id', 'title', 'slug', 'hash', 'thumb_ext', 'published_at', 'deleted_at', 'is_public', 'is_approved');
|
||||
@@ -489,7 +489,7 @@ final class CommunityActivityService
|
||||
return ['label' => 'Moderator', 'tone' => 'amber'];
|
||||
}
|
||||
|
||||
if ((int) ($user->artworks_count ?? 0) > 0) {
|
||||
if ((int) ($user->statistics?->uploads_count ?? 0) > 0) {
|
||||
return ['label' => 'Creator', 'tone' => 'sky'];
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Models\BlogPost;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
@@ -50,7 +51,6 @@ final class ErrorSuggestionService
|
||||
|
||||
return Cache::remember("error_suggestions.similar_tags.{$slug}.{$limit}", self::CACHE_TTL, function () use ($slug, $limit, $prefix) {
|
||||
return Tag::query()
|
||||
->withCount('artworks')
|
||||
->where('slug', '!=', $slug)
|
||||
->where(function ($q) use ($prefix, $slug) {
|
||||
$q->where('slug', 'like', $prefix . '%')
|
||||
@@ -70,7 +70,6 @@ final class ErrorSuggestionService
|
||||
|
||||
return Cache::remember("error_suggestions.tags.{$limit}", self::CACHE_TTL, function () use ($limit) {
|
||||
return Tag::query()
|
||||
->withCount('artworks')
|
||||
->orderByDesc('artworks_count')
|
||||
->limit($limit)
|
||||
->get(['id', 'name', 'slug', 'artworks_count']);
|
||||
@@ -84,14 +83,17 @@ final class ErrorSuggestionService
|
||||
$limit = min($limit, 6);
|
||||
|
||||
return Cache::remember("error_suggestions.creators.{$limit}", self::CACHE_TTL, function () use ($limit) {
|
||||
return User::query()
|
||||
->with('profile')
|
||||
->withCount(['artworks' => fn ($q) => $q->public()->published()])
|
||||
->having('artworks_count', '>', 0)
|
||||
->orderByDesc('artworks_count')
|
||||
return DB::table('users as u')
|
||||
->join('user_statistics as us', 'us.user_id', '=', 'u.id')
|
||||
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
||||
->select('u.id', 'u.name', 'u.username', 'up.avatar_hash', DB::raw('us.uploads_count as artworks_count'))
|
||||
->where('u.is_active', true)
|
||||
->whereNull('u.deleted_at')
|
||||
->where('us.uploads_count', '>', 0)
|
||||
->orderByDesc('us.uploads_count')
|
||||
->limit($limit)
|
||||
->get(['users.id', 'users.name', 'users.username'])
|
||||
->map(fn (User $u) => $this->creatorCard($u, $u->artworks_count));
|
||||
->get()
|
||||
->map(fn ($u) => $this->creatorCardFromRow($u));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,14 +104,17 @@ final class ErrorSuggestionService
|
||||
$limit = min($limit, 6);
|
||||
|
||||
return Cache::remember("error_suggestions.creators.recent.{$limit}", self::CACHE_TTL, function () use ($limit) {
|
||||
return User::query()
|
||||
->with('profile')
|
||||
->withCount(['artworks' => fn ($q) => $q->public()->published()])
|
||||
->having('artworks_count', '>', 0)
|
||||
->orderByDesc('users.id')
|
||||
return DB::table('users as u')
|
||||
->join('user_statistics as us', 'us.user_id', '=', 'u.id')
|
||||
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
||||
->select('u.id', 'u.name', 'u.username', 'up.avatar_hash', DB::raw('us.uploads_count as artworks_count'))
|
||||
->where('u.is_active', true)
|
||||
->whereNull('u.deleted_at')
|
||||
->where('us.uploads_count', '>', 0)
|
||||
->orderByDesc('u.id')
|
||||
->limit($limit)
|
||||
->get(['users.id', 'users.name', 'users.username'])
|
||||
->map(fn (User $u) => $this->creatorCard($u, $u->artworks_count));
|
||||
->get()
|
||||
->map(fn ($u) => $this->creatorCardFromRow($u));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -166,4 +171,20 @@ final class ErrorSuggestionService
|
||||
'artworks_count' => $artworksCount,
|
||||
];
|
||||
}
|
||||
|
||||
private function creatorCardFromRow(object $u): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $u->id,
|
||||
'name' => $u->name ?: $u->username,
|
||||
'username' => $u->username,
|
||||
'url' => '/@' . $u->username,
|
||||
'avatar_url' => \App\Support\AvatarUrl::forUser(
|
||||
(int) $u->id,
|
||||
$u->avatar_hash ?? null,
|
||||
64
|
||||
),
|
||||
'artworks_count' => (int) ($u->artworks_count ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Services;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Models\Group;
|
||||
use App\Jobs\IndexArtworkJob;
|
||||
use App\Models\Tag;
|
||||
use App\Models\User;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
@@ -392,17 +393,6 @@ class GroupArtworkReviewService
|
||||
|
||||
private function syncSearchIndex(Artwork $artwork): void
|
||||
{
|
||||
try {
|
||||
if ((bool) $artwork->is_public && (bool) $artwork->is_approved && ! empty($artwork->published_at)) {
|
||||
$artwork->searchable();
|
||||
} else {
|
||||
$artwork->unsearchable();
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('Failed to sync artwork search index for group review workflow', [
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
IndexArtworkJob::dispatch((int) $artwork->id);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ class GroupCardService
|
||||
{
|
||||
$owner = $group->relationLoaded('owner') ? $group->owner : $group->owner()->with('profile')->first();
|
||||
$recruitment = $this->recruitment->payloadForGroup($group);
|
||||
$viewerRole = $viewer ? $group->activeRoleFor($viewer) : null;
|
||||
$canManage = $viewer ? $group->canManage($viewer) : false;
|
||||
$canManageMembers = $viewer ? $group->canManageMembers($viewer) : false;
|
||||
$canPublishArtworks = $viewer ? $group->canPublishArtworks($viewer) : false;
|
||||
@@ -117,9 +118,13 @@ class GroupCardService
|
||||
$badges,
|
||||
))),
|
||||
'viewer' => [
|
||||
'role' => $viewer ? $group->activeRoleFor($viewer) : null,
|
||||
'role_label' => $viewer ? Group::displayRole($group->activeRoleFor($viewer)) : null,
|
||||
'is_following' => $viewer ? $this->follows->isFollowing($group, $viewer) : false,
|
||||
'role' => $viewerRole,
|
||||
'role_label' => $viewerRole ? Group::displayRole($viewerRole) : null,
|
||||
'is_following' => $viewer
|
||||
? (array_key_exists('viewer_is_following', $group->getAttributes())
|
||||
? (bool) $group->viewer_is_following
|
||||
: $this->follows->isFollowing($group, $viewer))
|
||||
: false,
|
||||
'permission_overrides' => $viewer ? $group->permissionOverridesFor($viewer) : [],
|
||||
],
|
||||
'urls' => [
|
||||
|
||||
@@ -8,9 +8,12 @@ use App\Models\Artwork;
|
||||
use App\Models\Group;
|
||||
use App\Models\GroupChallenge;
|
||||
use App\Models\GroupChallengeArtwork;
|
||||
use App\Models\GroupChallengeOutcome;
|
||||
use App\Models\User;
|
||||
use App\Support\ThumbnailPresenter;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Services\Worlds\WorldRewardService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -22,6 +25,7 @@ class GroupChallengeService
|
||||
private readonly GroupActivityService $activity,
|
||||
private readonly GroupMediaService $media,
|
||||
private readonly NotificationService $notifications,
|
||||
private readonly WorldRewardService $worldRewards,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -83,22 +87,29 @@ class GroupChallengeService
|
||||
$challenge->visibility === GroupChallenge::VISIBILITY_PUBLIC ? 'public' : 'internal',
|
||||
);
|
||||
|
||||
return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile']);
|
||||
return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'outcomes.artwork.user.profile']);
|
||||
}
|
||||
|
||||
public function update(GroupChallenge $challenge, User $actor, array $attributes): GroupChallenge
|
||||
{
|
||||
$coverPath = null;
|
||||
$oldCoverPath = $challenge->cover_path;
|
||||
$before = $challenge->only(['title', 'summary', 'description', 'visibility', 'participation_scope', 'status', 'rules_text', 'submission_instructions', 'judging_mode', 'linked_collection_id', 'linked_project_id', 'featured_artwork_id']);
|
||||
$before = [
|
||||
...$challenge->only(['title', 'summary', 'description', 'visibility', 'participation_scope', 'status', 'rules_text', 'submission_instructions', 'judging_mode', 'linked_collection_id', 'linked_project_id', 'featured_artwork_id']),
|
||||
'outcomes_count' => $challenge->outcomes()->count(),
|
||||
];
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($challenge, $attributes, &$coverPath): void {
|
||||
DB::transaction(function () use ($challenge, $actor, $attributes, &$coverPath): void {
|
||||
if (($attributes['cover_file'] ?? null) instanceof UploadedFile) {
|
||||
$coverPath = $this->media->storeUploadedEntityImage($challenge->group, $attributes['cover_file'], 'challenges');
|
||||
}
|
||||
|
||||
$title = trim((string) ($attributes['title'] ?? $challenge->title));
|
||||
$featuredArtworkId = array_key_exists('featured_artwork_id', $attributes)
|
||||
? $this->normalizeArtworkId($challenge->group, $attributes['featured_artwork_id'])
|
||||
: $challenge->featured_artwork_id;
|
||||
|
||||
$challenge->fill([
|
||||
'title' => $title,
|
||||
'slug' => $title !== $challenge->title ? $this->makeUniqueSlug($title, (int) $challenge->id) : $challenge->slug,
|
||||
@@ -115,8 +126,18 @@ class GroupChallengeService
|
||||
'judging_mode' => array_key_exists('judging_mode', $attributes) ? $this->nullableString($attributes['judging_mode']) : $challenge->judging_mode,
|
||||
'linked_collection_id' => array_key_exists('linked_collection_id', $attributes) ? $this->normalizeCollectionId($challenge->group, $attributes['linked_collection_id']) : $challenge->linked_collection_id,
|
||||
'linked_project_id' => array_key_exists('linked_project_id', $attributes) ? $this->normalizeProjectId($challenge->group, $attributes['linked_project_id']) : $challenge->linked_project_id,
|
||||
'featured_artwork_id' => array_key_exists('featured_artwork_id', $attributes) ? $this->normalizeArtworkId($challenge->group, $attributes['featured_artwork_id']) : $challenge->featured_artwork_id,
|
||||
'featured_artwork_id' => $featuredArtworkId,
|
||||
])->save();
|
||||
|
||||
if (array_key_exists('outcomes', $attributes)) {
|
||||
$canonicalWinnerArtworkId = $this->syncOutcomes($challenge, $actor, (array) ($attributes['outcomes'] ?? []), $featuredArtworkId);
|
||||
|
||||
if ((int) ($challenge->featured_artwork_id ?? 0) !== (int) ($canonicalWinnerArtworkId ?? 0)) {
|
||||
$challenge->forceFill([
|
||||
'featured_artwork_id' => $canonicalWinnerArtworkId,
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (\Throwable $exception) {
|
||||
$this->media->deleteIfManaged($coverPath);
|
||||
@@ -137,10 +158,15 @@ class GroupChallengeService
|
||||
'group_challenge',
|
||||
(int) $challenge->id,
|
||||
$before,
|
||||
$challenge->only(['title', 'summary', 'description', 'visibility', 'participation_scope', 'status', 'rules_text', 'submission_instructions', 'judging_mode', 'linked_collection_id', 'linked_project_id', 'featured_artwork_id'])
|
||||
[
|
||||
...$challenge->only(['title', 'summary', 'description', 'visibility', 'participation_scope', 'status', 'rules_text', 'submission_instructions', 'judging_mode', 'linked_collection_id', 'linked_project_id', 'featured_artwork_id']),
|
||||
'outcomes_count' => $challenge->outcomes()->count(),
|
||||
]
|
||||
);
|
||||
|
||||
return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile']);
|
||||
$this->worldRewards->syncLinkedChallengeRewardsForChallenge($challenge);
|
||||
|
||||
return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile', 'outcomes.artwork.user.profile']);
|
||||
}
|
||||
|
||||
public function publish(GroupChallenge $challenge, User $actor): GroupChallenge
|
||||
@@ -191,7 +217,9 @@ class GroupChallengeService
|
||||
}
|
||||
}
|
||||
|
||||
return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile']);
|
||||
$this->worldRewards->syncLinkedChallengeRewardsForChallenge($challenge);
|
||||
|
||||
return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile', 'outcomes.artwork.user.profile']);
|
||||
}
|
||||
|
||||
public function attachArtwork(GroupChallenge $challenge, Artwork $artwork, User $actor): GroupChallenge
|
||||
@@ -224,7 +252,9 @@ class GroupChallengeService
|
||||
['artwork_id' => (int) $artwork->id]
|
||||
);
|
||||
|
||||
return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile']);
|
||||
$this->worldRewards->syncLinkedChallengeRewardsForChallenge($challenge);
|
||||
|
||||
return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile', 'outcomes.artwork.user.profile']);
|
||||
}
|
||||
|
||||
public function publicListing(Group $group, ?User $viewer = null, int $limit = 12): array
|
||||
@@ -289,16 +319,18 @@ class GroupChallengeService
|
||||
|
||||
public function detailPayload(GroupChallenge $challenge, ?User $viewer = null): array
|
||||
{
|
||||
$challenge->loadMissing(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile']);
|
||||
$challenge->loadMissing(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile', 'outcomes.artwork.user.profile']);
|
||||
|
||||
$primaryWinnerArtwork = $this->primaryWinnerArtwork($challenge) ?? $challenge->featuredArtwork;
|
||||
|
||||
return array_merge($this->mapPublicChallenge($challenge), [
|
||||
'description' => $challenge->description,
|
||||
'rules_text' => $challenge->rules_text,
|
||||
'submission_instructions' => $challenge->submission_instructions,
|
||||
'featured_artwork' => $challenge->featuredArtwork ? [
|
||||
'id' => (int) $challenge->featuredArtwork->id,
|
||||
'title' => $challenge->featuredArtwork->title,
|
||||
'url' => route('art.show', ['id' => $challenge->featuredArtwork->id, 'slug' => $challenge->featuredArtwork->slug ?: $challenge->featuredArtwork->id]),
|
||||
'featured_artwork' => $primaryWinnerArtwork ? [
|
||||
'id' => (int) $primaryWinnerArtwork->id,
|
||||
'title' => $primaryWinnerArtwork->title,
|
||||
'url' => route('art.show', ['id' => $primaryWinnerArtwork->id, 'slug' => $primaryWinnerArtwork->slug ?: $primaryWinnerArtwork->id]),
|
||||
] : null,
|
||||
'artworks' => $challenge->artworks->map(fn (Artwork $artwork): array => [
|
||||
'id' => (int) $artwork->id,
|
||||
@@ -306,11 +338,16 @@ class GroupChallengeService
|
||||
'thumb' => ThumbnailPresenter::present($artwork, 'md')['url'] ?? $artwork->thumbUrl('md'),
|
||||
'url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug ?: $artwork->id]),
|
||||
])->values()->all(),
|
||||
'outcomes' => $challenge->outcomes->map(fn (GroupChallengeOutcome $outcome): array => $this->mapOutcomeForEditor($outcome))->values()->all(),
|
||||
'outcome_sections' => $this->outcomeSectionsPayload($challenge),
|
||||
'outcome_counts' => $this->outcomeCounts($challenge),
|
||||
]);
|
||||
}
|
||||
|
||||
public function mapPublicChallenge(GroupChallenge $challenge): array
|
||||
{
|
||||
$challenge->loadMissing(['group', 'outcomes']);
|
||||
|
||||
return [
|
||||
'id' => (int) $challenge->id,
|
||||
'title' => (string) $challenge->title,
|
||||
@@ -324,6 +361,7 @@ class GroupChallengeService
|
||||
'end_at' => $challenge->end_at?->toISOString(),
|
||||
'rules_text' => $challenge->rules_text,
|
||||
'entry_count' => (int) $challenge->artworkLinks()->count(),
|
||||
'outcome_counts' => $this->outcomeCounts($challenge),
|
||||
'url' => route('groups.challenges.show', ['group' => $challenge->group, 'challenge' => $challenge]),
|
||||
];
|
||||
}
|
||||
@@ -363,6 +401,196 @@ class GroupChallengeService
|
||||
return $challenge->group->hasActiveMember($actor) && (int) $artwork->group_id === (int) $challenge->group_id;
|
||||
}
|
||||
|
||||
private function syncOutcomes(GroupChallenge $challenge, User $actor, array $rows, ?int $fallbackFeaturedArtworkId = null): ?int
|
||||
{
|
||||
$normalized = collect($rows)
|
||||
->values()
|
||||
->map(function (mixed $row, int $index): ?array {
|
||||
if (! is_array($row)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$artworkId = (int) ($row['artwork_id'] ?? 0);
|
||||
$outcomeType = trim((string) ($row['outcome_type'] ?? ''));
|
||||
|
||||
if ($artworkId < 1 || $outcomeType === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'artwork_id' => $artworkId,
|
||||
'outcome_type' => $outcomeType,
|
||||
'position' => isset($row['position']) && (int) $row['position'] > 0 ? (int) $row['position'] : null,
|
||||
'sort_order' => max(0, (int) ($row['sort_order'] ?? $index)),
|
||||
'title_override' => $this->nullableString($row['title_override'] ?? null),
|
||||
'note' => $this->nullableString($row['note'] ?? null),
|
||||
];
|
||||
})
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
$pairs = $normalized
|
||||
->map(fn (array $row): string => $row['artwork_id'] . '|' . $row['outcome_type']);
|
||||
|
||||
if ($pairs->count() !== $pairs->unique()->count()) {
|
||||
throw ValidationException::withMessages([
|
||||
'outcomes' => 'Each artwork can only receive a given outcome type once per challenge.',
|
||||
]);
|
||||
}
|
||||
|
||||
$artworkIds = $normalized->pluck('artwork_id')->unique()->values();
|
||||
$validArtworkIds = $artworkIds->isEmpty()
|
||||
? collect()
|
||||
: $challenge->artworkLinks()
|
||||
->whereIn('artwork_id', $artworkIds->all())
|
||||
->pluck('artwork_id')
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->values();
|
||||
|
||||
if ($artworkIds->diff($validArtworkIds)->isNotEmpty()) {
|
||||
throw ValidationException::withMessages([
|
||||
'outcomes' => 'Challenge outcomes can only reference artworks already attached as challenge entries.',
|
||||
]);
|
||||
}
|
||||
|
||||
$artworksById = $artworkIds->isEmpty()
|
||||
? collect()
|
||||
: Artwork::query()
|
||||
->whereIn('id', $artworkIds->all())
|
||||
->get(['id', 'user_id'])
|
||||
->keyBy('id');
|
||||
|
||||
GroupChallengeOutcome::query()
|
||||
->where('group_challenge_id', (int) $challenge->id)
|
||||
->delete();
|
||||
|
||||
if ($normalized->isEmpty()) {
|
||||
return $fallbackFeaturedArtworkId;
|
||||
}
|
||||
|
||||
$challenge->outcomes()->createMany($normalized->map(function (array $row) use ($actor, $artworksById): array {
|
||||
/** @var Artwork|null $artwork */
|
||||
$artwork = $artworksById->get($row['artwork_id']);
|
||||
|
||||
return [
|
||||
'artwork_id' => $row['artwork_id'],
|
||||
'user_id' => (int) ($artwork?->user_id ?? 0) > 0 ? (int) $artwork->user_id : null,
|
||||
'outcome_type' => $row['outcome_type'],
|
||||
'position' => $row['position'],
|
||||
'sort_order' => $row['sort_order'],
|
||||
'title_override' => $row['title_override'],
|
||||
'note' => $row['note'],
|
||||
'awarded_by_user_id' => (int) $actor->id,
|
||||
'awarded_at' => now(),
|
||||
];
|
||||
})->all());
|
||||
|
||||
$winner = $normalized
|
||||
->sortBy([
|
||||
fn (array $row): int => $row['outcome_type'] === GroupChallengeOutcome::TYPE_WINNER ? 0 : 1,
|
||||
fn (array $row): int => (int) $row['sort_order'],
|
||||
fn (array $row): int => (int) ($row['position'] ?? PHP_INT_MAX),
|
||||
])
|
||||
->first(fn (array $row): bool => $row['outcome_type'] === GroupChallengeOutcome::TYPE_WINNER);
|
||||
|
||||
return $winner['artwork_id'] ?? $fallbackFeaturedArtworkId;
|
||||
}
|
||||
|
||||
private function primaryWinnerArtwork(GroupChallenge $challenge): ?Artwork
|
||||
{
|
||||
/** @var GroupChallengeOutcome|null $winner */
|
||||
$winner = $challenge->outcomes
|
||||
->first(fn (GroupChallengeOutcome $outcome): bool => $outcome->outcome_type === GroupChallengeOutcome::TYPE_WINNER && $outcome->artwork !== null);
|
||||
|
||||
return $winner?->artwork;
|
||||
}
|
||||
|
||||
private function outcomeCounts(GroupChallenge $challenge): array
|
||||
{
|
||||
$challenge->loadMissing('outcomes');
|
||||
|
||||
return collect(GroupChallengeOutcome::supportedTypes())
|
||||
->mapWithKeys(fn (string $type): array => [$type => $challenge->outcomes->where('outcome_type', $type)->count()])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function outcomeSectionsPayload(GroupChallenge $challenge): array
|
||||
{
|
||||
$challenge->loadMissing(['outcomes.artwork.user.profile']);
|
||||
|
||||
$sections = [];
|
||||
|
||||
foreach (GroupChallengeOutcome::supportedTypes() as $type) {
|
||||
$items = $challenge->outcomes
|
||||
->where('outcome_type', $type)
|
||||
->values();
|
||||
|
||||
if ($items->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sections[$type] = [
|
||||
'type' => $type,
|
||||
'label' => $this->outcomeSectionLabel($type, $items->count()),
|
||||
'items' => $items->map(fn (GroupChallengeOutcome $outcome): array => $this->mapOutcomeItem($outcome))->all(),
|
||||
];
|
||||
}
|
||||
|
||||
return $sections;
|
||||
}
|
||||
|
||||
private function outcomeSectionLabel(string $type, int $count): string
|
||||
{
|
||||
return match ($type) {
|
||||
GroupChallengeOutcome::TYPE_WINNER => $count === 1 ? 'Winner' : 'Winners',
|
||||
GroupChallengeOutcome::TYPE_FINALIST => 'Finalists',
|
||||
GroupChallengeOutcome::TYPE_RUNNER_UP => $count === 1 ? 'Runner-up' : 'Runner-up',
|
||||
GroupChallengeOutcome::TYPE_HONORABLE_MENTION => 'Honorable Mentions',
|
||||
GroupChallengeOutcome::TYPE_FEATURED => 'Featured Entries',
|
||||
default => GroupChallengeOutcome::labelForType($type),
|
||||
};
|
||||
}
|
||||
|
||||
private function mapOutcomeForEditor(GroupChallengeOutcome $outcome): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $outcome->id,
|
||||
'artwork_id' => (int) $outcome->artwork_id,
|
||||
'outcome_type' => (string) $outcome->outcome_type,
|
||||
'position' => $outcome->position,
|
||||
'sort_order' => (int) $outcome->sort_order,
|
||||
'title_override' => (string) ($outcome->title_override ?? ''),
|
||||
'note' => (string) ($outcome->note ?? ''),
|
||||
'artwork_title' => (string) ($outcome->artwork?->title ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function mapOutcomeItem(GroupChallengeOutcome $outcome): array
|
||||
{
|
||||
$artwork = $outcome->artwork;
|
||||
$creator = $artwork?->user;
|
||||
$statusLabel = $outcome->title_override ?: GroupChallengeOutcome::labelForType((string) $outcome->outcome_type);
|
||||
|
||||
return [
|
||||
'id' => (int) $outcome->id,
|
||||
'artwork_id' => (int) ($artwork?->id ?? 0),
|
||||
'outcome_type' => (string) $outcome->outcome_type,
|
||||
'position' => $outcome->position,
|
||||
'title' => (string) ($artwork?->title ?: 'Untitled artwork'),
|
||||
'subtitle' => (string) ($creator?->name ?: $creator?->username ?: ''),
|
||||
'description' => (string) ($outcome->note ?: Str::limit(trim(strip_tags((string) ($artwork?->description ?? ''))), 140)),
|
||||
'url' => $artwork ? route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug ?: $artwork->id]) : null,
|
||||
'image' => $artwork ? (ThumbnailPresenter::present($artwork, 'md')['url'] ?? $artwork->thumbUrl('md')) : null,
|
||||
'status' => (string) $outcome->outcome_type,
|
||||
'status_label' => $statusLabel,
|
||||
'context_label' => 'Challenge outcome',
|
||||
'meta' => array_values(array_filter([
|
||||
$outcome->position ? 'Place ' . $outcome->position : null,
|
||||
$outcome->awarded_at?->format('M j, Y'),
|
||||
])),
|
||||
];
|
||||
}
|
||||
|
||||
private function makeUniqueSlug(string $source, ?int $ignoreId = null): string
|
||||
{
|
||||
$base = Str::slug(Str::limit($source, 150, '')) ?: 'challenge';
|
||||
|
||||
@@ -69,7 +69,7 @@ class GroupDiscoveryService
|
||||
|
||||
public function publicListing(?User $viewer, string $surface = 'featured', int $page = 1, int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
$groups = $this->publicGroupBaseQuery()->get();
|
||||
$groups = $this->publicGroupBaseQuery($viewer)->get();
|
||||
|
||||
$sorted = $this->sortGroups($groups, $surface);
|
||||
$page = max(1, $page);
|
||||
@@ -89,7 +89,7 @@ class GroupDiscoveryService
|
||||
|
||||
public function surfaceCards(?User $viewer = null, string $surface = 'featured', int $limit = 6): array
|
||||
{
|
||||
return $this->sortGroups($this->publicGroupBaseQuery()->get(), $surface)
|
||||
return $this->sortGroups($this->publicGroupBaseQuery($viewer)->get(), $surface)
|
||||
->take(max(1, $limit))
|
||||
->map(fn (Group $group): array => $this->cards->mapGroupCard($group, $viewer))
|
||||
->values()
|
||||
@@ -104,7 +104,7 @@ class GroupDiscoveryService
|
||||
return [];
|
||||
}
|
||||
|
||||
$groups = $this->publicGroupBaseQuery()
|
||||
$groups = $this->publicGroupBaseQuery($viewer)
|
||||
->where(function (Builder $builder) use ($normalized): void {
|
||||
$builder->whereRaw('LOWER(name) LIKE ?', ['%' . $normalized . '%'])
|
||||
->orWhereRaw('LOWER(slug) LIKE ?', ['%' . $normalized . '%'])
|
||||
@@ -191,9 +191,9 @@ class GroupDiscoveryService
|
||||
];
|
||||
}
|
||||
|
||||
private function publicGroupBaseQuery(): Builder
|
||||
private function publicGroupBaseQuery(?User $viewer = null): Builder
|
||||
{
|
||||
return Group::query()
|
||||
$query = Group::query()
|
||||
->with(['owner.profile', 'recruitmentProfile', 'discoveryMetric', 'members', 'badges'])
|
||||
->withCount([
|
||||
'members as active_members_count' => fn (Builder $query) => $query->where('status', Group::STATUS_ACTIVE),
|
||||
@@ -203,7 +203,9 @@ class GroupDiscoveryService
|
||||
'releases as recent_public_releases_count' => fn (Builder $query) => $query
|
||||
->where('visibility', GroupRelease::VISIBILITY_PUBLIC)
|
||||
->where('status', GroupRelease::STATUS_RELEASED)
|
||||
->where('released_at', '>=', now()->subDays(60)),
|
||||
->where('released_at', '>=', now()->subDays(45)),
|
||||
'artworks as approved_group_artworks_count' => fn (Builder $query) => $query
|
||||
->where('group_review_status', 'approved'),
|
||||
'projects as public_projects_count' => fn (Builder $query) => $query
|
||||
->where('visibility', GroupProject::VISIBILITY_PUBLIC)
|
||||
->whereIn('status', [GroupProject::STATUS_ACTIVE, GroupProject::STATUS_REVIEW, GroupProject::STATUS_RELEASED]),
|
||||
@@ -225,6 +227,14 @@ class GroupDiscoveryService
|
||||
->where('status', GroupRelease::STATUS_RELEASED),
|
||||
], 'released_at')
|
||||
->public();
|
||||
|
||||
if ($viewer) {
|
||||
$query->withExists([
|
||||
'follows as viewer_is_following' => fn (Builder $followQuery) => $followQuery->where('user_id', $viewer->id),
|
||||
]);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function sortGroups(Collection $groups, string $surface): Collection
|
||||
|
||||
@@ -84,16 +84,29 @@ class GroupReputationService
|
||||
|
||||
public function trustSignals(Group $group): array
|
||||
{
|
||||
$releaseCount = (int) $group->releases()->where('status', GroupRelease::STATUS_RELEASED)->count();
|
||||
$recentReleaseCount = (int) $group->releases()
|
||||
->where('status', GroupRelease::STATUS_RELEASED)
|
||||
->where('released_at', '>=', now()->subDays(45))
|
||||
->count();
|
||||
$activeMembers = (int) $group->members()->where('status', Group::STATUS_ACTIVE)->count() + 1;
|
||||
$approvedArtworks = (int) Artwork::query()
|
||||
->where('group_id', $group->id)
|
||||
->where('group_review_status', 'approved')
|
||||
->count();
|
||||
$releaseCount = isset($group->public_releases_count)
|
||||
? (int) $group->public_releases_count
|
||||
: (int) $group->releases()->where('status', GroupRelease::STATUS_RELEASED)->count();
|
||||
|
||||
$recentReleaseCount = isset($group->recent_public_releases_count)
|
||||
? (int) $group->recent_public_releases_count
|
||||
: (int) $group->releases()
|
||||
->where('status', GroupRelease::STATUS_RELEASED)
|
||||
->where('released_at', '>=', now()->subDays(45))
|
||||
->count();
|
||||
|
||||
$activeMembers = (isset($group->active_members_count)
|
||||
? (int) $group->active_members_count
|
||||
: ($group->relationLoaded('members')
|
||||
? (int) $group->members->where('status', Group::STATUS_ACTIVE)->count()
|
||||
: (int) $group->members()->where('status', Group::STATUS_ACTIVE)->count())) + 1;
|
||||
|
||||
$approvedArtworks = isset($group->approved_group_artworks_count)
|
||||
? (int) $group->approved_group_artworks_count
|
||||
: (int) Artwork::query()
|
||||
->where('group_id', $group->id)
|
||||
->where('group_review_status', 'approved')
|
||||
->count();
|
||||
|
||||
$signals = [];
|
||||
|
||||
@@ -165,10 +178,15 @@ class GroupReputationService
|
||||
|
||||
public function groupBadges(Group $group, int $limit = 6): array
|
||||
{
|
||||
return $group->badges()
|
||||
->latest('awarded_at')
|
||||
->limit(max(1, min(24, $limit)))
|
||||
->get()
|
||||
$badges = $group->relationLoaded('badges')
|
||||
? $group->badges->sortByDesc(fn (GroupBadge $badge) => $badge->awarded_at?->getTimestamp() ?? 0)
|
||||
->take(max(1, min(24, $limit)))
|
||||
: $group->badges()
|
||||
->latest('awarded_at')
|
||||
->limit(max(1, min(24, $limit)))
|
||||
->get();
|
||||
|
||||
return $badges
|
||||
->map(fn (GroupBadge $badge): array => [
|
||||
'key' => (string) $badge->badge_key,
|
||||
'label' => $this->badgeLabel('group', (string) $badge->badge_key),
|
||||
@@ -382,16 +400,40 @@ class GroupReputationService
|
||||
private function awardMemberBadges(Group $group): void
|
||||
{
|
||||
$stats = GroupContributorStat::query()->where('group_id', $group->id)->get();
|
||||
$userIds = $stats->pluck('user_id')->map(static fn ($id): int => (int) $id)->unique()->values();
|
||||
|
||||
$projectLeadIds = GroupProject::query()
|
||||
->where('group_id', $group->id)
|
||||
->whereIn('lead_user_id', $userIds)
|
||||
->pluck('lead_user_id')
|
||||
->map(static fn ($id): int => (int) $id)
|
||||
->flip();
|
||||
|
||||
$assetCounts = $group->assets()
|
||||
->selectRaw('uploaded_by_user_id, COUNT(*) as aggregate')
|
||||
->whereIn('uploaded_by_user_id', $userIds)
|
||||
->groupBy('uploaded_by_user_id')
|
||||
->pluck('aggregate', 'uploaded_by_user_id');
|
||||
|
||||
$foundingMemberIds = GroupMember::query()
|
||||
->where('group_id', $group->id)
|
||||
->whereIn('user_id', $userIds)
|
||||
->when($group->created_at, fn ($query) => $query->where('accepted_at', '<=', $group->created_at->copy()->addDays(30)))
|
||||
->pluck('user_id')
|
||||
->map(static fn ($id): int => (int) $id)
|
||||
->flip();
|
||||
|
||||
foreach ($stats as $stat) {
|
||||
$userId = (int) $stat->user_id;
|
||||
|
||||
$this->awardMemberBadge($group, (int) $stat->user_id, 'first_group_contribution', (int) $stat->credited_artworks_count >= 1);
|
||||
$this->awardMemberBadge($group, (int) $stat->user_id, 'ten_group_contributions', (int) $stat->credited_artworks_count >= 10);
|
||||
$this->awardMemberBadge($group, (int) $stat->user_id, 'release_contributor', (int) $stat->release_count >= 1);
|
||||
$this->awardMemberBadge($group, (int) $stat->user_id, 'project_lead', GroupProject::query()->where('group_id', $group->id)->where('lead_user_id', $stat->user_id)->exists());
|
||||
$this->awardMemberBadge($group, (int) $stat->user_id, 'reliable_reviewer', (int) $stat->review_actions_count >= 5);
|
||||
$this->awardMemberBadge($group, (int) $stat->user_id, 'long_term_collaborator', ((int) $stat->project_count + (int) $stat->release_count) >= 5);
|
||||
$this->awardMemberBadge($group, (int) $stat->user_id, 'founding_member', $this->isFoundingMember($group, (int) $stat->user_id));
|
||||
$this->awardMemberBadge($group, (int) $stat->user_id, 'asset_builder', $group->assets()->where('uploaded_by_user_id', $stat->user_id)->count() >= 3);
|
||||
$this->awardMemberBadge($group, $userId, 'project_lead', $projectLeadIds->has($userId));
|
||||
$this->awardMemberBadge($group, $userId, 'reliable_reviewer', (int) $stat->review_actions_count >= 5);
|
||||
$this->awardMemberBadge($group, $userId, 'long_term_collaborator', ((int) $stat->project_count + (int) $stat->release_count) >= 5);
|
||||
$this->awardMemberBadge($group, $userId, 'founding_member', (int) $group->owner_user_id === $userId || $foundingMemberIds->has($userId));
|
||||
$this->awardMemberBadge($group, $userId, 'asset_builder', (int) ($assetCounts[$userId] ?? 0) >= 3);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -145,20 +145,32 @@ class NovaCardRenderService
|
||||
private function paintOverlay($image, array $project, int $width, int $height): void
|
||||
{
|
||||
$style = (string) Arr::get($project, 'background.overlay_style', 'dark-soft');
|
||||
$alpha = match ($style) {
|
||||
'dark-strong' => 72,
|
||||
'dark-soft' => 92,
|
||||
'light-soft' => 108,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($alpha === null) {
|
||||
if ($style === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Respect the opacity slider (0–100 %) that the CSS preview applies to the overlay div.
|
||||
$opacityPct = max(0, min(100, (int) Arr::get($project, 'background.opacity', 50)));
|
||||
$scale = $opacityPct / 100.0;
|
||||
|
||||
// Top/bottom gradient stop opacities — matches overlayStyle() in NovaCardCanvasPreview.jsx:
|
||||
// dark-soft: linear-gradient(180deg, rgba(2,6,23,0.18), rgba(2,6,23,0.48))
|
||||
// dark-strong: linear-gradient(180deg, rgba(2,6,23,0.38), rgba(2,6,23,0.68))
|
||||
// light-soft: linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0.22))
|
||||
[$topA, $botA] = match ($style) {
|
||||
'dark-strong' => [0.38, 0.68],
|
||||
'light-soft' => [0.08, 0.22],
|
||||
default => [0.18, 0.48], // dark-soft
|
||||
};
|
||||
$rgb = $style === 'light-soft' ? [255, 255, 255] : [0, 0, 0];
|
||||
$overlay = imagecolorallocatealpha($image, $rgb[0], $rgb[1], $rgb[2], $alpha);
|
||||
imagefilledrectangle($image, 0, 0, $width, $height, $overlay);
|
||||
|
||||
// Draw a scanline gradient to match the CSS linear-gradient overlay.
|
||||
for ($y = 0; $y < $height; $y++) {
|
||||
$alpha = ($topA + ($botA - $topA) * ($y / $height)) * $scale;
|
||||
$gdAlpha = max(0, min(127, (int) round((1.0 - $alpha) * 127)));
|
||||
$color = imagecolorallocatealpha($image, $rgb[0], $rgb[1], $rgb[2], $gdAlpha);
|
||||
imageline($image, 0, $y, $width - 1, $y, $color);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Text rendering (FreeType / GD fallback) ─────────────────────────────
|
||||
@@ -167,10 +179,15 @@ class NovaCardRenderService
|
||||
{
|
||||
$fontPreset = (string) Arr::get($project, 'typography.font_preset', 'modern-sans');
|
||||
$fontFile = $this->resolveFont($fontPreset);
|
||||
$textColor = $this->allocateHex($image, (string) Arr::get($project, 'typography.text_color', '#ffffff'));
|
||||
$accentColor = $this->allocateHex($image, (string) Arr::get($project, 'typography.accent_color', Arr::get($project, 'typography.text_color', '#ffffff')));
|
||||
// Apply text_opacity (10–100 %) to both text and accent colours, matching CSS blockStyle().
|
||||
$textOpacityPct = max(10, min(100, (int) Arr::get($project, 'typography.text_opacity', 100)));
|
||||
$textAlpha = (int) round((1.0 - $textOpacityPct / 100.0) * 127);
|
||||
[$tr, $tg, $tb] = $this->hexToRgb((string) Arr::get($project, 'typography.text_color', '#ffffff'));
|
||||
[$ar, $ag, $ab] = $this->hexToRgb((string) Arr::get($project, 'typography.accent_color', Arr::get($project, 'typography.text_color', '#ffffff')));
|
||||
$textColor = imagecolorallocatealpha($image, $tr, $tg, $tb, $textAlpha);
|
||||
$accentColor = imagecolorallocatealpha($image, $ar, $ag, $ab, $textAlpha);
|
||||
$alignment = (string) Arr::get($project, 'layout.alignment', 'center');
|
||||
$lhMulti = (float) Arr::get($project, 'typography.line_height', 1.35);
|
||||
$lhMulti = (float) Arr::get($project, 'typography.line_height', 1.2);
|
||||
$shadow = (string) Arr::get($project, 'typography.shadow_preset', 'soft');
|
||||
|
||||
$paddingRatio = match ((string) Arr::get($project, 'layout.padding', 'comfortable')) {
|
||||
@@ -400,15 +417,22 @@ class NovaCardRenderService
|
||||
foreach (array_slice($decorations, 0, (int) config('nova_cards.validation.max_decorations', 6)) as $index => $decoration) {
|
||||
$glyph = (string) Arr::get($decoration, 'glyph', '•');
|
||||
|
||||
// pos_x / pos_y are stored as percentages (0–100); fall back to sensible defaults.
|
||||
// pos_x / pos_y are stored as percentages (0–100); when absent, fall back to
|
||||
// `placement` field — mirroring placementStyles in NovaCardCanvasPreview.jsx.
|
||||
$xPct = Arr::get($decoration, 'pos_x');
|
||||
$yPct = Arr::get($decoration, 'pos_y');
|
||||
$x = $xPct !== null
|
||||
? (int) round((float) $xPct / 100 * $width)
|
||||
: (int) round(($index % 2 === 0 ? 0.12 : 0.82) * $width);
|
||||
$y = $yPct !== null
|
||||
? (int) round((float) $yPct / 100 * $height)
|
||||
: (int) round((0.14 + ($index * 0.1)) * $height);
|
||||
if ($xPct !== null && $yPct !== null) {
|
||||
$x = (int) round((float) $xPct / 100 * $width);
|
||||
$y = (int) round((float) $yPct / 100 * $height);
|
||||
} else {
|
||||
$placement = (string) Arr::get($decoration, 'placement', 'top-right');
|
||||
$x = str_contains($placement, 'left') ? (int) round(0.12 * $width)
|
||||
: (str_contains($placement, 'right') ? (int) round(0.88 * $width)
|
||||
: (int) round(0.50 * $width));
|
||||
$y = str_contains($placement, 'top') ? (int) round(0.12 * $height)
|
||||
: (str_contains($placement, 'bottom') ? (int) round(0.88 * $height)
|
||||
: (int) round(0.50 * $height));
|
||||
}
|
||||
|
||||
// Canvas clamp: max(18, min(size, 64)) matching NovaCardCanvasPreview.
|
||||
$rawSize = max(18, min((int) Arr::get($decoration, 'size', 28), 64));
|
||||
|
||||
@@ -22,7 +22,10 @@ class PostFeedService
|
||||
?int $viewerId,
|
||||
int $page = 1,
|
||||
): array {
|
||||
$baseQuery = Post::with($this->eagerLoads())
|
||||
$baseQuery = $this->applyViewerSaveState(
|
||||
Post::with($this->eagerLoads()),
|
||||
$viewerId,
|
||||
)
|
||||
->where('user_id', $profileUser->id)
|
||||
->visibleTo($viewerId);
|
||||
|
||||
@@ -80,6 +83,8 @@ class PostFeedService
|
||||
->visibleTo($viewer->id)
|
||||
->orderByDesc('created_at');
|
||||
|
||||
$query = $this->applyViewerSaveState($query, $viewer->id);
|
||||
|
||||
if ($filter === 'shares') $query->where('type', Post::TYPE_ARTWORK_SHARE);
|
||||
elseif ($filter === 'text') $query->where('type', Post::TYPE_TEXT);
|
||||
elseif ($filter === 'uploads') $query->where('type', Post::TYPE_UPLOAD);
|
||||
@@ -109,7 +114,10 @@ class PostFeedService
|
||||
): array {
|
||||
$tag = mb_strtolower($tag);
|
||||
|
||||
$paginated = Post::with($this->eagerLoads())
|
||||
$paginated = $this->applyViewerSaveState(
|
||||
Post::with($this->eagerLoads()),
|
||||
$viewerId,
|
||||
)
|
||||
->whereHas('hashtags', fn ($q) => $q->where('tag', $tag))
|
||||
->visibleTo($viewerId)
|
||||
->orderByDesc('created_at')
|
||||
@@ -132,7 +140,10 @@ class PostFeedService
|
||||
|
||||
public function getSavedFeed(User $viewer, int $page = 1): array
|
||||
{
|
||||
$paginated = Post::with($this->eagerLoads())
|
||||
$paginated = $this->applyViewerSaveState(
|
||||
Post::with($this->eagerLoads()),
|
||||
$viewer->id,
|
||||
)
|
||||
->whereHas('saves', fn ($q) => $q->where('user_id', $viewer->id))
|
||||
->where('status', Post::STATUS_PUBLISHED)
|
||||
->orderByDesc('created_at')
|
||||
@@ -174,6 +185,17 @@ class PostFeedService
|
||||
return $this->eagerLoads();
|
||||
}
|
||||
|
||||
private function applyViewerSaveState($query, ?int $viewerId)
|
||||
{
|
||||
if (! $viewerId) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->withExists([
|
||||
'saves as viewer_saved' => fn ($saveQuery) => $saveQuery->where('user_id', $viewerId),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Penalize runs of 5+ posts from the same author by deferring them to the end.
|
||||
*/
|
||||
@@ -223,8 +245,9 @@ class PostFeedService
|
||||
$viewerLiked = $viewerSaved = false;
|
||||
if ($viewerId) {
|
||||
$viewerLiked = $post->reactions->where('user_id', $viewerId)->where('reaction', 'like')->isNotEmpty();
|
||||
// saves are lazy-loaded only when needed; check if relation is loaded
|
||||
if ($post->relationLoaded('saves')) {
|
||||
if (array_key_exists('viewer_saved', $post->getAttributes())) {
|
||||
$viewerSaved = (bool) $post->getAttribute('viewer_saved');
|
||||
} elseif ($post->relationLoaded('saves')) {
|
||||
$viewerSaved = $post->saves->where('user_id', $viewerId)->isNotEmpty();
|
||||
} else {
|
||||
$viewerSaved = $post->saves()->where('user_id', $viewerId)->exists();
|
||||
|
||||
@@ -52,6 +52,9 @@ class PostTrendingService
|
||||
|
||||
// Load posts preserving ranked order
|
||||
$posts = Post::with($this->feedService->publicEagerLoads())
|
||||
->withExists([
|
||||
'saves as viewer_saved' => fn ($saveQuery) => $saveQuery->where('user_id', $viewerId),
|
||||
])
|
||||
->whereIn('id', $pageIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
@@ -204,17 +204,22 @@ class UserPreferenceBuilder
|
||||
return [];
|
||||
}
|
||||
|
||||
// Sample recent artworks to avoid full scan
|
||||
$rows = DB::table('artworks as a')
|
||||
->join('artwork_tag as at', 'at.artwork_id', '=', 'a.id')
|
||||
->join('tags as t', 't.id', '=', 'at.tag_id')
|
||||
->whereIn('a.user_id', $creatorIds)
|
||||
->where('a.is_public', true)
|
||||
->where('a.is_approved', true)
|
||||
->where('t.is_active', true)
|
||||
->whereNull('a.deleted_at')
|
||||
->orderByDesc('a.published_at')
|
||||
// Sample the 500 most-recent artworks first (subquery), then count tags.
|
||||
// ORDER BY must not appear on a non-aggregated column inside GROUP BY
|
||||
// (MySQL only_full_group_by mode rejects it).
|
||||
$recentIds = DB::table('artworks')
|
||||
->whereIn('user_id', $creatorIds)
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
->whereNull('deleted_at')
|
||||
->orderByDesc('published_at')
|
||||
->limit(500)
|
||||
->select('id');
|
||||
|
||||
$rows = DB::table('artwork_tag as at')
|
||||
->joinSub($recentIds, 'a', 'a.id', '=', 'at.artwork_id')
|
||||
->join('tags as t', 't.id', '=', 'at.tag_id')
|
||||
->where('t.is_active', true)
|
||||
->selectRaw('t.slug, COUNT(*) as cnt')
|
||||
->groupBy('t.id', 't.slug')
|
||||
->get();
|
||||
|
||||
@@ -35,10 +35,7 @@ abstract class AbstractIdShardableSitemapBuilder extends AbstractSitemapBuilder
|
||||
|
||||
public function lastModified(): ?DateTimeInterface
|
||||
{
|
||||
return $this->newest(...array_map(
|
||||
fn (SitemapUrl $item): ?DateTimeInterface => $item->lastModified,
|
||||
$this->items(),
|
||||
));
|
||||
return $this->dateTime((clone $this->query())->max($this->lastModifiedColumn()));
|
||||
}
|
||||
|
||||
public function totalItems(): int
|
||||
@@ -69,10 +66,16 @@ abstract class AbstractIdShardableSitemapBuilder extends AbstractSitemapBuilder
|
||||
|
||||
public function lastModifiedForShard(int $shard): ?DateTimeInterface
|
||||
{
|
||||
return $this->newest(...array_map(
|
||||
fn (SitemapUrl $item): ?DateTimeInterface => $item->lastModified,
|
||||
$this->itemsForShard($shard),
|
||||
));
|
||||
$window = $this->shardWindow($shard);
|
||||
|
||||
if ($window === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->dateTime(
|
||||
$this->applyShardWindow($window['from'], $window['to'])
|
||||
->max($this->lastModifiedColumn()),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,4 +135,9 @@ abstract class AbstractIdShardableSitemapBuilder extends AbstractSitemapBuilder
|
||||
{
|
||||
return $this->idColumn();
|
||||
}
|
||||
|
||||
protected function lastModifiedColumn(): string
|
||||
{
|
||||
return 'updated_at';
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
final class PublishedSitemapResolver
|
||||
{
|
||||
public function __construct(private readonly SitemapReleaseManager $releases)
|
||||
@@ -23,9 +25,23 @@ final class PublishedSitemapResolver
|
||||
*/
|
||||
public function resolveNamed(string $requestedName): ?array
|
||||
{
|
||||
$manifest = $this->releases->activeManifest();
|
||||
$releaseId = Cache::remember(
|
||||
'sitemaps:active-release-id',
|
||||
60,
|
||||
fn (): ?string => $this->releases->activeReleaseId(),
|
||||
);
|
||||
|
||||
if ($manifest === null) {
|
||||
if (! is_string($releaseId) || $releaseId === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$manifest = Cache::remember(
|
||||
'sitemaps:manifest:' . $releaseId,
|
||||
3600,
|
||||
fn (): ?array => $this->releases->readManifest($releaseId),
|
||||
);
|
||||
|
||||
if (! is_array($manifest)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -36,13 +52,27 @@ final class PublishedSitemapResolver
|
||||
|
||||
private function resolveDocumentName(string $documentName): ?array
|
||||
{
|
||||
$releaseId = $this->releases->activeReleaseId();
|
||||
$releaseId = Cache::remember(
|
||||
'sitemaps:active-release-id',
|
||||
60,
|
||||
fn (): ?string => $this->releases->activeReleaseId(),
|
||||
);
|
||||
|
||||
if ($releaseId === null) {
|
||||
if (! is_string($releaseId) || $releaseId === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = $this->releases->getDocument($releaseId, $documentName);
|
||||
$ttl = max((int) config('sitemaps.cache_ttl_seconds', 900), 3600);
|
||||
$cacheKey = 'sitemaps:doc:' . $releaseId . ':' . $documentName;
|
||||
|
||||
$content = Cache::get($cacheKey);
|
||||
|
||||
if (! is_string($content) || $content === '') {
|
||||
$content = $this->releases->getDocument($releaseId, $documentName);
|
||||
if (is_string($content) && $content !== '') {
|
||||
Cache::put($cacheKey, $content, $ttl);
|
||||
}
|
||||
}
|
||||
|
||||
return is_string($content) && $content !== ''
|
||||
? ['content' => $content, 'release_id' => $releaseId, 'document_name' => $documentName]
|
||||
|
||||
@@ -13,6 +13,7 @@ final class SitemapPublishService
|
||||
private readonly SitemapReleaseCleanupService $cleanup,
|
||||
private readonly SitemapReleaseManager $releases,
|
||||
private readonly SitemapReleaseValidator $validator,
|
||||
private readonly SitemapStaticPublisher $staticPublisher,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -59,7 +60,12 @@ final class SitemapPublishService
|
||||
$this->releases->activate($releaseId);
|
||||
$deleted = $this->cleanup->cleanup();
|
||||
|
||||
return $manifest + ['cleanup_deleted' => $deleted];
|
||||
$staticResult = [];
|
||||
if ($this->staticPublisher->enabled()) {
|
||||
$staticResult = $this->staticPublisher->publish($releaseId);
|
||||
}
|
||||
|
||||
return $manifest + ['cleanup_deleted' => $deleted, 'static_published' => $staticResult];
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
@@ -70,6 +71,8 @@ final class SitemapReleaseManager
|
||||
];
|
||||
|
||||
$this->atomicJsonWrite($this->activePointerPath(), $payload);
|
||||
|
||||
Cache::forget('sitemaps:active-release-id');
|
||||
}
|
||||
|
||||
public function activeReleaseId(): ?string
|
||||
|
||||
63
app/Services/Sitemaps/SitemapStaticPublisher.php
Normal file
63
app/Services/Sitemaps/SitemapStaticPublisher.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Writes every document from a published release to the public disk so nginx
|
||||
* can serve sitemap.xml and sitemaps/{name}.xml as plain static files,
|
||||
* bypassing PHP entirely on subsequent requests.
|
||||
*/
|
||||
final class SitemapStaticPublisher
|
||||
{
|
||||
public function __construct(private readonly SitemapReleaseManager $releases)
|
||||
{
|
||||
}
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
return (bool) config('sitemaps.static_publish.enabled', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy all documents from the given release to the public disk.
|
||||
*
|
||||
* @return array{written: int, skipped: int}
|
||||
*/
|
||||
public function publish(string $releaseId): array
|
||||
{
|
||||
$manifest = $this->releases->readManifest($releaseId);
|
||||
|
||||
if ($manifest === null) {
|
||||
return ['written' => 0, 'skipped' => 0];
|
||||
}
|
||||
|
||||
$disk = Storage::disk($this->publicDisk());
|
||||
$documents = (array) ($manifest['documents'] ?? []);
|
||||
|
||||
$written = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($documents as $documentName => $relativePath) {
|
||||
$content = $this->releases->getDocument($releaseId, (string) $documentName);
|
||||
|
||||
if (! is_string($content) || $content === '') {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$disk->put((string) $relativePath, $content);
|
||||
$written++;
|
||||
}
|
||||
|
||||
return ['written' => $written, 'skipped' => $skipped];
|
||||
}
|
||||
|
||||
private function publicDisk(): string
|
||||
{
|
||||
return (string) config('sitemaps.static_publish.disk', 'sitemaps_public');
|
||||
}
|
||||
}
|
||||
@@ -332,7 +332,7 @@ class SmartCollectionService
|
||||
match ($field) {
|
||||
'tags' => $query->whereHas('tags', function (Builder $builder) use ($value): void {
|
||||
$builder->where('tags.slug', (string) $value)
|
||||
->orWhere('tags.name', 'like', '%' . (string) $value . '%');
|
||||
->orWhere('tags.name', (string) $value);
|
||||
}),
|
||||
'category' => $query->whereHas('categories', function (Builder $builder) use ($value): void {
|
||||
$builder->where('categories.slug', (string) $value)
|
||||
|
||||
@@ -129,9 +129,12 @@ final class CreatorStudioCalendarService
|
||||
$days[] = [
|
||||
'date' => $key,
|
||||
'day' => $date->day,
|
||||
'label' => $date->format('D, M j'),
|
||||
'is_current_month' => $date->month === $focusDate->month,
|
||||
'count' => $items->count(),
|
||||
'items' => $items->take(3)->all(),
|
||||
'overflow_count' => max(0, $items->count() - 4),
|
||||
'detail_items' => $items->all(),
|
||||
'items' => $items->take(4)->all(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,8 @@ final class StudioArtworkQueryService
|
||||
|
||||
// Category filter
|
||||
if (!empty($filters['category'])) {
|
||||
$filterParts[] = 'category = "' . addslashes((string) $filters['category']) . '"';
|
||||
$quoted = addslashes((string) $filters['category']);
|
||||
$filterParts[] = '(category = "' . $quoted . '" OR categories = "' . $quoted . '")';
|
||||
}
|
||||
|
||||
// Tag filter
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Services\Studio;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Tag;
|
||||
use App\Jobs\IndexArtworkJob;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
@@ -156,12 +157,8 @@ final class StudioBulkActionService
|
||||
*/
|
||||
private function reindexArtworks(\Illuminate\Database\Eloquent\Collection $artworks): void
|
||||
{
|
||||
try {
|
||||
$artworks->each->searchable();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Studio: Failed to reindex artworks after bulk action', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
foreach ($artworks as $artwork) {
|
||||
IndexArtworkJob::dispatch((int) $artwork->id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,7 +305,7 @@ final class TagService
|
||||
{
|
||||
$before = (int) $tag->usage_count;
|
||||
$count = (int) DB::table('artwork_tag')->where('tag_id', $tag->id)->count();
|
||||
$tag->forceFill(['usage_count' => $count])->save();
|
||||
$tag->forceFill(['usage_count' => $count, 'artworks_count' => $count])->save();
|
||||
|
||||
return [
|
||||
'before' => $before,
|
||||
@@ -379,7 +379,10 @@ final class TagService
|
||||
return;
|
||||
}
|
||||
|
||||
Tag::query()->whereIn('id', $tagIds)->increment('usage_count');
|
||||
DB::table('tags')->whereIn('id', $tagIds)->update([
|
||||
'usage_count' => DB::raw('usage_count + 1'),
|
||||
'artworks_count' => DB::raw('artworks_count + 1'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -394,7 +397,10 @@ final class TagService
|
||||
// Never allow negative counts.
|
||||
DB::table('tags')
|
||||
->whereIn('id', $tagIds)
|
||||
->update(['usage_count' => DB::raw('CASE WHEN usage_count > 0 THEN usage_count - 1 ELSE 0 END')]);
|
||||
->update([
|
||||
'usage_count' => DB::raw('CASE WHEN usage_count > 0 THEN usage_count - 1 ELSE 0 END'),
|
||||
'artworks_count' => DB::raw('CASE WHEN artworks_count > 0 THEN artworks_count - 1 ELSE 0 END'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,10 +23,9 @@ final class TagDiscoveryService
|
||||
public function featuredTags(int $limit = 6, int $windowDays = 14): Collection
|
||||
{
|
||||
return $this->activeTagsQuery($windowDays)
|
||||
->withCount('artworks')
|
||||
->orderByDesc('recent_clicks')
|
||||
->orderByDesc('usage_count')
|
||||
->orderByDesc('artworks_count')
|
||||
->orderByDesc('tags.artworks_count')
|
||||
->orderBy('name')
|
||||
->limit($limit)
|
||||
->get();
|
||||
@@ -37,7 +36,6 @@ final class TagDiscoveryService
|
||||
$featuredSlugs = $featuredTags->pluck('slug')->filter()->values();
|
||||
|
||||
$risingTags = $this->activeTagsQuery($windowDays)
|
||||
->withCount('artworks')
|
||||
->when($featuredSlugs->isNotEmpty(), function ($builder) use ($featuredSlugs): void {
|
||||
$builder->whereNotIn('tags.slug', $featuredSlugs->all());
|
||||
})
|
||||
@@ -45,7 +43,7 @@ final class TagDiscoveryService
|
||||
$builder->whereRaw('COALESCE(tag_momentum.recent_clicks, 0) > 0');
|
||||
})
|
||||
->orderByDesc('recent_clicks')
|
||||
->orderByDesc('artworks_count')
|
||||
->orderByDesc('tags.artworks_count')
|
||||
->orderBy('name')
|
||||
->limit($limit)
|
||||
->get();
|
||||
@@ -61,12 +59,11 @@ final class TagDiscoveryService
|
||||
->values();
|
||||
|
||||
$fallback = $this->activeTagsQuery($windowDays)
|
||||
->withCount('artworks')
|
||||
->when($excludeSlugs->isNotEmpty(), function ($builder) use ($excludeSlugs): void {
|
||||
$builder->whereNotIn('tags.slug', $excludeSlugs->all());
|
||||
})
|
||||
->orderByDesc('usage_count')
|
||||
->orderByDesc('artworks_count')
|
||||
->orderByDesc('tags.artworks_count')
|
||||
->orderBy('name')
|
||||
->limit($limit - $risingTags->count())
|
||||
->get();
|
||||
@@ -76,8 +73,7 @@ final class TagDiscoveryService
|
||||
|
||||
public function paginatedTags(string $query = '', int $perPage = 48, int $windowDays = 14): LengthAwarePaginator
|
||||
{
|
||||
$tagsQuery = $this->activeTagsQuery($windowDays)
|
||||
->withCount('artworks');
|
||||
$tagsQuery = $this->activeTagsQuery($windowDays);
|
||||
|
||||
if ($query !== '') {
|
||||
$tagsQuery->where(function ($builder) use ($query): void {
|
||||
@@ -90,7 +86,7 @@ final class TagDiscoveryService
|
||||
return $tagsQuery
|
||||
->orderByDesc('recent_clicks')
|
||||
->orderByDesc('usage_count')
|
||||
->orderByDesc('artworks_count')
|
||||
->orderByDesc('tags.artworks_count')
|
||||
->orderBy('name')
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
|
||||
@@ -12,6 +12,35 @@ use RuntimeException;
|
||||
|
||||
final class UploadDerivativesService
|
||||
{
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private const PASSTHROUGH_DOWNLOAD_EXTENSIONS = [
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'gif',
|
||||
'webp',
|
||||
'bmp',
|
||||
'tif',
|
||||
'tiff',
|
||||
'svg',
|
||||
'avif',
|
||||
'heic',
|
||||
'heif',
|
||||
'ico',
|
||||
'jfif',
|
||||
'zip',
|
||||
'rar',
|
||||
'7z',
|
||||
'7zip',
|
||||
'tar',
|
||||
'gz',
|
||||
'tgz',
|
||||
'bz2',
|
||||
'xz',
|
||||
];
|
||||
|
||||
private bool $imageAvailable = false;
|
||||
private ?ImageManager $manager = null;
|
||||
|
||||
@@ -59,6 +88,49 @@ final class UploadDerivativesService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{local_path: string, object_path: string, filename: string, mime: string, size: int, ext: string}
|
||||
*/
|
||||
public function storeDownloadOriginal(string $sourcePath, string $hash, ?string $originalFileName = null): array
|
||||
{
|
||||
$origExt = $this->resolveOriginalExtension($sourcePath, $originalFileName);
|
||||
|
||||
if (in_array($origExt, self::PASSTHROUGH_DOWNLOAD_EXTENSIONS, true)) {
|
||||
return $this->storeOriginal($sourcePath, $hash, $originalFileName);
|
||||
}
|
||||
|
||||
$filename = $hash . '.zip';
|
||||
$target = $this->storage->localOriginalPath($hash, $filename);
|
||||
File::delete($target);
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
$opened = $zip->open($target, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
|
||||
if ($opened !== true) {
|
||||
throw new RuntimeException('Unable to create zip archive for download original.');
|
||||
}
|
||||
|
||||
try {
|
||||
if (! $zip->addFile($sourcePath, $this->resolveDownloadArchiveEntryName($originalFileName, $hash, $origExt))) {
|
||||
throw new RuntimeException('Unable to add source file to download archive.');
|
||||
}
|
||||
} finally {
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
$size = (int) (filesize($target) ?: 0);
|
||||
$objectPath = $this->storage->objectPathForVariant('original', $hash, $filename);
|
||||
$this->storage->putObjectFromPath($target, $objectPath, 'application/zip');
|
||||
|
||||
return [
|
||||
'local_path' => $target,
|
||||
'object_path' => $objectPath,
|
||||
'filename' => $filename,
|
||||
'mime' => 'application/zip',
|
||||
'size' => $size,
|
||||
'ext' => 'zip',
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveOriginalExtension(string $sourcePath, ?string $originalFileName): string
|
||||
{
|
||||
$fromClientName = strtolower((string) pathinfo((string) $originalFileName, PATHINFO_EXTENSION));
|
||||
@@ -75,6 +147,20 @@ final class UploadDerivativesService
|
||||
return $this->extensionFromMime($mime);
|
||||
}
|
||||
|
||||
private function resolveDownloadArchiveEntryName(?string $originalFileName, string $hash, string $sourceExt): string
|
||||
{
|
||||
$candidate = trim((string) pathinfo((string) $originalFileName, PATHINFO_FILENAME));
|
||||
$candidate = str_replace(['/', '\\'], '-', $candidate);
|
||||
$candidate = trim((string) preg_replace('/[\x00-\x1F\x7F]/', '', $candidate));
|
||||
$candidate = trim($candidate, ". \t\n\r\0\x0B");
|
||||
|
||||
if ($candidate === '' || $candidate === '.' || $candidate === '..') {
|
||||
$candidate = $hash !== '' ? $hash : 'artwork';
|
||||
}
|
||||
|
||||
return $candidate . '.' . ($sourceExt !== '' ? $sourceExt : 'bin');
|
||||
}
|
||||
|
||||
private function extensionFromMime(string $mime): string
|
||||
{
|
||||
return match (strtolower($mime)) {
|
||||
|
||||
@@ -166,7 +166,7 @@ final class UploadPipelineService
|
||||
|
||||
$archiveOriginal = null;
|
||||
if ($archiveSession && is_string($archiveHash) && trim($archiveHash) !== '') {
|
||||
$archiveOriginal = $this->derivatives->storeOriginal($archiveSession->tempPath, trim($archiveHash), $archiveOriginalFileName);
|
||||
$archiveOriginal = $this->derivatives->storeDownloadOriginal($archiveSession->tempPath, trim($archiveHash), $archiveOriginalFileName);
|
||||
$localCleanup[] = $archiveOriginal['local_path'];
|
||||
$objectCleanup[] = $archiveOriginal['object_path'];
|
||||
}
|
||||
@@ -301,24 +301,24 @@ final class UploadPipelineService
|
||||
|
||||
private function resolveDownloadFileName(string $storedFilename, string $ext, ?string $preferredFileName): string
|
||||
{
|
||||
$downloadFileName = $storedFilename;
|
||||
$name = is_string($preferredFileName) && trim($preferredFileName) !== ''
|
||||
? basename(str_replace('\\', '/', $preferredFileName))
|
||||
: $storedFilename;
|
||||
|
||||
if (is_string($preferredFileName) && trim($preferredFileName) !== '') {
|
||||
$candidate = basename(str_replace('\\', '/', $preferredFileName));
|
||||
$candidate = preg_replace('/[\x00-\x1F\x7F]/', '', (string) $candidate) ?? '';
|
||||
$candidate = trim((string) $candidate);
|
||||
$name = preg_replace('/[\x00-\x1F\x7F]/', '', (string) $name) ?? '';
|
||||
$name = preg_replace('/\s+/', ' ', $name) ?? '';
|
||||
$name = trim((string) $name, ". \t\n\r\0\x0B");
|
||||
|
||||
if ($candidate !== '') {
|
||||
$candidateExt = strtolower((string) pathinfo($candidate, PATHINFO_EXTENSION));
|
||||
if ($candidateExt === '' && $ext !== '') {
|
||||
$candidate .= '.' . $ext;
|
||||
}
|
||||
|
||||
$downloadFileName = $candidate;
|
||||
}
|
||||
$baseName = trim((string) pathinfo($name, PATHINFO_FILENAME), ". \t\n\r\0\x0B");
|
||||
if ($baseName === '') {
|
||||
$baseName = 'artwork';
|
||||
}
|
||||
|
||||
return $downloadFileName;
|
||||
$normalizedExt = strtolower(ltrim(trim($ext), '.'));
|
||||
|
||||
return $normalizedExt !== ''
|
||||
? $baseName . '.' . $normalizedExt
|
||||
: $baseName;
|
||||
}
|
||||
|
||||
private function screenshotVariantName(int $position): string
|
||||
|
||||
760
app/Services/Uploads/UploadQueueService.php
Normal file
760
app/Services/Uploads/UploadQueueService.php
Normal file
@@ -0,0 +1,760 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Uploads;
|
||||
|
||||
use App\Jobs\AnalyzeArtworkAiAssistJob;
|
||||
use App\Jobs\AutoTagArtworkJob;
|
||||
use App\Jobs\DetectArtworkMaturityJob;
|
||||
use App\Jobs\GenerateArtworkEmbeddingJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\UploadBatch;
|
||||
use App\Models\UploadBatchItem;
|
||||
use App\Models\User;
|
||||
use App\Services\Artworks\ArtworkDraftService;
|
||||
use App\Services\Artworks\ArtworkPublicationService;
|
||||
use App\Services\Maturity\ArtworkMaturityService;
|
||||
use App\Services\TagService;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class UploadQueueService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ArtworkDraftService $artworkDrafts,
|
||||
private readonly ArtworkPublicationService $publication,
|
||||
private readonly TagService $tags,
|
||||
) {
|
||||
}
|
||||
|
||||
public function createBatch(User $user, array $files, array $defaults = [], ?string $name = null): UploadBatch
|
||||
{
|
||||
$normalizedFiles = collect($files)
|
||||
->map(fn (mixed $file): array => [
|
||||
'name' => trim((string) data_get($file, 'name', '')),
|
||||
])
|
||||
->filter(fn (array $file): bool => $file['name'] !== '')
|
||||
->values();
|
||||
|
||||
if ($normalizedFiles->isEmpty()) {
|
||||
throw ValidationException::withMessages([
|
||||
'files' => ['Choose at least one file for the upload queue.'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($normalizedFiles->count() > 50) {
|
||||
throw ValidationException::withMessages([
|
||||
'files' => ['Bulk upload is limited to 50 files per batch in v1.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$normalizedDefaults = $this->normalizeDefaults($defaults);
|
||||
|
||||
$batch = DB::transaction(function () use ($user, $normalizedFiles, $normalizedDefaults, $name): UploadBatch {
|
||||
$batch = UploadBatch::query()->create([
|
||||
'user_id' => (int) $user->id,
|
||||
'name' => $this->normalizeBatchName($name, $normalizedFiles->count()),
|
||||
'status' => UploadBatch::STATUS_UPLOADING,
|
||||
'total_items' => $normalizedFiles->count(),
|
||||
'defaults_json' => $normalizedDefaults === [] ? null : $normalizedDefaults,
|
||||
]);
|
||||
|
||||
foreach ($normalizedFiles as $file) {
|
||||
$draft = $this->artworkDrafts->createDraft(
|
||||
$user,
|
||||
$this->titleFromFilename($file['name']),
|
||||
null,
|
||||
Arr::get($normalizedDefaults, 'category_id'),
|
||||
(bool) Arr::get($normalizedDefaults, 'is_mature', false),
|
||||
Arr::get($normalizedDefaults, 'group')
|
||||
);
|
||||
|
||||
$artwork = Artwork::query()->findOrFail($draft->artworkId);
|
||||
|
||||
$artwork->forceFill([
|
||||
'visibility' => Arr::get($normalizedDefaults, 'visibility', Artwork::VISIBILITY_PUBLIC),
|
||||
'is_public' => false,
|
||||
'artwork_status' => 'draft',
|
||||
])->saveQuietly();
|
||||
|
||||
$defaultTags = (array) Arr::get($normalizedDefaults, 'tags', []);
|
||||
if ($defaultTags !== []) {
|
||||
$this->tags->syncStudioTags($artwork, $defaultTags);
|
||||
}
|
||||
|
||||
$batch->items()->create([
|
||||
'user_id' => (int) $user->id,
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'original_filename' => $file['name'],
|
||||
'status' => UploadBatchItem::STATUS_UPLOADED,
|
||||
'processing_stage' => UploadBatchItem::STAGE_QUEUED,
|
||||
'metadata_completeness' => $this->metadataCompleteness($artwork, false, false),
|
||||
'is_ready_to_publish' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
return $batch;
|
||||
});
|
||||
|
||||
return $this->refreshBatch($batch->id);
|
||||
}
|
||||
|
||||
public function listPayload(User $user, array $filters = []): array
|
||||
{
|
||||
$requestedBatchId = (int) ($filters['batch_id'] ?? 0);
|
||||
$statusFilter = Str::lower(trim((string) ($filters['status'] ?? 'all')));
|
||||
$sort = Str::lower(trim((string) ($filters['sort'] ?? 'newest')));
|
||||
|
||||
$recentBatches = UploadBatch::query()
|
||||
->where('user_id', (int) $user->id)
|
||||
->latest('id')
|
||||
->limit(12)
|
||||
->get();
|
||||
|
||||
$currentBatch = $requestedBatchId > 0
|
||||
? $recentBatches->firstWhere('id', $requestedBatchId)
|
||||
: $recentBatches->first();
|
||||
|
||||
if ($currentBatch) {
|
||||
$currentBatch = $this->refreshBatch((int) $currentBatch->id);
|
||||
}
|
||||
|
||||
$items = $currentBatch
|
||||
? $currentBatch->items
|
||||
->loadMissing(['artwork.categories.contentType', 'artwork.tags'])
|
||||
->map(fn (UploadBatchItem $item): array => $this->presentItem($this->refreshItem((int) $item->id)))
|
||||
: collect();
|
||||
|
||||
$filteredItems = $this->applyFiltersAndSorting($items, $statusFilter, $sort)->values();
|
||||
$recentBatchPayloads = $recentBatches
|
||||
->map(fn (UploadBatch $batch): UploadBatch => $batch->relationLoaded('items') ? $batch : $this->refreshBatch((int) $batch->id))
|
||||
->map(fn (UploadBatch $batch): array => $this->presentBatch($batch))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'filters' => [
|
||||
'batch_id' => $currentBatch?->id,
|
||||
'status' => $statusFilter,
|
||||
'sort' => $sort,
|
||||
],
|
||||
'batches' => $recentBatchPayloads,
|
||||
'current_batch' => $currentBatch ? $this->presentBatch($currentBatch) : null,
|
||||
'items' => $filteredItems->all(),
|
||||
'status_options' => [
|
||||
['value' => 'all', 'label' => 'All'],
|
||||
['value' => UploadBatchItem::STATUS_PROCESSING, 'label' => 'Processing'],
|
||||
['value' => UploadBatchItem::STATUS_NEEDS_METADATA, 'label' => 'Needs metadata'],
|
||||
['value' => UploadBatchItem::STATUS_NEEDS_REVIEW, 'label' => 'Needs review'],
|
||||
['value' => UploadBatchItem::STATUS_READY, 'label' => 'Ready'],
|
||||
['value' => UploadBatchItem::STATUS_FAILED, 'label' => 'Failed'],
|
||||
['value' => UploadBatchItem::STATUS_PUBLISHED, 'label' => 'Published'],
|
||||
],
|
||||
'sort_options' => [
|
||||
['value' => 'newest', 'label' => 'Newest first'],
|
||||
['value' => 'oldest', 'label' => 'Oldest first'],
|
||||
['value' => 'filename', 'label' => 'Filename'],
|
||||
['value' => 'status', 'label' => 'Status'],
|
||||
['value' => 'ready', 'label' => 'Ready first'],
|
||||
['value' => 'failed', 'label' => 'Failed first'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function markItemProcessingQueued(int $itemId): UploadBatchItem
|
||||
{
|
||||
$item = $this->itemQuery()->findOrFail($itemId);
|
||||
|
||||
$item->forceFill([
|
||||
'status' => UploadBatchItem::STATUS_PROCESSING,
|
||||
'processing_stage' => UploadBatchItem::STAGE_THUMBNAILS,
|
||||
'error_code' => null,
|
||||
'error_message' => null,
|
||||
'uploaded_at' => $item->uploaded_at ?: now(),
|
||||
])->save();
|
||||
|
||||
$this->refreshBatch((int) $item->upload_batch_id);
|
||||
|
||||
return $item->fresh(['artwork.categories.contentType', 'artwork.tags']) ?? $item;
|
||||
}
|
||||
|
||||
public function markItemMediaProcessed(int $itemId): UploadBatchItem
|
||||
{
|
||||
$item = $this->itemQuery()->findOrFail($itemId);
|
||||
|
||||
$item->forceFill([
|
||||
'processing_stage' => UploadBatchItem::STAGE_MATURITY_CHECK,
|
||||
'error_code' => null,
|
||||
'error_message' => null,
|
||||
'processed_at' => now(),
|
||||
])->save();
|
||||
|
||||
$refreshed = $this->refreshItem((int) $item->id);
|
||||
$this->refreshBatch((int) $item->upload_batch_id);
|
||||
|
||||
return $refreshed;
|
||||
}
|
||||
|
||||
public function markItemFailed(int $itemId, ?string $errorCode, ?string $errorMessage): UploadBatchItem
|
||||
{
|
||||
$item = $this->itemQuery()->findOrFail($itemId);
|
||||
|
||||
$item->forceFill([
|
||||
'status' => UploadBatchItem::STATUS_FAILED,
|
||||
'error_code' => $this->nullableString($errorCode),
|
||||
'error_message' => $this->nullableText($errorMessage),
|
||||
'is_ready_to_publish' => false,
|
||||
])->save();
|
||||
|
||||
$this->refreshBatch((int) $item->upload_batch_id);
|
||||
|
||||
return $item->fresh(['artwork.categories.contentType', 'artwork.tags']) ?? $item;
|
||||
}
|
||||
|
||||
public function markItemFailedForUser(User $user, int $itemId, ?string $errorCode, ?string $errorMessage): UploadBatchItem
|
||||
{
|
||||
$item = $this->ownedItems($user, [$itemId])->first();
|
||||
if (! $item) {
|
||||
throw ValidationException::withMessages([
|
||||
'item' => ['Upload queue item not found.'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->markItemFailed((int) $item->id, $errorCode, $errorMessage);
|
||||
}
|
||||
|
||||
public function refreshItem(int $itemId): UploadBatchItem
|
||||
{
|
||||
$item = $this->itemQuery()->findOrFail($itemId);
|
||||
$state = $this->evaluateItemState($item);
|
||||
|
||||
$item->forceFill([
|
||||
'status' => $state['status'],
|
||||
'processing_stage' => $state['processing_stage'],
|
||||
'metadata_completeness' => $state['metadata_completeness'],
|
||||
'is_ready_to_publish' => $state['is_ready_to_publish'],
|
||||
'published_at' => $state['published_at'],
|
||||
'processed_at' => $state['processed_at'],
|
||||
])->saveQuietly();
|
||||
|
||||
return $item->fresh(['artwork.categories.contentType', 'artwork.tags']) ?? $item;
|
||||
}
|
||||
|
||||
public function refreshBatch(int $batchId): UploadBatch
|
||||
{
|
||||
$batch = UploadBatch::query()
|
||||
->with(['items.artwork.categories.contentType', 'items.artwork.tags'])
|
||||
->findOrFail($batchId);
|
||||
|
||||
$items = $batch->items->map(fn (UploadBatchItem $item): UploadBatchItem => $this->refreshItem((int) $item->id));
|
||||
$summary = $this->summarizeItems($items);
|
||||
|
||||
$batch->forceFill([
|
||||
'status' => $summary['status'],
|
||||
'total_items' => $summary['total_items'],
|
||||
'processed_items' => $summary['processed_items'],
|
||||
'failed_items' => $summary['failed_items'],
|
||||
'published_items' => $summary['published_items'],
|
||||
])->saveQuietly();
|
||||
|
||||
return $batch->fresh(['items.artwork.categories.contentType', 'items.artwork.tags']) ?? $batch;
|
||||
}
|
||||
|
||||
public function bulkAction(User $user, string $action, array $itemIds, array $params = []): array
|
||||
{
|
||||
$items = $this->ownedItems($user, $itemIds);
|
||||
|
||||
if ($items->isEmpty()) {
|
||||
return [
|
||||
'success' => 0,
|
||||
'failed' => count($itemIds),
|
||||
'errors' => ['No owned upload queue items were found.'],
|
||||
];
|
||||
}
|
||||
|
||||
$success = 0;
|
||||
$failed = 0;
|
||||
$errors = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
try {
|
||||
match ($action) {
|
||||
'publish' => $this->publishItem($item),
|
||||
'delete' => $this->deleteItem($item),
|
||||
'apply_category' => $this->applyCategory($item, (int) ($params['category_id'] ?? 0)),
|
||||
'apply_tags' => $this->applyTags($item, (array) ($params['tags'] ?? [])),
|
||||
'set_visibility' => $this->setVisibility($item, (string) ($params['visibility'] ?? '')),
|
||||
'generate_ai' => $this->retryProcessing($item),
|
||||
default => throw ValidationException::withMessages([
|
||||
'action' => ['Unsupported upload queue action.'],
|
||||
]),
|
||||
};
|
||||
|
||||
$success++;
|
||||
} catch (ValidationException $exception) {
|
||||
$failed++;
|
||||
$errors[] = collect($exception->errors())->flatten()->first() ?: 'Action failed.';
|
||||
} catch (\Throwable $exception) {
|
||||
$failed++;
|
||||
$errors[] = $exception->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$batchIds = $items->pluck('upload_batch_id')->unique()->filter()->all();
|
||||
foreach ($batchIds as $batchId) {
|
||||
$this->refreshBatch((int) $batchId);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => $success,
|
||||
'failed' => $failed,
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
public function retryProcessingForUser(User $user, int $itemId): UploadBatchItem
|
||||
{
|
||||
$item = $this->ownedItems($user, [$itemId])->first();
|
||||
if (! $item) {
|
||||
throw ValidationException::withMessages([
|
||||
'item' => ['Upload queue item not found.'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->retryProcessing($item);
|
||||
}
|
||||
|
||||
private function retryProcessing(UploadBatchItem $item): UploadBatchItem
|
||||
{
|
||||
$artwork = $item->artwork;
|
||||
if (! $artwork || trim((string) ($artwork->hash ?? '')) === '' || trim((string) ($artwork->file_path ?? '')) === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'item' => ['This item cannot be retried safely. Re-upload the original file instead.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$item->forceFill([
|
||||
'status' => UploadBatchItem::STATUS_PROCESSING,
|
||||
'processing_stage' => UploadBatchItem::STAGE_MATURITY_CHECK,
|
||||
'error_code' => null,
|
||||
'error_message' => null,
|
||||
'is_ready_to_publish' => false,
|
||||
])->save();
|
||||
|
||||
AutoTagArtworkJob::dispatch((int) $artwork->id, (string) $artwork->hash)->afterCommit();
|
||||
DetectArtworkMaturityJob::dispatch((int) $artwork->id, (string) $artwork->hash)->afterCommit();
|
||||
GenerateArtworkEmbeddingJob::dispatch((int) $artwork->id, (string) $artwork->hash)->afterCommit();
|
||||
AnalyzeArtworkAiAssistJob::dispatch((int) $artwork->id, true)->afterCommit();
|
||||
|
||||
return $this->refreshItem((int) $item->id);
|
||||
}
|
||||
|
||||
private function publishItem(UploadBatchItem $item): void
|
||||
{
|
||||
$item = $this->refreshItem((int) $item->id);
|
||||
if (! $item->is_ready_to_publish || ! $item->artwork) {
|
||||
throw ValidationException::withMessages([
|
||||
'item' => ['Only ready queue items can be published.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$artwork = $item->artwork;
|
||||
$artwork->forceFill([
|
||||
'is_approved' => true,
|
||||
'visibility' => $artwork->visibility ?: Artwork::VISIBILITY_PUBLIC,
|
||||
])->saveQuietly();
|
||||
|
||||
$this->publication->publishNow($artwork->fresh() ?? $artwork);
|
||||
|
||||
$item->forceFill([
|
||||
'status' => UploadBatchItem::STATUS_PUBLISHED,
|
||||
'processing_stage' => UploadBatchItem::STAGE_FINALIZED,
|
||||
'is_ready_to_publish' => false,
|
||||
'published_at' => now(),
|
||||
])->save();
|
||||
}
|
||||
|
||||
private function deleteItem(UploadBatchItem $item): void
|
||||
{
|
||||
if ($item->artwork && ! $item->artwork->trashed()) {
|
||||
$item->artwork->delete();
|
||||
}
|
||||
|
||||
$item->forceFill([
|
||||
'status' => UploadBatchItem::STATUS_DELETED,
|
||||
'processing_stage' => UploadBatchItem::STAGE_FINALIZED,
|
||||
'is_ready_to_publish' => false,
|
||||
])->save();
|
||||
}
|
||||
|
||||
private function applyCategory(UploadBatchItem $item, int $categoryId): void
|
||||
{
|
||||
if ($categoryId <= 0 || ! $item->artwork) {
|
||||
throw ValidationException::withMessages([
|
||||
'category_id' => ['Choose a valid category.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$item->artwork->categories()->sync([$categoryId]);
|
||||
$this->refreshItem((int) $item->id);
|
||||
}
|
||||
|
||||
private function applyTags(UploadBatchItem $item, array $tags): void
|
||||
{
|
||||
if (! $item->artwork) {
|
||||
throw ValidationException::withMessages([
|
||||
'tags' => ['Artwork not found for this queue item.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$normalizedTags = collect($tags)
|
||||
->map(fn (mixed $tag): string => trim((string) $tag))
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
if ($normalizedTags->isEmpty()) {
|
||||
throw ValidationException::withMessages([
|
||||
'tags' => ['Enter at least one tag to apply.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$mergedTags = collect($item->artwork->tags()->pluck('tags.slug')->all())
|
||||
->merge($normalizedTags)
|
||||
->map(fn (string $tag): string => trim($tag))
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$this->tags->syncStudioTags($item->artwork->fresh(['tags']) ?? $item->artwork, $mergedTags);
|
||||
$this->refreshItem((int) $item->id);
|
||||
}
|
||||
|
||||
private function setVisibility(UploadBatchItem $item, string $visibility): void
|
||||
{
|
||||
if (! in_array($visibility, [Artwork::VISIBILITY_PUBLIC, Artwork::VISIBILITY_UNLISTED, Artwork::VISIBILITY_PRIVATE], true) || ! $item->artwork) {
|
||||
throw ValidationException::withMessages([
|
||||
'visibility' => ['Choose a valid visibility.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$item->artwork->forceFill([
|
||||
'visibility' => $visibility,
|
||||
'is_public' => false,
|
||||
])->saveQuietly();
|
||||
|
||||
$this->refreshItem((int) $item->id);
|
||||
}
|
||||
|
||||
private function ownedItems(User $user, array $itemIds): EloquentCollection
|
||||
{
|
||||
$normalizedIds = collect($itemIds)
|
||||
->map(fn (mixed $id): int => (int) $id)
|
||||
->filter(fn (int $id): bool => $id > 0)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return $this->itemQuery()
|
||||
->where('user_id', (int) $user->id)
|
||||
->whereIn('id', $normalizedIds)
|
||||
->get();
|
||||
}
|
||||
|
||||
private function itemQuery()
|
||||
{
|
||||
return UploadBatchItem::query()->with(['artwork.categories.contentType', 'artwork.tags']);
|
||||
}
|
||||
|
||||
private function applyFiltersAndSorting(Collection $items, string $statusFilter, string $sort): Collection
|
||||
{
|
||||
$filtered = $statusFilter === 'all'
|
||||
? $items
|
||||
: $items->filter(fn (array $item): bool => (string) ($item['status'] ?? '') === $statusFilter);
|
||||
|
||||
return match ($sort) {
|
||||
'oldest' => $filtered->sortBy('created_at'),
|
||||
'filename' => $filtered->sortBy(fn (array $item): string => Str::lower((string) ($item['original_filename'] ?? ''))),
|
||||
'status' => $filtered->sortBy(fn (array $item): string => Str::lower((string) ($item['status'] ?? ''))),
|
||||
'ready' => $filtered->sortByDesc(fn (array $item): int => (int) ($item['is_ready_to_publish'] ?? false)),
|
||||
'failed' => $filtered->sortByDesc(fn (array $item): int => (int) ((string) ($item['status'] ?? '') === UploadBatchItem::STATUS_FAILED)),
|
||||
default => $filtered->sortByDesc('created_at'),
|
||||
};
|
||||
}
|
||||
|
||||
private function summarizeItems(EloquentCollection $items): array
|
||||
{
|
||||
$statusCounts = $items->groupBy('status')->map->count();
|
||||
$activeCount = (int) ($statusCounts[UploadBatchItem::STATUS_UPLOADED] ?? 0)
|
||||
+ (int) ($statusCounts[UploadBatchItem::STATUS_PROCESSING] ?? 0);
|
||||
$failedCount = (int) ($statusCounts[UploadBatchItem::STATUS_FAILED] ?? 0);
|
||||
$publishedCount = (int) ($statusCounts[UploadBatchItem::STATUS_PUBLISHED] ?? 0);
|
||||
$processedCount = $items->filter(fn (UploadBatchItem $item): bool => in_array((string) $item->status, [
|
||||
UploadBatchItem::STATUS_NEEDS_METADATA,
|
||||
UploadBatchItem::STATUS_NEEDS_REVIEW,
|
||||
UploadBatchItem::STATUS_READY,
|
||||
UploadBatchItem::STATUS_FAILED,
|
||||
UploadBatchItem::STATUS_PUBLISHED,
|
||||
UploadBatchItem::STATUS_DELETED,
|
||||
], true))->count();
|
||||
|
||||
$status = UploadBatch::STATUS_COMPLETED;
|
||||
if ($activeCount > 0) {
|
||||
$status = $statusCounts[UploadBatchItem::STATUS_PROCESSING] ?? 0
|
||||
? UploadBatch::STATUS_PROCESSING
|
||||
: UploadBatch::STATUS_UPLOADING;
|
||||
} elseif ($failedCount > 0) {
|
||||
$status = UploadBatch::STATUS_COMPLETED_WITH_ERRORS;
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'total_items' => $items->count(),
|
||||
'processed_items' => $processedCount,
|
||||
'failed_items' => $failedCount,
|
||||
'published_items' => $publishedCount,
|
||||
'ready_items' => (int) ($statusCounts[UploadBatchItem::STATUS_READY] ?? 0),
|
||||
'processing_items' => $activeCount,
|
||||
'needs_metadata_items' => (int) ($statusCounts[UploadBatchItem::STATUS_NEEDS_METADATA] ?? 0),
|
||||
'needs_review_items' => (int) ($statusCounts[UploadBatchItem::STATUS_NEEDS_REVIEW] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
private function evaluateItemState(UploadBatchItem $item): array
|
||||
{
|
||||
$artwork = $item->artwork;
|
||||
$isDeleted = (string) $item->status === UploadBatchItem::STATUS_DELETED || ($artwork?->trashed() ?? false);
|
||||
$isPublished = $artwork
|
||||
&& (string) ($artwork->artwork_status ?? '') === 'published'
|
||||
&& $artwork->published_at !== null;
|
||||
$hasProcessedMedia = $artwork
|
||||
&& trim((string) ($artwork->file_name ?? '')) !== ''
|
||||
&& trim((string) ($artwork->file_name ?? '')) !== 'pending'
|
||||
&& trim((string) ($artwork->file_path ?? '')) !== ''
|
||||
&& trim((string) ($artwork->hash ?? '')) !== '';
|
||||
$title = trim((string) ($artwork?->title ?? ''));
|
||||
$hasTitle = $title !== '';
|
||||
$hasCategory = (bool) $artwork?->categories?->first();
|
||||
$maturityStatus = Str::lower((string) ($artwork?->maturity_status ?? ArtworkMaturityService::STATUS_CLEAR));
|
||||
$maturityAiStatus = Str::lower((string) ($artwork?->maturity_ai_status ?? ArtworkMaturityService::AI_STATUS_NOT_REQUESTED));
|
||||
$aiStatus = Str::lower((string) ($artwork?->ai_status ?? ''));
|
||||
$visionEnabled = (bool) config('vision.enabled', true);
|
||||
|
||||
$maturityPending = $visionEnabled && in_array($maturityAiStatus, [
|
||||
ArtworkMaturityService::AI_STATUS_PENDING,
|
||||
ArtworkMaturityService::AI_STATUS_NOT_REQUESTED,
|
||||
], true);
|
||||
$maturityFailed = $visionEnabled && $maturityAiStatus === ArtworkMaturityService::AI_STATUS_FAILED;
|
||||
$needsReview = $maturityStatus === ArtworkMaturityService::STATUS_SUSPECTED || $maturityFailed;
|
||||
$needsMetadata = ! $hasTitle || ! $hasCategory;
|
||||
$blockingUploadFailure = ! $hasProcessedMedia && ($this->nullableString($item->error_code) !== null || $this->nullableText($item->error_message) !== null);
|
||||
$isReadyToPublish = ! $isDeleted
|
||||
&& ! $isPublished
|
||||
&& ! $blockingUploadFailure
|
||||
&& $hasProcessedMedia
|
||||
&& ! $maturityPending
|
||||
&& ! $needsReview
|
||||
&& ! $needsMetadata
|
||||
&& $artwork
|
||||
&& (int) $artwork->user_id === (int) $item->user_id;
|
||||
|
||||
$status = match (true) {
|
||||
$isDeleted => UploadBatchItem::STATUS_DELETED,
|
||||
$isPublished => UploadBatchItem::STATUS_PUBLISHED,
|
||||
$blockingUploadFailure => UploadBatchItem::STATUS_FAILED,
|
||||
! $hasProcessedMedia || $maturityPending => UploadBatchItem::STATUS_PROCESSING,
|
||||
$needsReview => UploadBatchItem::STATUS_NEEDS_REVIEW,
|
||||
$needsMetadata => UploadBatchItem::STATUS_NEEDS_METADATA,
|
||||
$isReadyToPublish => UploadBatchItem::STATUS_READY,
|
||||
default => UploadBatchItem::STATUS_PROCESSING,
|
||||
};
|
||||
|
||||
$processingStage = match (true) {
|
||||
in_array($status, [UploadBatchItem::STATUS_DELETED, UploadBatchItem::STATUS_PUBLISHED, UploadBatchItem::STATUS_FAILED], true) => UploadBatchItem::STAGE_FINALIZED,
|
||||
! $hasProcessedMedia && (string) $item->processing_stage === UploadBatchItem::STAGE_QUEUED => UploadBatchItem::STAGE_QUEUED,
|
||||
! $hasProcessedMedia => UploadBatchItem::STAGE_THUMBNAILS,
|
||||
$maturityPending => UploadBatchItem::STAGE_MATURITY_CHECK,
|
||||
in_array($aiStatus, ['queued', 'processing'], true) => UploadBatchItem::STAGE_METADATA_SUGGESTIONS,
|
||||
default => UploadBatchItem::STAGE_FINALIZED,
|
||||
};
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'processing_stage' => $processingStage,
|
||||
'metadata_completeness' => $this->metadataCompleteness($artwork, $hasProcessedMedia, ! $maturityPending && ! $maturityFailed),
|
||||
'is_ready_to_publish' => $isReadyToPublish,
|
||||
'published_at' => $isPublished ? ($item->published_at ?: now()) : null,
|
||||
'processed_at' => $hasProcessedMedia ? ($item->processed_at ?: now()) : $item->processed_at,
|
||||
];
|
||||
}
|
||||
|
||||
private function presentBatch(UploadBatch $batch): array
|
||||
{
|
||||
$summary = $this->summarizeItems($batch->items);
|
||||
|
||||
return [
|
||||
'id' => (int) $batch->id,
|
||||
'name' => $batch->name,
|
||||
'status' => $summary['status'],
|
||||
'total_items' => $summary['total_items'],
|
||||
'processed_items' => $summary['processed_items'],
|
||||
'failed_items' => $summary['failed_items'],
|
||||
'published_items' => $summary['published_items'],
|
||||
'ready_items' => $summary['ready_items'],
|
||||
'processing_items' => $summary['processing_items'],
|
||||
'needs_metadata_items' => $summary['needs_metadata_items'],
|
||||
'needs_review_items' => $summary['needs_review_items'],
|
||||
'defaults' => $batch->defaults_json ?? [],
|
||||
'created_at' => $this->iso($batch->created_at),
|
||||
'updated_at' => $this->iso($batch->updated_at),
|
||||
];
|
||||
}
|
||||
|
||||
private function presentItem(UploadBatchItem $item): array
|
||||
{
|
||||
$artwork = $item->artwork;
|
||||
$state = $this->evaluateItemState($item);
|
||||
$maturityStatus = Str::lower((string) ($artwork?->maturity_status ?? ArtworkMaturityService::STATUS_CLEAR));
|
||||
$maturityAiStatus = Str::lower((string) ($artwork?->maturity_ai_status ?? ArtworkMaturityService::AI_STATUS_NOT_REQUESTED));
|
||||
$missing = [];
|
||||
|
||||
if (! $artwork || trim((string) ($artwork->file_path ?? '')) === '' || trim((string) ($artwork->hash ?? '')) === '') {
|
||||
$missing[] = 'Processing incomplete';
|
||||
}
|
||||
if (! $artwork || trim((string) ($artwork->title ?? '')) === '') {
|
||||
$missing[] = 'Missing title';
|
||||
}
|
||||
if (! $artwork?->categories?->first()) {
|
||||
$missing[] = 'Missing category';
|
||||
}
|
||||
if ($maturityStatus === ArtworkMaturityService::STATUS_SUSPECTED) {
|
||||
$missing[] = 'Needs maturity review';
|
||||
} elseif ((bool) config('vision.enabled', true) && in_array($maturityAiStatus, [ArtworkMaturityService::AI_STATUS_PENDING, ArtworkMaturityService::AI_STATUS_NOT_REQUESTED], true)) {
|
||||
$missing[] = 'Maturity analysis pending';
|
||||
} elseif ((bool) config('vision.enabled', true) && $maturityAiStatus === ArtworkMaturityService::AI_STATUS_FAILED) {
|
||||
$missing[] = 'Maturity check failed';
|
||||
}
|
||||
|
||||
$canRetry = ! empty($artwork?->hash)
|
||||
&& ! empty($artwork?->file_path)
|
||||
&& ($state['status'] === UploadBatchItem::STATUS_FAILED || $maturityAiStatus === ArtworkMaturityService::AI_STATUS_FAILED);
|
||||
|
||||
return [
|
||||
'id' => (int) $item->id,
|
||||
'batch_id' => (int) $item->upload_batch_id,
|
||||
'artwork_id' => $artwork?->id,
|
||||
'title' => $artwork?->title ?: $this->titleFromFilename((string) $item->original_filename),
|
||||
'original_filename' => $item->original_filename,
|
||||
'status' => $state['status'],
|
||||
'processing_stage' => $state['processing_stage'],
|
||||
'metadata_completeness' => $state['metadata_completeness'],
|
||||
'metadata_label' => $state['metadata_completeness'] . '% complete',
|
||||
'is_ready_to_publish' => $state['is_ready_to_publish'],
|
||||
'error_code' => $item->error_code,
|
||||
'error_message' => $item->error_message,
|
||||
'missing' => $missing,
|
||||
'thumbnail_url' => $artwork?->thumbUrl('sm'),
|
||||
'visibility' => $artwork?->visibility,
|
||||
'maturity_status' => $maturityStatus,
|
||||
'maturity_ai_status' => $maturityAiStatus,
|
||||
'ai_status' => Str::lower((string) ($artwork?->ai_status ?? '')) ?: null,
|
||||
'created_at' => $this->iso($item->created_at),
|
||||
'updated_at' => $this->iso($item->updated_at),
|
||||
'published_at' => $this->iso($item->published_at),
|
||||
'edit_url' => $artwork ? route('studio.artworks.edit', ['id' => $artwork->id]) : null,
|
||||
'public_url' => $artwork && $artwork->published_at ? route('art.show', ['id' => $artwork->id]) : null,
|
||||
'actions' => [
|
||||
'can_edit' => $artwork !== null && $state['status'] !== UploadBatchItem::STATUS_DELETED,
|
||||
'can_publish' => $state['is_ready_to_publish'],
|
||||
'can_delete' => ! in_array($state['status'], [UploadBatchItem::STATUS_DELETED, UploadBatchItem::STATUS_PUBLISHED], true),
|
||||
'can_retry_processing' => $canRetry,
|
||||
'can_generate_ai' => $artwork !== null && trim((string) ($artwork->hash ?? '')) !== '',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function metadataCompleteness(?Artwork $artwork, bool $hasProcessedMedia, bool $maturityReady): int
|
||||
{
|
||||
$checks = [
|
||||
$hasProcessedMedia,
|
||||
trim((string) ($artwork?->title ?? '')) !== '',
|
||||
(bool) $artwork?->categories?->first(),
|
||||
$maturityReady,
|
||||
];
|
||||
|
||||
return (int) round((collect($checks)->filter()->count() / count($checks)) * 100);
|
||||
}
|
||||
|
||||
private function normalizeDefaults(array $defaults): array
|
||||
{
|
||||
$visibility = (string) ($defaults['visibility'] ?? Artwork::VISIBILITY_PUBLIC);
|
||||
if (! in_array($visibility, [Artwork::VISIBILITY_PUBLIC, Artwork::VISIBILITY_UNLISTED, Artwork::VISIBILITY_PRIVATE], true)) {
|
||||
$visibility = Artwork::VISIBILITY_PUBLIC;
|
||||
}
|
||||
|
||||
return array_filter([
|
||||
'category_id' => ($categoryId = (int) ($defaults['category_id'] ?? 0)) > 0 ? $categoryId : null,
|
||||
'tags' => collect((array) ($defaults['tags'] ?? []))
|
||||
->map(fn (mixed $tag): string => trim((string) $tag))
|
||||
->filter()
|
||||
->values()
|
||||
->all(),
|
||||
'visibility' => $visibility,
|
||||
'is_mature' => (bool) ($defaults['is_mature'] ?? false),
|
||||
'group' => $this->nullableString($defaults['group'] ?? null),
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== []);
|
||||
}
|
||||
|
||||
private function titleFromFilename(string $filename): string
|
||||
{
|
||||
$base = pathinfo($filename, PATHINFO_FILENAME);
|
||||
$normalized = Str::of($base)
|
||||
->replace(['_', '-'], ' ')
|
||||
->squish()
|
||||
->trim();
|
||||
|
||||
return (string) ($normalized !== '' ? Str::limit((string) $normalized, 255, '') : 'Untitled artwork');
|
||||
}
|
||||
|
||||
private function normalizeBatchName(?string $name, int $count): string
|
||||
{
|
||||
$normalized = trim((string) $name);
|
||||
if ($normalized !== '') {
|
||||
return Str::limit($normalized, 160, '');
|
||||
}
|
||||
|
||||
return 'Upload Queue ' . now()->format('M j, Y g:i A') . ' (' . $count . ')';
|
||||
}
|
||||
|
||||
private function iso(CarbonInterface|string|null $value): ?string
|
||||
{
|
||||
if ($value instanceof CarbonInterface) {
|
||||
return $value->toIso8601String();
|
||||
}
|
||||
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function nullableString(mixed $value): ?string
|
||||
{
|
||||
$normalized = trim((string) $value);
|
||||
|
||||
return $normalized === '' ? null : $normalized;
|
||||
}
|
||||
|
||||
private function nullableText(mixed $value): ?string
|
||||
{
|
||||
$normalized = trim((string) $value);
|
||||
|
||||
return $normalized === '' ? null : Str::limit($normalized, 65535, '');
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,9 @@ namespace App\Services\Vision;
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class AiArtworkVectorSearchService
|
||||
{
|
||||
@@ -35,12 +36,7 @@ final class AiArtworkVectorSearchService
|
||||
$ttl = max(60, (int) config('recommendations.ttl.similar_artworks', 30 * 60));
|
||||
|
||||
return Cache::remember($cacheKey, $ttl, function () use ($artwork, $safeLimit): array {
|
||||
$url = $this->imageUrl->fromArtwork($artwork);
|
||||
if ($url === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$matches = $this->client->searchByUrl($url, $safeLimit + 1);
|
||||
$matches = $this->searchMatchesForArtwork($artwork, $safeLimit + 1);
|
||||
|
||||
return $this->resolveMatches($matches, $safeLimit, $artwork->id);
|
||||
});
|
||||
@@ -52,27 +48,80 @@ final class AiArtworkVectorSearchService
|
||||
public function searchByUploadedImage(UploadedFile $file, int $limit = 12): array
|
||||
{
|
||||
$safeLimit = max(1, min(self::MAX_SIMILAR_RESULTS, $limit));
|
||||
$path = $file->store('ai-search/tmp', 'public');
|
||||
$matches = $this->client->searchByUploadedFile($file, $safeLimit);
|
||||
|
||||
if (! is_string($path) || $path === '') {
|
||||
throw new RuntimeException('Unable to persist uploaded image for vector search.');
|
||||
return $this->resolveMatches($matches, $safeLimit);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{contents: string, filename: string}|null
|
||||
*/
|
||||
private function downloadArtworkImage(Artwork $artwork, string $url): ?array
|
||||
{
|
||||
$response = Http::accept('*/*')
|
||||
->connectTimeout(5)
|
||||
->timeout(20)
|
||||
->retry(1, 200, throw: false)
|
||||
->get($url);
|
||||
|
||||
if (! $response->ok()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$publicBaseUrl = rtrim((string) config('filesystems.disks.public.url', ''), '/');
|
||||
if ($publicBaseUrl === '') {
|
||||
Storage::disk('public')->delete($path);
|
||||
throw new RuntimeException('Public disk URL is not configured for vector search uploads.');
|
||||
$contents = $response->body();
|
||||
if ($contents === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = $publicBaseUrl . '/' . ltrim($path, '/');
|
||||
$ext = strtolower(ltrim((string) ($artwork->thumb_ext ?: 'webp'), '.'));
|
||||
|
||||
return [
|
||||
'contents' => $contents,
|
||||
'filename' => sprintf('artwork-%d.%s', (int) $artwork->id, $ext !== '' ? $ext : 'webp'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id: int|string, score: float, metadata: array<string, mixed>}>
|
||||
*/
|
||||
private function searchMatchesForArtwork(Artwork $artwork, int $limit): array
|
||||
{
|
||||
$url = $this->imageUrl->fromArtwork($artwork);
|
||||
if ($url === null || $url === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$fileFailure = null;
|
||||
|
||||
try {
|
||||
$matches = $this->client->searchByUrl($url, $safeLimit);
|
||||
|
||||
return $this->resolveMatches($matches, $safeLimit);
|
||||
} finally {
|
||||
Storage::disk('public')->delete($path);
|
||||
$payload = $this->downloadArtworkImage($artwork, $url);
|
||||
if ($payload !== null) {
|
||||
return $this->client->searchByFileContents($payload['contents'], $payload['filename'], $limit);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$fileFailure = $e;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->client->searchByUrl($url, $limit);
|
||||
} catch (Throwable $e) {
|
||||
throw $this->normalizeSearchFailure($fileFailure, $e);
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeSearchFailure(?Throwable $fileFailure, Throwable $fallbackFailure): RuntimeException
|
||||
{
|
||||
if ($fileFailure === null) {
|
||||
return $fallbackFailure instanceof RuntimeException
|
||||
? $fallbackFailure
|
||||
: new RuntimeException($fallbackFailure->getMessage(), 0, $fallbackFailure);
|
||||
}
|
||||
|
||||
return new RuntimeException(sprintf(
|
||||
'Vector search failed via file endpoint (%s) and URL fallback (%s).',
|
||||
$fileFailure->getMessage(),
|
||||
$fallbackFailure->getMessage(),
|
||||
), 0, $fallbackFailure);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,7 +175,7 @@ final class AiArtworkVectorSearchService
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'thumb' => $artwork->thumbUrl('md'),
|
||||
'thumb' => $artwork->thumbUrl('sm'),
|
||||
'url' => '/art/' . $artwork->id . '/' . $artwork->slug,
|
||||
'author' => $artwork->user?->name ?? 'Artist',
|
||||
'author_avatar' => $artwork->user?->profile?->avatar_url,
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Services\Vision;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
final class ArtworkVectorIndexService
|
||||
@@ -44,7 +45,12 @@ final class ArtworkVectorIndexService
|
||||
{
|
||||
$payload = $this->payloadForArtwork($artwork);
|
||||
|
||||
$this->client->upsertByUrl($payload['url'], (int) $artwork->id, $payload['metadata']);
|
||||
$filePayload = $this->downloadArtworkImage($artwork, $payload['url']);
|
||||
if ($filePayload === null) {
|
||||
throw new RuntimeException('Unable to download artwork image bytes for vector upsert for artwork ' . (int) $artwork->id . '.');
|
||||
}
|
||||
|
||||
$this->client->upsertByFileContents($filePayload['contents'], $filePayload['filename'], (int) $artwork->id, $payload['metadata']);
|
||||
|
||||
$artwork->forceFill([
|
||||
'last_vector_indexed_at' => now(),
|
||||
@@ -52,4 +58,32 @@ final class ArtworkVectorIndexService
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{contents: string, filename: string}|null
|
||||
*/
|
||||
private function downloadArtworkImage(Artwork $artwork, string $url): ?array
|
||||
{
|
||||
$response = Http::accept('*/*')
|
||||
->connectTimeout(5)
|
||||
->timeout(20)
|
||||
->retry(1, 200, throw: false)
|
||||
->get($url);
|
||||
|
||||
if (! $response->ok()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$contents = $response->body();
|
||||
if ($contents === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ext = strtolower(ltrim((string) ($artwork->thumb_ext ?: 'webp'), '.'));
|
||||
|
||||
return [
|
||||
'contents' => $contents,
|
||||
'filename' => sprintf('artwork-%d.%s', (int) $artwork->id, $ext !== '' ? $ext : 'webp'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,12 @@ use App\Services\ThumbnailService;
|
||||
|
||||
final class ArtworkVisionImageUrl
|
||||
{
|
||||
public function fromArtwork(Artwork $artwork): ?string
|
||||
public function fromArtwork(Artwork $artwork, ?string $size = null): ?string
|
||||
{
|
||||
return $this->fromHash(
|
||||
(string) ($artwork->hash ?? ''),
|
||||
(string) ($artwork->thumb_ext ?: 'webp')
|
||||
(string) ($artwork->thumb_ext ?: 'webp'),
|
||||
$size ?? (string) config('vision.image_variant', 'md')
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Vision;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
@@ -38,6 +39,27 @@ final class VectorGatewayClient
|
||||
return is_array($json) ? $json : [];
|
||||
}
|
||||
|
||||
public function upsertByFileContents(string $contents, string $filename, int|string $id, array $metadata = []): array
|
||||
{
|
||||
$response = $this->request()
|
||||
->attach('file', $contents, $filename)
|
||||
->post(
|
||||
$this->url((string) config('vision.vector_gateway.upsert_file_endpoint', '/vectors/upsert/file')),
|
||||
[
|
||||
'id' => (string) $id,
|
||||
'metadata_json' => $metadata === [] ? null : json_encode($metadata, JSON_THROW_ON_ERROR),
|
||||
]
|
||||
);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException($this->failureMessage('Vector upsert', $response));
|
||||
}
|
||||
|
||||
$json = $response->json();
|
||||
|
||||
return is_array($json) ? $json : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id: int|string, score: float, metadata: array<string, mixed>}>
|
||||
*/
|
||||
@@ -58,6 +80,45 @@ final class VectorGatewayClient
|
||||
return $this->extractMatches($response->json());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id: int|string, score: float, metadata: array<string, mixed>}>
|
||||
*/
|
||||
public function searchByFileContents(string $contents, string $filename, int $limit = 5): array
|
||||
{
|
||||
$response = $this->request()
|
||||
->attach('file', $contents, $filename)
|
||||
->post(
|
||||
$this->url((string) config('vision.vector_gateway.search_file_endpoint', '/vectors/search/file')),
|
||||
[
|
||||
'limit' => max(1, $limit),
|
||||
]
|
||||
);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException($this->failureMessage('Vector search', $response));
|
||||
}
|
||||
|
||||
return $this->extractMatches($response->json());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id: int|string, score: float, metadata: array<string, mixed>}>
|
||||
*/
|
||||
public function searchByUploadedFile(UploadedFile $file, int $limit = 5): array
|
||||
{
|
||||
$realPath = $file->getRealPath();
|
||||
if (! is_string($realPath) || $realPath === '') {
|
||||
throw new RuntimeException('Uploaded file has no readable temporary path for vector search.');
|
||||
}
|
||||
|
||||
$contents = file_get_contents($realPath);
|
||||
if (! is_string($contents) || $contents === '') {
|
||||
throw new RuntimeException('Unable to read uploaded image bytes for vector search.');
|
||||
}
|
||||
|
||||
return $this->searchByFileContents($contents, $file->getClientOriginalName() ?: 'search-image', $limit);
|
||||
}
|
||||
|
||||
public function deleteByIds(array $ids): array
|
||||
{
|
||||
$response = $this->postJson(
|
||||
|
||||
Reference in New Issue
Block a user