Wire admin studio SSR and search infrastructure

This commit is contained in:
2026-05-01 11:46:06 +02:00
parent 257b0dbef6
commit 18cea8b0f0
329 changed files with 197465 additions and 2741 deletions

View File

@@ -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,
],

View 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;
}
}

View File

@@ -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,
];
}
}

View File

@@ -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()) {

View File

@@ -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.
*

View File

@@ -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

View 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();
}
}

View File

@@ -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'];
}

View File

@@ -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),
];
}
}

View File

@@ -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);
}
}

View File

@@ -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' => [

View File

@@ -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';

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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 (0100 %) 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 (10100 %) 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 (0100); fall back to sensible defaults.
// pos_x / pos_y are stored as percentages (0100); 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));

View File

@@ -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();

View File

@@ -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');

View File

@@ -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();

View File

@@ -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';
}
}

View File

@@ -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]

View File

@@ -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];
});
}

View File

@@ -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

View 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');
}
}

View File

@@ -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)

View File

@@ -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(),
];
}

View File

@@ -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

View File

@@ -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);
}
}
}

View File

@@ -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'),
]);
}
/**

View File

@@ -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();

View File

@@ -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)) {

View File

@@ -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

View 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, '');
}
}

View File

@@ -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,

View File

@@ -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'),
];
}
}

View File

@@ -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')
);
}

View File

@@ -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(