Allow artworks to have one primary category and extra secondaries.
Keep artwork_category as membership, store a deterministic primary_category_id, and let upload plus studio pick up to five additional same-type categories without changing canonical URLs.
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Artworks;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Services\ArtworkSearchIndexer;
|
||||
use App\Services\ArtworkService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class ArtworkCategoryService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ArtworkSearchIndexer $searchIndexer,
|
||||
private readonly ArtworkService $artworkService,
|
||||
) {}
|
||||
|
||||
public function maxSecondary(): int
|
||||
{
|
||||
return max(0, (int) config('categories.max_secondary_categories', 5));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int|string>|null $secondaryCategoryIds null keeps existing secondaries that remain compatible
|
||||
*/
|
||||
public function sync(
|
||||
Artwork $artwork,
|
||||
?int $primaryCategoryId,
|
||||
?array $secondaryCategoryIds = [],
|
||||
?string $source = null,
|
||||
bool $reindex = true,
|
||||
): void {
|
||||
$primaryCategoryId = $primaryCategoryId !== null && $primaryCategoryId > 0 ? $primaryCategoryId : null;
|
||||
$secondaryCategoryIds = $secondaryCategoryIds === null
|
||||
? $this->existingSecondaryIds($artwork, $primaryCategoryId)
|
||||
: $this->normalizeIds($secondaryCategoryIds);
|
||||
|
||||
if ($primaryCategoryId === null) {
|
||||
if ($secondaryCategoryIds !== []) {
|
||||
throw ValidationException::withMessages([
|
||||
'primary_category_id' => ['A primary category is required when additional categories are selected.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$this->persist($artwork, null, [], $source, $reindex);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$secondaryCategoryIds = array_values(array_filter(
|
||||
$secondaryCategoryIds,
|
||||
static fn (int $id): bool => $id !== $primaryCategoryId,
|
||||
));
|
||||
|
||||
if (count($secondaryCategoryIds) > $this->maxSecondary()) {
|
||||
throw ValidationException::withMessages([
|
||||
'secondary_category_ids' => [sprintf('You can add at most %d additional categories.', $this->maxSecondary())],
|
||||
]);
|
||||
}
|
||||
|
||||
$requestedIds = array_values(array_unique([$primaryCategoryId, ...$secondaryCategoryIds]));
|
||||
$categories = Category::query()
|
||||
->whereIn('id', $requestedIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
if ($categories->count() !== count($requestedIds)) {
|
||||
throw ValidationException::withMessages([
|
||||
'primary_category_id' => ['One or more selected categories do not exist.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$primary = $categories->get($primaryCategoryId);
|
||||
if (! $primary) {
|
||||
throw ValidationException::withMessages([
|
||||
'primary_category_id' => ['Choose a valid primary category.'],
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $primary->is_active) {
|
||||
throw ValidationException::withMessages([
|
||||
'primary_category_id' => ['The primary category is not available.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$contentTypeId = (int) $primary->content_type_id;
|
||||
|
||||
foreach ($secondaryCategoryIds as $secondaryId) {
|
||||
$category = $categories->get($secondaryId);
|
||||
if (! $category || ! $category->is_active) {
|
||||
throw ValidationException::withMessages([
|
||||
'secondary_category_ids' => ['One or more additional categories are not available.'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ((int) $category->content_type_id !== $contentTypeId) {
|
||||
throw ValidationException::withMessages([
|
||||
'secondary_category_ids' => ['Additional categories must belong to the same content type as the primary category.'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->persist($artwork, $primaryCategoryId, $secondaryCategoryIds, $source, $reindex);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function presentCategory(Category $category, bool $isPrimary): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $category->id,
|
||||
'name' => html_entity_decode((string) $category->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'slug' => (string) $category->slug,
|
||||
'content_type_slug' => (string) ($category->contentType?->slug ?? ''),
|
||||
'url' => $category->contentType ? $category->url : null,
|
||||
'is_primary' => $isPrimary,
|
||||
'parent' => $category->parent ? [
|
||||
'id' => (int) $category->parent->id,
|
||||
'slug' => (string) $category->parent->slug,
|
||||
'name' => html_entity_decode((string) $category->parent->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'content_type_slug' => (string) ($category->parent->contentType?->slug ?? ''),
|
||||
'url' => $category->parent->contentType ? $category->parent->url : null,
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int|string> $ids
|
||||
* @return list<int>
|
||||
*/
|
||||
private function normalizeIds(array $ids): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($ids as $id) {
|
||||
$value = (int) $id;
|
||||
if ($value > 0 && ! in_array($value, $normalized, true)) {
|
||||
$normalized[] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
private function existingSecondaryIds(Artwork $artwork, ?int $primaryCategoryId): array
|
||||
{
|
||||
return $artwork->categories()
|
||||
->pluck('categories.id')
|
||||
->map(static fn ($id): int => (int) $id)
|
||||
->reject(static fn (int $id): bool => $primaryCategoryId !== null && $id === $primaryCategoryId)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $secondaryCategoryIds
|
||||
*/
|
||||
private function persist(
|
||||
Artwork $artwork,
|
||||
?int $primaryCategoryId,
|
||||
array $secondaryCategoryIds,
|
||||
?string $source,
|
||||
bool $reindex,
|
||||
): void {
|
||||
$membershipIds = $primaryCategoryId === null
|
||||
? []
|
||||
: array_values(array_unique([$primaryCategoryId, ...$secondaryCategoryIds]));
|
||||
|
||||
DB::transaction(function () use ($artwork, $primaryCategoryId, $membershipIds, $source): void {
|
||||
$artwork->categories()->sync($membershipIds);
|
||||
$artwork->primary_category_id = $primaryCategoryId;
|
||||
|
||||
if ($source !== null) {
|
||||
$artwork->category_source = $source;
|
||||
}
|
||||
|
||||
$artwork->save();
|
||||
});
|
||||
|
||||
$artwork->unsetRelation('categories');
|
||||
$artwork->unsetRelation('primaryCategory');
|
||||
|
||||
if (! $reindex) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::afterCommit(function () use ($artwork): void {
|
||||
$this->artworkService->clearArtworkCache($artwork->fresh() ?? $artwork);
|
||||
Cache::forget('categories.directory.v1');
|
||||
$this->searchIndexer->update($artwork);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,16 @@ final class ArtworkDraftService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GroupService $groups,
|
||||
private readonly ArtworkCategoryService $categories,
|
||||
) {
|
||||
}
|
||||
|
||||
public function createDraft(User $user, string $title, ?string $description, ?int $categoryId = null, bool $isMature = false, string|int|null $groupIdentifier = null): ArtworkDraftResult
|
||||
/**
|
||||
* @param list<int> $secondaryCategoryIds
|
||||
*/
|
||||
public function createDraft(User $user, string $title, ?string $description, ?int $categoryId = null, bool $isMature = false, string|int|null $groupIdentifier = null, array $secondaryCategoryIds = []): ArtworkDraftResult
|
||||
{
|
||||
return DB::transaction(function () use ($user, $title, $description, $categoryId, $isMature, $groupIdentifier) {
|
||||
return DB::transaction(function () use ($user, $title, $description, $categoryId, $isMature, $groupIdentifier, $secondaryCategoryIds) {
|
||||
$slug = $this->makeSlug($title);
|
||||
$group = $this->resolveGroup($user, $groupIdentifier);
|
||||
|
||||
@@ -50,9 +54,8 @@ final class ArtworkDraftService
|
||||
'artwork_status' => 'draft',
|
||||
]);
|
||||
|
||||
// Attach the selected category to the artwork pivot table
|
||||
if ($categoryId !== null && \App\Models\Category::where('id', $categoryId)->exists()) {
|
||||
$artwork->categories()->sync([$categoryId]);
|
||||
if ($categoryId !== null || $secondaryCategoryIds !== []) {
|
||||
$this->categories->sync($artwork, $categoryId, $secondaryCategoryIds, 'user', false);
|
||||
}
|
||||
|
||||
if ($group) {
|
||||
|
||||
@@ -140,7 +140,7 @@ class CollectionAiCurationService
|
||||
->first();
|
||||
|
||||
$label = $tag?->name
|
||||
?: ($artwork->categories->first()?->name)
|
||||
?: ($artwork->resolvedPrimaryCategory()?->name)
|
||||
?: ($artwork->stats?->views ? 'Popular highlights' : 'Curated picks');
|
||||
|
||||
if (! isset($themeBuckets[$label])) {
|
||||
@@ -673,7 +673,7 @@ class CollectionAiCurationService
|
||||
$artworks = $this->candidateArtworks($collection, $draft, 36);
|
||||
$themes = $this->topThemes($artworks);
|
||||
$categories = $artworks
|
||||
->map(fn (Artwork $artwork) => $artwork->categories->first()?->name)
|
||||
->map(fn (Artwork $artwork) => $artwork->resolvedPrimaryCategory()?->name)
|
||||
->filter()
|
||||
->countBy()
|
||||
->sortDesc();
|
||||
|
||||
@@ -95,7 +95,7 @@ class CollectionLinkService
|
||||
'label' => (string) $artwork->title,
|
||||
'description' => collect([
|
||||
$artwork->user?->username ? '@' . strtolower((string) $artwork->user->username) : null,
|
||||
$artwork->categories->first()?->contentType?->name,
|
||||
$artwork->resolvedPrimaryCategory()?->contentType?->name,
|
||||
])->filter()->join(' • ') ?: 'Published artwork',
|
||||
])
|
||||
->values()
|
||||
@@ -343,7 +343,7 @@ class CollectionLinkService
|
||||
'title' => (string) $entity->title,
|
||||
'subtitle' => collect([
|
||||
$entity->user?->username ? '@' . strtolower((string) $entity->user->username) : null,
|
||||
$entity->categories->first()?->contentType?->name,
|
||||
$entity->resolvedPrimaryCategory()?->contentType?->name,
|
||||
])->filter()->join(' • ') ?: 'Artwork',
|
||||
'description' => $link->relationship_type ?: 'Linked artwork',
|
||||
'url' => route('art.show', [
|
||||
|
||||
@@ -861,6 +861,14 @@ class CollectionService
|
||||
? $collections
|
||||
: new EloquentCollection(is_array($collections) ? $collections : iterator_to_array($collections));
|
||||
|
||||
// The profile producer already eager-loads these relations. Keep the
|
||||
// mapper safe for direct callers as well, so the card loop never
|
||||
// triggers relation lazy-loading one collection at a time.
|
||||
$collectionList->loadMissing([
|
||||
'user:id,username,name',
|
||||
'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at',
|
||||
]);
|
||||
|
||||
$collectionIds = $collectionList->pluck('id')->map(static fn ($id) => (int) $id)->all();
|
||||
$hideMatureCovers = ! $ownerView && $this->viewerShouldHideMature($viewer);
|
||||
|
||||
@@ -879,12 +887,16 @@ class CollectionService
|
||||
->all()
|
||||
: [];
|
||||
|
||||
return $collectionList->map(function (Collection $collection) use ($ownerView, $viewer, $firstArtworkMap, $savedCollectionIds) {
|
||||
return $collectionList->map(function (Collection $collection) use ($ownerView, $viewer, $firstArtworkMap, $savedCollectionIds, $hideMatureCovers) {
|
||||
// Manual collections have their explicit cover eager-loaded. The
|
||||
// already bulk-loaded first artwork map is the exact fallback
|
||||
// used by resolvedCoverArtwork() when that cover is absent or not
|
||||
// visible, so no per-card relation query is needed here.
|
||||
$resolvedCover = $collection->isSmart()
|
||||
? $this->smartCollections->firstArtwork($collection, $ownerView)
|
||||
: $collection->resolvedCoverArtwork(! $ownerView, ! $ownerView && $this->viewerShouldHideMature($viewer));
|
||||
: ($collection->relationLoaded('coverArtwork') ? $collection->coverArtwork : null);
|
||||
$fallbackCover = $firstArtworkMap->get((int) $collection->id);
|
||||
$cover = $this->eligibleCoverArtwork($resolvedCover, ! $ownerView, ! $ownerView && $this->viewerShouldHideMature($viewer))
|
||||
$cover = $this->eligibleCoverArtwork($resolvedCover, ! $ownerView, $hideMatureCovers)
|
||||
? $resolvedCover
|
||||
: $fallbackCover;
|
||||
$summary = $collection->summary ?? $collection->description;
|
||||
@@ -1264,7 +1276,7 @@ class CollectionService
|
||||
|
||||
private function mapArtworkPayload(Artwork $artwork, array $extra = []): array
|
||||
{
|
||||
$category = $artwork->categories->first();
|
||||
$category = $artwork->resolvedPrimaryCategory();
|
||||
$contentType = $category?->contentType;
|
||||
$stats = $artwork->stats;
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Models\Group;
|
||||
use App\Jobs\IndexArtworkJob;
|
||||
use App\Models\Tag;
|
||||
use App\Models\User;
|
||||
use App\Services\Artworks\ArtworkCategoryService;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -23,6 +24,7 @@ class GroupArtworkReviewService
|
||||
private readonly GroupHistoryService $history,
|
||||
private readonly NotificationService $notifications,
|
||||
private readonly GroupMembershipService $memberships,
|
||||
private readonly ArtworkCategoryService $artworkCategories,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -108,6 +110,10 @@ class GroupArtworkReviewService
|
||||
'group_reviewed_at' => now(),
|
||||
'group_review_notes' => $notes,
|
||||
'is_approved' => true,
|
||||
'approval_source' => 'moderator',
|
||||
'moderated_at' => now(),
|
||||
'moderated_by' => $actor->id,
|
||||
'moderation_note' => $notes,
|
||||
'artwork_status' => 'published',
|
||||
'published_at' => now(),
|
||||
'publish_at' => null,
|
||||
@@ -363,9 +369,20 @@ class GroupArtworkReviewService
|
||||
$artwork->uploaded_by_user_id = $artwork->uploaded_by_user_id ?: (int) $actor->id;
|
||||
$artwork->primary_author_user_id = $artwork->primary_author_user_id ?: (int) $actor->id;
|
||||
|
||||
$categoryId = isset($validated['category']) ? (int) $validated['category'] : null;
|
||||
if ($categoryId > 0 && Category::query()->where('id', $categoryId)->exists()) {
|
||||
$artwork->categories()->sync([$categoryId]);
|
||||
$categoryId = isset($validated['primary_category_id'])
|
||||
? (int) $validated['primary_category_id']
|
||||
: (isset($validated['category']) ? (int) $validated['category'] : null);
|
||||
$secondaryCategoryIds = array_key_exists('secondary_category_ids', $validated)
|
||||
? array_values(array_map('intval', (array) $validated['secondary_category_ids']))
|
||||
: [];
|
||||
if ($categoryId > 0) {
|
||||
$this->artworkCategories->sync(
|
||||
$artwork,
|
||||
$categoryId,
|
||||
$secondaryCategoryIds,
|
||||
'user',
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
if (array_key_exists('tags', $validated) && is_array($validated['tags'])) {
|
||||
@@ -395,4 +412,4 @@ class GroupArtworkReviewService
|
||||
{
|
||||
IndexArtworkJob::dispatch((int) $artwork->id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ final class RSSFeedBuilder
|
||||
|
||||
// Primary category from eagerly loaded relation (avoid N+1)
|
||||
$primaryCategory = ($artwork->relationLoaded('categories'))
|
||||
? $artwork->categories->first()
|
||||
? $artwork->resolvedPrimaryCategory()
|
||||
: null;
|
||||
|
||||
// Build HTML description embedded in CDATA
|
||||
|
||||
@@ -201,7 +201,7 @@ final class ArtworkStudioProvider implements CreatorStudioProvider
|
||||
? 'scheduled'
|
||||
: ((bool) $artwork->is_public ? 'published' : 'draft'));
|
||||
|
||||
$category = $artwork->categories->first();
|
||||
$category = $artwork->resolvedPrimaryCategory();
|
||||
$visibility = $artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE);
|
||||
|
||||
return [
|
||||
|
||||
@@ -211,9 +211,13 @@ final class StudioAiAssistService
|
||||
}
|
||||
|
||||
$categoryId = $this->resolveCategoryId($payload);
|
||||
$secondaryCategoryIds = array_key_exists('secondary_category_ids', $payload)
|
||||
? array_values(array_map('intval', (array) $payload['secondary_category_ids']))
|
||||
: null;
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$artwork->categories()->sync([$categoryId]);
|
||||
app(\App\Services\Artworks\ArtworkCategoryService::class)
|
||||
->sync($artwork, $categoryId, $secondaryCategoryIds, 'ai_applied', false);
|
||||
$artwork->category_source = 'ai_applied';
|
||||
$updated = true;
|
||||
$applied[] = 'category';
|
||||
@@ -294,10 +298,10 @@ final class StudioAiAssistService
|
||||
*/
|
||||
public function payloadFor(Artwork $artwork): array
|
||||
{
|
||||
$artwork->loadMissing(['artworkAiAssist', 'tags', 'categories.contentType']);
|
||||
$artwork->loadMissing(['artworkAiAssist', 'tags', 'categories.contentType', 'primaryCategory.contentType']);
|
||||
$assist = $artwork->artworkAiAssist;
|
||||
|
||||
$primaryCategory = $artwork->categories->first();
|
||||
$primaryCategory = $artwork->resolvedPrimaryCategory();
|
||||
|
||||
if (! $assist) {
|
||||
return [
|
||||
@@ -515,6 +519,13 @@ final class StudioAiAssistService
|
||||
'description' => (string) ($artwork->description ?? ''),
|
||||
'tags' => $artwork->tags->pluck('slug')->values()->all(),
|
||||
'category_id' => $primaryCategory?->id,
|
||||
'primary_category_id' => $primaryCategory?->id,
|
||||
'secondary_category_ids' => $artwork->categories
|
||||
->reject(fn ($category): bool => (int) $category->id === (int) ($primaryCategory?->id ?? 0))
|
||||
->pluck('id')
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->values()
|
||||
->all(),
|
||||
'content_type_id' => $primaryCategory?->contentType?->id,
|
||||
'sources' => [
|
||||
'title' => $artwork->title_source ?: 'manual',
|
||||
@@ -530,6 +541,10 @@ final class StudioAiAssistService
|
||||
*/
|
||||
private function resolveCategoryId(array $payload): ?int
|
||||
{
|
||||
if (isset($payload['primary_category_id']) && $payload['primary_category_id'] !== null) {
|
||||
return (int) $payload['primary_category_id'];
|
||||
}
|
||||
|
||||
if (isset($payload['category_id']) && $payload['category_id'] !== null) {
|
||||
return (int) $payload['category_id'];
|
||||
}
|
||||
|
||||
@@ -49,8 +49,9 @@ final class StudioAiCategoryMapper
|
||||
$selectedCategory,
|
||||
$this->confidenceForModel($categoryScores, $selectedCategory->id),
|
||||
$categoryScores
|
||||
->reject(fn (array $row): bool => (int) $row['model']->id === (int) $selectedCategory->id)
|
||||
->take(3)
|
||||
->reject(fn (array $row): bool => (int) $row['model']->id === (int) $selectedCategory->id
|
||||
|| (int) $row['model']->content_type_id !== (int) $selectedCategory->content_type_id)
|
||||
->take(max(0, (int) config('categories.max_secondary_categories', 5)))
|
||||
->map(fn (array $row): array => $this->serializeCategory($row['model'], $row['confidence']))
|
||||
->all()
|
||||
) : null,
|
||||
@@ -245,6 +246,7 @@ final class StudioAiCategoryMapper
|
||||
'root_category_id' => (int) $rootCategory->id,
|
||||
'sub_category_id' => $category->parent_id ? (int) $category->id : null,
|
||||
'alternatives' => array_values($alternatives),
|
||||
'secondary_categories' => array_values($alternatives),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -117,7 +117,8 @@ final class StudioBulkActionService
|
||||
throw new \InvalidArgumentException('category_id required for change_category');
|
||||
}
|
||||
|
||||
$artwork->categories()->sync([(int) $params['category_id']]);
|
||||
app(\App\Services\Artworks\ArtworkCategoryService::class)
|
||||
->sync($artwork, (int) $params['category_id'], [], 'moderator');
|
||||
}
|
||||
|
||||
private function addTags(Artwork $artwork, array $params): void
|
||||
|
||||
@@ -608,7 +608,7 @@ final class WorldSubmissionService
|
||||
'thumbnail_url' => $artwork->thumbUrl('md'),
|
||||
'creator_name' => (string) ($artwork->user?->name ?: $artwork->user?->username ?: ''),
|
||||
'meta' => array_values(array_filter([
|
||||
$artwork->categories->first()?->name,
|
||||
$artwork->resolvedPrimaryCategory()?->name,
|
||||
$views > 0 ? number_format($views) . ' views' : null,
|
||||
$artwork->visibility ? Str::headline((string) $artwork->visibility) : null,
|
||||
])),
|
||||
|
||||
Reference in New Issue
Block a user