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:
test
2026-09-20 14:50:12 +02:00
parent 4d60a876af
commit 232880e01f
43 changed files with 2896 additions and 565 deletions
@@ -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) {
+2 -2
View File
@@ -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();
+2 -2
View File
@@ -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', [
+16 -4
View File
@@ -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;
+21 -4
View File
@@ -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);
}
}
}
+1 -1
View File
@@ -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 [
+18 -3
View File
@@ -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,
])),