normalizeLayoutModules(null, Collection::TYPE_PERSONAL, true, false); } public function makeUniqueSlugForUser(User $user, string $source, ?int $ignoreCollectionId = null): string { $base = Str::slug(Str::limit($source, 140, '')); $base = $base !== '' ? $base : 'collection'; $slug = $base; $suffix = 2; while ($this->slugExistsForUser($user, $slug, $ignoreCollectionId)) { $slug = Str::limit($base, 132, ''); $slug = rtrim($slug, '-'); $slug .= '-' . $suffix; $suffix++; } return $slug; } public function createCollection(User $user, array $attributes): Collection { return DB::transaction(function () use ($user, $attributes): Collection { $mode = (string) ($attributes['mode'] ?? Collection::MODE_MANUAL); $type = (string) ($attributes['type'] ?? Collection::TYPE_PERSONAL); $ownership = $this->resolveOwnershipContext($user, $attributes, null, $type); $smartRules = $mode === Collection::MODE_SMART ? $this->smartCollections->sanitizeRules($attributes['smart_rules_json'] ?? null) : null; $allowComments = array_key_exists('allow_comments', $attributes) ? (bool) $attributes['allow_comments'] : true; $allowSubmissions = (bool) ($attributes['allow_submissions'] ?? false); $collection = new Collection(); $collection->user()->associate($ownership['owner_user']); $collection->managed_by_user_id = $ownership['managed_by_user_id']; $collection->title = (string) $attributes['title']; $collection->slug = $this->makeUniqueSlugForUser($ownership['owner_user'], (string) ($attributes['slug'] ?? $attributes['title'])); $collection->lifecycle_state = (string) ($attributes['lifecycle_state'] ?? Collection::LIFECYCLE_DRAFT); $collection->type = $type; $collection->editorial_owner_mode = $ownership['editorial_owner_mode']; $collection->editorial_owner_user_id = $ownership['editorial_owner_user_id']; $collection->editorial_owner_label = $ownership['editorial_owner_label']; $collection->description = $attributes['description'] ?? null; $collection->subtitle = $attributes['subtitle'] ?? null; $collection->summary = $attributes['summary'] ?? null; $collection->collaboration_mode = (string) ($attributes['collaboration_mode'] ?? Collection::COLLABORATION_CLOSED); $collection->allow_submissions = $allowSubmissions; $collection->allow_comments = $allowComments; $collection->allow_saves = array_key_exists('allow_saves', $attributes) ? (bool) $attributes['allow_saves'] : true; $collection->moderation_status = Collection::MODERATION_ACTIVE; $collection->visibility = (string) ($attributes['visibility'] ?? Collection::VISIBILITY_PUBLIC); $collection->mode = $mode; $collection->sort_mode = (string) ($attributes['sort_mode'] ?? ($mode === Collection::MODE_SMART ? Collection::SORT_NEWEST : Collection::SORT_MANUAL)); $collection->event_key = $attributes['event_key'] ?? null; $collection->event_label = $attributes['event_label'] ?? null; $collection->season_key = $attributes['season_key'] ?? null; $collection->banner_text = $attributes['banner_text'] ?? null; $collection->badge_label = $attributes['badge_label'] ?? null; $collection->spotlight_style = $attributes['spotlight_style'] ?? Collection::SPOTLIGHT_STYLE_DEFAULT; $collection->quality_score = null; $collection->ranking_score = null; $collection->analytics_enabled = array_key_exists('analytics_enabled', $attributes) ? (bool) $attributes['analytics_enabled'] : true; $collection->presentation_style = (string) ($attributes['presentation_style'] ?? Collection::PRESENTATION_STANDARD); $collection->emphasis_mode = (string) ($attributes['emphasis_mode'] ?? Collection::EMPHASIS_BALANCED); $collection->theme_token = $attributes['theme_token'] ?? null; $collection->series_key = $attributes['series_key'] ?? null; $collection->series_title = $attributes['series_title'] ?? null; $collection->series_description = $attributes['series_description'] ?? null; $collection->series_order = $attributes['series_order'] ?? null; $collection->campaign_key = $attributes['campaign_key'] ?? null; $collection->campaign_label = $attributes['campaign_label'] ?? null; $collection->commercial_eligibility = (bool) ($attributes['commercial_eligibility'] ?? false); $collection->promotion_tier = $attributes['promotion_tier'] ?? null; $collection->sponsorship_label = $attributes['sponsorship_label'] ?? null; $collection->partner_label = $attributes['partner_label'] ?? null; $collection->monetization_ready_status = $attributes['monetization_ready_status'] ?? null; $collection->brand_safe_status = $attributes['brand_safe_status'] ?? null; $collection->editorial_notes = $attributes['editorial_notes'] ?? null; $collection->staff_commercial_notes = $attributes['staff_commercial_notes'] ?? null; $collection->archived_at = $attributes['archived_at'] ?? null; $collection->expired_at = $attributes['expired_at'] ?? null; $collection->history_count = 0; $collection->cover_artwork_id = null; $collection->artworks_count = 0; $collection->comments_count = 0; $collection->views_count = 0; $collection->likes_count = 0; $collection->followers_count = 0; $collection->shares_count = 0; $collection->saves_count = 0; $collection->collaborators_count = 1; $collection->smart_rules_json = $smartRules; $collection->layout_modules_json = $this->normalizeLayoutModules($attributes['layout_modules_json'] ?? null, $type, $allowComments, $allowSubmissions, false); $collection->profile_order = $this->nextProfileOrder($ownership['owner_user']); $collection->last_activity_at = now(); $collection->published_at = $this->resolvePublishedAt($attributes); $collection->unpublished_at = $this->resolveUnpublishedAt($attributes); $collection->save(); $this->collaborators->ensureOwnerMembership($collection); $this->collaborators->ensureManagerMembership($collection, $user); if ($collection->isSmart()) { $this->syncSmartCollectionState($collection); } $fresh = $this->postMutationSync($collection->fresh()); app(CollectionHistoryService::class)->record($fresh, $user, 'created', 'Collection created.', null, [ 'title' => $fresh->title, 'type' => $fresh->type, 'lifecycle_state' => $fresh->lifecycle_state, ]); event(new CollectionCreated($fresh)); return $fresh; }); } public function updateCollection(Collection $collection, array $attributes, ?User $actor = null): Collection { return DB::transaction(function () use ($collection, $attributes, $actor): Collection { $originalFeatured = (bool) $collection->is_featured; $originalSmartRules = $collection->smart_rules_json; $slugSource = (string) ($attributes['slug'] ?? $attributes['title'] ?? $collection->slug); $mode = (string) ($attributes['mode'] ?? $collection->mode ?? Collection::MODE_MANUAL); $type = (string) ($attributes['type'] ?? $collection->type); $manager = $actor ?? $collection->user; $ownership = $this->resolveOwnershipContext($manager, $attributes, $collection, $type); $smartRules = $mode === Collection::MODE_SMART ? $this->smartCollections->sanitizeRules($attributes['smart_rules_json'] ?? $collection->smart_rules_json) : null; $coverArtworkId = $mode === Collection::MODE_SMART ? null : ($attributes['cover_artwork_id'] ?? $collection->cover_artwork_id); $visibility = (string) ($attributes['visibility'] ?? $collection->visibility); $isFeatureable = $visibility === Collection::VISIBILITY_PUBLIC; $allowSubmissions = array_key_exists('allow_submissions', $attributes) ? (bool) $attributes['allow_submissions'] : $collection->allow_submissions; $allowComments = array_key_exists('allow_comments', $attributes) ? (bool) $attributes['allow_comments'] : $collection->allow_comments; $collection->user()->associate($ownership['owner_user']); $collection->fill([ 'title' => (string) ($attributes['title'] ?? $collection->title), 'slug' => $this->makeUniqueSlugForUser($ownership['owner_user'], $slugSource, (int) $collection->id), 'lifecycle_state' => (string) ($attributes['lifecycle_state'] ?? $collection->lifecycle_state), 'type' => $type, 'managed_by_user_id' => $ownership['managed_by_user_id'], 'editorial_owner_mode' => $ownership['editorial_owner_mode'], 'editorial_owner_user_id' => $ownership['editorial_owner_user_id'], 'editorial_owner_label' => $ownership['editorial_owner_label'], 'description' => $attributes['description'] ?? null, 'subtitle' => $attributes['subtitle'] ?? null, 'summary' => $attributes['summary'] ?? null, 'collaboration_mode' => (string) ($attributes['collaboration_mode'] ?? $collection->collaboration_mode), 'allow_submissions' => $allowSubmissions, 'allow_comments' => $allowComments, 'allow_saves' => array_key_exists('allow_saves', $attributes) ? (bool) $attributes['allow_saves'] : $collection->allow_saves, 'visibility' => $visibility, 'mode' => $mode, 'sort_mode' => (string) ($attributes['sort_mode'] ?? $collection->sort_mode), 'cover_artwork_id' => $coverArtworkId, 'smart_rules_json' => $smartRules, 'layout_modules_json' => $this->normalizeLayoutModules($attributes['layout_modules_json'] ?? $collection->layout_modules_json, $type, $allowComments, $allowSubmissions, false), 'event_key' => $attributes['event_key'] ?? $collection->event_key, 'event_label' => $attributes['event_label'] ?? $collection->event_label, 'season_key' => $attributes['season_key'] ?? $collection->season_key, 'banner_text' => $attributes['banner_text'] ?? $collection->banner_text, 'badge_label' => $attributes['badge_label'] ?? $collection->badge_label, 'spotlight_style' => $attributes['spotlight_style'] ?? $collection->spotlight_style, 'analytics_enabled' => array_key_exists('analytics_enabled', $attributes) ? (bool) $attributes['analytics_enabled'] : $collection->analytics_enabled, 'presentation_style' => $attributes['presentation_style'] ?? $collection->presentation_style, 'emphasis_mode' => $attributes['emphasis_mode'] ?? $collection->emphasis_mode, 'theme_token' => $attributes['theme_token'] ?? $collection->theme_token, 'series_key' => $attributes['series_key'] ?? $collection->series_key, 'series_title' => $attributes['series_title'] ?? $collection->series_title, 'series_description' => $attributes['series_description'] ?? $collection->series_description, 'series_order' => $attributes['series_order'] ?? $collection->series_order, 'campaign_key' => $attributes['campaign_key'] ?? $collection->campaign_key, 'campaign_label' => $attributes['campaign_label'] ?? $collection->campaign_label, 'commercial_eligibility' => array_key_exists('commercial_eligibility', $attributes) ? (bool) $attributes['commercial_eligibility'] : $collection->commercial_eligibility, 'promotion_tier' => $attributes['promotion_tier'] ?? $collection->promotion_tier, 'sponsorship_label' => $attributes['sponsorship_label'] ?? $collection->sponsorship_label, 'partner_label' => $attributes['partner_label'] ?? $collection->partner_label, 'monetization_ready_status' => $attributes['monetization_ready_status'] ?? $collection->monetization_ready_status, 'brand_safe_status' => $attributes['brand_safe_status'] ?? $collection->brand_safe_status, 'editorial_notes' => $attributes['editorial_notes'] ?? $collection->editorial_notes, 'staff_commercial_notes' => $attributes['staff_commercial_notes'] ?? $collection->staff_commercial_notes, 'published_at' => $this->resolvePublishedAt($attributes, $collection->published_at), 'unpublished_at' => $this->resolveUnpublishedAt($attributes, $collection->unpublished_at), 'archived_at' => $attributes['archived_at'] ?? $collection->archived_at, 'expired_at' => $attributes['expired_at'] ?? $collection->expired_at, 'last_activity_at' => now(), ]); if (! $isFeatureable) { $collection->is_featured = false; $collection->featured_at = null; } $collection->save(); $this->collaborators->ensureOwnerMembership($collection); $this->collaborators->ensureManagerMembership($collection, $manager); if ($collection->isSmart()) { $this->syncSmartCollectionState($collection); } else { $collection->syncArtworksCount(); } $fresh = $this->postMutationSync($collection->fresh()); if ($originalFeatured && ! $fresh->is_featured) { event(new CollectionUnfeatured($fresh)); } if ($fresh->isSmart() && $originalSmartRules !== $fresh->smart_rules_json) { event(new SmartCollectionRulesUpdated($fresh)); } app(CollectionHistoryService::class)->record($fresh, $manager, 'updated', 'Collection settings updated.', [ 'title' => $collection->getOriginal('title'), 'visibility' => $collection->getOriginal('visibility'), 'lifecycle_state' => $collection->getOriginal('lifecycle_state'), ], [ 'title' => $fresh->title, 'visibility' => $fresh->visibility, 'lifecycle_state' => $fresh->lifecycle_state, ]); event(new CollectionUpdated($fresh)); return $fresh; }); } public function deleteCollection(Collection $collection): void { DB::transaction(function () use ($collection): void { DB::table('collection_artwork') ->where('collection_id', $collection->id) ->delete(); $collection->forceFill([ 'cover_artwork_id' => null, 'artworks_count' => 0, ])->save(); app(CollectionHistoryService::class)->record($collection->fresh(), null, 'deleted', 'Collection deleted.'); event(new CollectionDeleted($collection->fresh())); $collection->delete(); }); } /** * @param array $artworkIds */ public function attachArtworks(Collection $collection, User $owner, array $artworkIds): Collection { if ($collection->isSmart()) { throw ValidationException::withMessages([ 'collection' => 'Smart collections update from rules and do not accept manual attachments.', ]); } $normalizedIds = collect($artworkIds) ->map(static fn ($id) => (int) $id) ->filter(static fn (int $id) => $id > 0) ->unique() ->values(); if ($normalizedIds->isEmpty()) { throw ValidationException::withMessages([ 'artwork_ids' => 'Select at least one artwork to add.', ]); } $validIds = Artwork::query() ->whereIn('user_id', $this->contributorIds($collection)) ->whereIn('id', $normalizedIds) ->whereNull('deleted_at') ->pluck('id'); if ($validIds->count() !== $normalizedIds->count()) { throw ValidationException::withMessages([ 'artwork_ids' => 'You can only add artworks from approved collection contributors.', ]); } DB::transaction(function () use ($collection, $owner, $validIds): void { $this->attachArtworkIds($collection, $validIds->all()); app(CollectionHistoryService::class)->record($collection->fresh(), $owner, 'artworks_attached', 'Artworks added to collection.', null, [ 'artwork_ids' => $validIds->values()->all(), ]); event(new CollectionArtworkAttached($collection->fresh(), $validIds->values()->all())); }); return $collection->fresh(); } /** * @param array $artworkIds */ public function attachArtworkIds(Collection $collection, array $artworkIds): void { $newIds = collect($artworkIds) ->map(static fn ($id) => (int) $id) ->filter(static fn (int $id) => $id > 0) ->unique() ->values(); if ($newIds->isEmpty()) { return; } $existingIds = DB::table('collection_artwork') ->where('collection_id', $collection->id) ->whereIn('artwork_id', $newIds) ->pluck('artwork_id'); $newIds = $newIds->diff($existingIds)->values(); if ($newIds->isEmpty()) { return; } $nextOrder = (int) (DB::table('collection_artwork') ->where('collection_id', $collection->id) ->max('order_num') ?? -1) + 1; $rows = $newIds->map(function (int $artworkId) use (&$nextOrder, $collection) { return [ 'collection_id' => $collection->id, 'artwork_id' => $artworkId, 'order_num' => $nextOrder++, 'created_at' => now(), 'updated_at' => now(), ]; })->all(); DB::table('collection_artwork')->insert($rows); $this->syncCollectionArtworkState($collection); } public function removeArtwork(Collection $collection, Artwork $artwork): Collection { if ($collection->isSmart()) { throw ValidationException::withMessages([ 'collection' => 'Smart collections update from rules and do not support manual removals.', ]); } DB::transaction(function () use ($collection, $artwork): void { DB::table('collection_artwork') ->where('collection_id', $collection->id) ->where('artwork_id', $artwork->id) ->delete(); if ((int) $collection->cover_artwork_id === (int) $artwork->id) { $collection->cover_artwork_id = null; $collection->save(); } $this->normalizeArtworkOrder($collection); $this->syncCollectionArtworkState($collection); app(CollectionHistoryService::class)->record($collection->fresh(), null, 'artwork_removed', 'Artwork removed from collection.', [ 'artwork_id' => (int) $artwork->id, ]); event(new CollectionArtworkRemoved($collection->fresh(), (int) $artwork->id)); }); return $collection->fresh(); } /** * @param array $orderedArtworkIds */ public function reorderArtworks(Collection $collection, array $orderedArtworkIds): Collection { if ($collection->isSmart()) { throw ValidationException::withMessages([ 'collection' => 'Smart collections use rule-based ordering and cannot be manually reordered.', ]); } $normalizedIds = collect($orderedArtworkIds) ->map(static fn ($id) => (int) $id) ->filter(static fn (int $id) => $id > 0) ->values(); $currentIds = DB::table('collection_artwork') ->where('collection_id', $collection->id) ->orderBy('order_num') ->pluck('artwork_id') ->map(static fn ($id) => (int) $id) ->values(); if ($normalizedIds->count() !== $currentIds->count() || $normalizedIds->diff($currentIds)->isNotEmpty() || $currentIds->diff($normalizedIds)->isNotEmpty()) { throw ValidationException::withMessages([ 'ordered_artwork_ids' => 'The submitted artwork order is invalid for this collection.', ]); } DB::transaction(function () use ($collection, $normalizedIds): void { foreach ($normalizedIds as $index => $artworkId) { DB::table('collection_artwork') ->where('collection_id', $collection->id) ->where('artwork_id', $artworkId) ->update([ 'order_num' => $index, 'updated_at' => now(), ]); } $this->syncCollectionArtworkState($collection); app(CollectionHistoryService::class)->record($collection->fresh(), null, 'artworks_reordered', 'Artwork order updated.', null, [ 'ordered_artwork_ids' => $normalizedIds->all(), ]); }); return $collection->fresh(); } public function getProfileCollections(User $profileOwner, ?User $viewer, int $limit = 12): EloquentCollection { $ownerView = $viewer && (int) $viewer->id === (int) $profileOwner->id; $query = Collection::query() ->ownedBy((int) $profileOwner->id) ->with([ 'user:id,username,name', 'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at', ]) ->orderByDesc('is_featured') ->orderByRaw('CASE WHEN profile_order IS NULL THEN 1 ELSE 0 END') ->orderBy('profile_order') ->orderByDesc('featured_at') ->orderByDesc('updated_at') ->limit($limit); if (! $ownerView) { $query->visibleOnProfile(); } return $query->get(); } public function getCollectionDetailArtworks(Collection $collection, bool $ownerView, int $perPage = 24): LengthAwarePaginator { if ($collection->isSmart()) { return $this->smartCollections->resolveArtworks($collection, $ownerView, $perPage); } $query = $collection->artworks() ->with([ 'user:id,name,username', 'stats:artwork_id,views,downloads,favorites', 'categories' => function ($query) { $query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order') ->with(['contentType:id,slug,name']); }, ]) ->whereNull('artworks.deleted_at') ->select('artworks.*'); if (! $ownerView) { $query->where('artworks.is_public', true) ->where('artworks.is_approved', true) ->whereNotNull('artworks.published_at') ->where('artworks.published_at', '<=', now()); } $query = match ($collection->sort_mode) { Collection::SORT_NEWEST => $query->orderByDesc('artworks.published_at'), Collection::SORT_OLDEST => $query->orderBy('artworks.published_at'), Collection::SORT_POPULAR => $query->orderByDesc('artworks.view_count')->orderByPivot('order_num'), default => $query->orderByPivot('order_num'), }; return $query->paginate($perPage)->withQueryString(); } public function getAvailableArtworkOptions(Collection $collection, User $owner, ?string $search = null, int $limit = 36): array { if ($collection->isSmart()) { return []; } $attachedIds = DB::table('collection_artwork') ->where('collection_id', $collection->id) ->pluck('artwork_id'); $query = Artwork::query() ->with([ 'stats:artwork_id,views,downloads,favorites', 'categories' => function ($query) { $query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order') ->with(['contentType:id,slug,name']); }, ]) ->whereIn('user_id', $this->contributorIds($collection)) ->whereNull('deleted_at') ->whereNotIn('id', $attachedIds) ->orderByDesc('published_at') ->orderByDesc('id') ->limit($limit); if ($search !== null && $search !== '') { $query->where(function ($builder) use ($search): void { $builder->where('title', 'like', '%' . $search . '%') ->orWhere('slug', 'like', '%' . $search . '%'); }); } return $query->get()->map(fn (Artwork $artwork) => $this->mapArtworkPayload($artwork))->all(); } public function getCollectionOptionsForArtwork(User $owner, Artwork $artwork): array { if ((int) $artwork->user_id !== (int) $owner->id) { throw ValidationException::withMessages([ 'artwork_id' => 'You can only manage collections for your own artworks.', ]); } $collections = Collection::query() ->ownedBy((int) $owner->id) ->where('mode', Collection::MODE_MANUAL) ->orderByDesc('updated_at') ->get(['id', 'user_id', 'title', 'slug', 'visibility', 'mode', 'artworks_count', 'updated_at']); if ($collections->isEmpty()) { return []; } $attachedCollectionIds = DB::table('collection_artwork') ->where('artwork_id', $artwork->id) ->whereIn('collection_id', $collections->pluck('id')->all()) ->pluck('collection_id') ->map(static fn ($id) => (int) $id) ->all(); return $collections->map(function (Collection $collection) use ($attachedCollectionIds, $owner) { $alreadyAttached = in_array((int) $collection->id, $attachedCollectionIds, true); return [ 'id' => (int) $collection->id, 'title' => (string) $collection->title, 'type' => (string) $collection->type, 'slug' => (string) $collection->slug, 'visibility' => (string) $collection->visibility, 'mode' => (string) $collection->mode, 'artworks_count' => (int) $collection->artworks_count, 'already_attached' => $alreadyAttached, 'attach_url' => route('settings.collections.artworks.attach', ['collection' => $collection->id]), 'manage_url' => route('settings.collections.show', ['collection' => $collection->id]), 'public_url' => route('profile.collections.show', [ 'username' => strtolower((string) $owner->username), 'slug' => $collection->slug, ]), ]; })->all(); } public function getSubmissionArtworkOptions(User $user, int $limit = 24): array { return Artwork::query() ->with([ 'stats:artwork_id,views,downloads,favorites', 'categories' => function ($query) { $query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order') ->with(['contentType:id,slug,name']); }, ]) ->where('user_id', $user->id) ->whereNull('deleted_at') ->orderByDesc('published_at') ->orderByDesc('id') ->limit(max(1, min($limit, 36))) ->get() ->map(fn (Artwork $artwork) => $this->mapArtworkPayload($artwork)) ->all(); } public function getSavedCollectionsForUser(User $user, int $limit = 48): EloquentCollection { return Collection::query() ->public() ->select( 'collections.*', 'collection_saves.created_at as saved_at', 'collection_saves.last_viewed_at as saved_last_viewed_at', 'collection_saves.save_context as saved_context', 'collection_saves.save_context_meta_json as saved_context_meta_json' ) ->join('collection_saves', 'collection_saves.collection_id', '=', 'collections.id') ->where('collection_saves.user_id', $user->id) ->with([ 'user:id,username,name', 'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at', ]) ->orderByDesc('collection_saves.created_at') ->limit(max(1, min($limit, 60))) ->get(); } public function featureCollection(Collection $collection): Collection { if (! $collection->isFeatureablePublicly()) { throw ValidationException::withMessages([ 'collection' => 'Only public collections can be featured.', ]); } return DB::transaction(function () use ($collection): Collection { $fresh = $collection->fresh(); if (! $fresh->is_featured) { $featuredCount = Collection::query() ->ownedBy((int) $fresh->user_id) ->where('is_featured', true) ->count(); if ($featuredCount >= $this->featuredLimit()) { throw ValidationException::withMessages([ 'collection' => sprintf('You can feature up to %d collections.', $this->featuredLimit()), ]); } } $fresh->forceFill([ 'is_featured' => true, 'featured_at' => now(), 'last_activity_at' => now(), ])->save(); $fresh = $this->postMutationSync($fresh->fresh()); app(CollectionHistoryService::class)->record($fresh, null, 'featured', 'Collection marked as featured.'); event(new CollectionFeatured($fresh)); return $fresh; }); } public function unfeatureCollection(Collection $collection): Collection { $collection->forceFill([ 'is_featured' => false, 'featured_at' => null, ])->save(); $collection = $this->postMutationSync($collection->fresh()); app(CollectionHistoryService::class)->record($collection, null, 'unfeatured', 'Collection removed from featured state.'); event(new CollectionUnfeatured($collection)); return $collection; } public function syncCollectionPublicState(Collection $collection, array $attributes): Collection { $updates = []; foreach (['allow_comments', 'allow_submissions', 'allow_saves', 'moderation_status'] as $key) { if (array_key_exists($key, $attributes)) { $updates[$key] = $attributes[$key]; } } if ($updates === []) { return $collection->fresh(); } if (($updates['moderation_status'] ?? $collection->moderation_status) !== Collection::MODERATION_ACTIVE) { $updates['is_featured'] = false; $updates['featured_at'] = null; } $updates['updated_at'] = now(); $updates['last_activity_at'] = now(); $collection->forceFill($updates)->save(); $collection = $this->postMutationSync($collection->fresh()); app(CollectionHistoryService::class)->record($collection, null, 'moderation_updated', 'Collection public state updated.', null, $updates); return $collection; } /** * @param array $collectionIds */ public function reorderProfileCollections(User $owner, array $collectionIds): void { $normalizedIds = collect($collectionIds) ->map(static fn ($id) => (int) $id) ->filter(static fn (int $id) => $id > 0) ->values(); $ownedIds = Collection::query() ->ownedBy((int) $owner->id) ->orderBy('id') ->pluck('id') ->map(static fn ($id) => (int) $id) ->values(); if ($normalizedIds->count() !== $ownedIds->count() || $normalizedIds->diff($ownedIds)->isNotEmpty() || $ownedIds->diff($normalizedIds)->isNotEmpty()) { throw ValidationException::withMessages([ 'collection_ids' => 'The submitted profile order is invalid.', ]); } DB::transaction(function () use ($normalizedIds): void { foreach ($normalizedIds as $index => $collectionId) { DB::table('collections') ->where('id', $collectionId) ->update([ 'profile_order' => $index, 'updated_at' => now(), ]); } }); } public function previewSmartCollection(User $owner, array $rules, int $perPage = 12): array { $sanitized = $this->smartCollections->sanitizeRules($rules); $paginator = $this->smartCollections->preview($owner, $sanitized, true, $perPage); return [ 'rules' => $sanitized, 'summary' => $this->smartCollections->smartSummary($sanitized), 'count' => $paginator->total(), 'artworks' => $this->mapArtworkPaginator($paginator), ]; } public function getSmartRuleOptions(User $owner): array { return $this->smartCollections->ruleOptionsForOwner($owner); } public function recordView(Collection $collection): Collection { DB::table('collections') ->where('id', $collection->id) ->update([ 'views_count' => DB::raw('views_count + 1'), 'updated_at' => now(), ]); $fresh = $collection->fresh(); if ($fresh->supportsAnalytics()) { app(CollectionAnalyticsService::class)->snapshot($fresh); } return $fresh; } public function recordShare(Collection $collection, ?User $actor = null): Collection { DB::table('collections') ->where('id', $collection->id) ->update([ 'shares_count' => DB::raw('shares_count + 1'), 'last_activity_at' => now(), 'updated_at' => now(), ]); $fresh = $this->postMutationSync($collection->fresh()); event(new CollectionShared($fresh, $actor?->id)); return $fresh; } public function mapCollectionCardPayloads(iterable $collections, bool $ownerView = false, ?User $viewer = null): array { $collectionList = $collections instanceof EloquentCollection ? $collections : new EloquentCollection(is_array($collections) ? $collections : iterator_to_array($collections)); $collectionIds = $collectionList->pluck('id')->map(static fn ($id) => (int) $id)->all(); $firstArtworkMap = $this->firstArtworkMapForCollections( $collectionIds, ! $ownerView ); $savedCollectionIds = $viewer && ! $ownerView && $collectionIds !== [] ? DB::table('collection_saves') ->where('user_id', $viewer->id) ->whereIn('collection_id', $collectionIds) ->pluck('collection_id') ->map(static fn ($id) => (int) $id) ->all() : []; return $collectionList->map(function (Collection $collection) use ($ownerView, $viewer, $firstArtworkMap, $savedCollectionIds) { $resolvedCover = $collection->isSmart() ? $this->smartCollections->firstArtwork($collection, $ownerView) : $collection->resolvedCoverArtwork(! $ownerView); $fallbackCover = $firstArtworkMap->get((int) $collection->id); $cover = $resolvedCover ?? $fallbackCover; $summary = $collection->summary ?? $collection->description; $isSaved = in_array((int) $collection->id, $savedCollectionIds, true); $canSave = ! $ownerView && $viewer && $collection->canBeSavedBy($viewer); return [ 'id' => $collection->id, 'title' => $collection->title, 'subtitle' => $collection->subtitle, 'slug' => $collection->slug, 'type' => $collection->type, 'lifecycle_state' => $collection->lifecycle_state, 'workflow_state' => $collection->workflow_state, 'readiness_state' => $collection->readiness_state, 'health_state' => $collection->health_state, 'health_flags' => is_array($collection->health_flags_json) ? $collection->health_flags_json : [], 'program_key' => $collection->program_key, 'partner_key' => $collection->partner_key, 'trust_tier' => $collection->trust_tier, 'experiment_key' => $collection->experiment_key, 'experiment_treatment' => $collection->experiment_treatment, 'placement_variant' => $collection->placement_variant, 'ranking_mode_variant' => $collection->ranking_mode_variant, 'collection_pool_version' => $collection->collection_pool_version, 'test_label' => $collection->test_label, 'recommendation_tier' => $collection->recommendation_tier, 'ranking_bucket' => $collection->ranking_bucket, 'search_boost_tier' => $collection->search_boost_tier, 'event_key' => $collection->event_key, 'event_label' => $collection->event_label, 'season_key' => $collection->season_key, 'badge_label' => $collection->badge_label, 'banner_text' => $collection->banner_text, 'spotlight_style' => $collection->spotlight_style, 'quality_score' => $collection->quality_score !== null ? (float) $collection->quality_score : null, 'ranking_score' => $collection->ranking_score !== null ? (float) $collection->ranking_score : null, 'metadata_completeness_score' => $collection->metadata_completeness_score !== null ? (float) $collection->metadata_completeness_score : null, 'editorial_readiness_score' => $collection->editorial_readiness_score !== null ? (float) $collection->editorial_readiness_score : null, 'freshness_score' => $collection->freshness_score !== null ? (float) $collection->freshness_score : null, 'engagement_score' => $collection->engagement_score !== null ? (float) $collection->engagement_score : null, 'health_score' => $collection->health_score !== null ? (float) $collection->health_score : null, 'placement_eligibility' => (bool) $collection->placement_eligibility, 'canonical_collection_id' => $collection->canonical_collection_id ? (int) $collection->canonical_collection_id : null, 'duplicate_cluster_key' => $collection->duplicate_cluster_key, 'analytics_enabled' => (bool) $collection->analytics_enabled, 'presentation_style' => $collection->presentation_style, 'emphasis_mode' => $collection->emphasis_mode, 'theme_token' => $collection->theme_token, 'series_key' => $collection->series_key, 'series_title' => $collection->series_title, 'series_description' => $collection->series_description, 'series_order' => $collection->series_order, 'campaign_key' => $collection->campaign_key, 'campaign_label' => $collection->campaign_label, 'commercial_eligibility' => (bool) $collection->commercial_eligibility, 'promotion_tier' => $collection->promotion_tier, 'sponsorship_label' => $collection->sponsorship_label, 'sponsorship_state' => $collection->sponsorship_state, 'partner_label' => $collection->partner_label, 'ownership_domain' => $collection->ownership_domain, 'commercial_review_state' => $collection->commercial_review_state, 'legal_review_state' => $collection->legal_review_state, 'editorial_notes' => $collection->editorial_notes, 'staff_commercial_notes' => $collection->staff_commercial_notes, 'description' => $collection->description, 'summary' => $collection->summary, 'description_excerpt' => Str::limit((string) ($summary ?? ''), 120), 'owner' => $this->mapCollectionOwnerPayload($collection), 'artworks_count' => (int) $collection->artworks_count, 'comments_count' => (int) $collection->comments_count, 'visibility' => $collection->visibility, 'mode' => $collection->mode, 'collaboration_mode' => $collection->collaboration_mode, 'allow_submissions' => (bool) $collection->allow_submissions, 'allow_comments' => (bool) $collection->allow_comments, 'allow_saves' => (bool) $collection->allow_saves, 'is_featured' => (bool) $collection->is_featured, 'views_count' => (int) $collection->views_count, 'likes_count' => (int) $collection->likes_count, 'followers_count' => (int) $collection->followers_count, 'shares_count' => (int) $collection->shares_count, 'saves_count' => (int) $collection->saves_count, 'collaborators_count' => (int) $collection->collaborators_count, 'updated_at' => optional($collection->updated_at)?->toISOString(), 'last_activity_at' => optional($collection->last_activity_at)?->toISOString(), 'featured_at' => optional($collection->featured_at)?->toISOString(), 'last_health_check_at' => optional($collection->last_health_check_at)?->toISOString(), 'last_recommendation_refresh_at' => optional($collection->last_recommendation_refresh_at)?->toISOString(), 'smart_summary' => $collection->isSmart() ? $this->smartCollections->smartSummary($collection->smart_rules_json) : null, 'cover_image' => $cover ? $this->mapArtworkThumb($cover) : null, 'cover_artwork_id' => $cover?->id, 'saved' => $isSaved, 'save_url' => $canSave ? route('collections.save', ['collection' => $collection->id]) : null, 'unsave_url' => $isSaved && ! $ownerView && $viewer ? route('collections.unsave', ['collection' => $collection->id]) : null, 'login_url' => ! $ownerView && ! $viewer ? route('login') : null, 'url' => route('profile.collections.show', [ 'username' => strtolower((string) $collection->user->username), 'slug' => $collection->slug, ]), 'manage_url' => $ownerView ? route('settings.collections.show', ['collection' => $collection->id]) : null, 'edit_url' => $ownerView ? route('settings.collections.edit', ['collection' => $collection->id]) : null, 'delete_url' => $ownerView ? route('settings.collections.destroy', ['collection' => $collection->id]) : null, 'feature_url' => $ownerView ? route('settings.collections.feature', ['collection' => $collection->id]) : null, 'unfeature_url' => $ownerView ? route('settings.collections.unfeature', ['collection' => $collection->id]) : null, ]; })->all(); } public function mapCollectionDetailPayload(Collection $collection, bool $ownerView = false): array { $cover = $collection->isSmart() ? $this->smartCollections->firstArtwork($collection, $ownerView) : $collection->resolvedCoverArtwork(! $ownerView); return [ 'id' => $collection->id, 'title' => $collection->title, 'subtitle' => $collection->subtitle, 'slug' => $collection->slug, 'type' => $collection->type, 'lifecycle_state' => $collection->lifecycle_state, 'workflow_state' => $collection->workflow_state, 'readiness_state' => $collection->readiness_state, 'health_state' => $collection->health_state, 'health_flags' => is_array($collection->health_flags_json) ? $collection->health_flags_json : [], 'collaboration_mode' => $collection->collaboration_mode, 'allow_submissions' => (bool) $collection->allow_submissions, 'allow_comments' => (bool) $collection->allow_comments, 'allow_saves' => (bool) $collection->allow_saves, 'moderation_status' => $collection->moderation_status, 'event_key' => $collection->event_key, 'event_label' => $collection->event_label, 'season_key' => $collection->season_key, 'banner_text' => $collection->banner_text, 'badge_label' => $collection->badge_label, 'spotlight_style' => $collection->spotlight_style, 'quality_score' => $collection->quality_score !== null ? (float) $collection->quality_score : null, 'ranking_score' => $collection->ranking_score !== null ? (float) $collection->ranking_score : null, 'metadata_completeness_score' => $collection->metadata_completeness_score !== null ? (float) $collection->metadata_completeness_score : null, 'editorial_readiness_score' => $collection->editorial_readiness_score !== null ? (float) $collection->editorial_readiness_score : null, 'freshness_score' => $collection->freshness_score !== null ? (float) $collection->freshness_score : null, 'engagement_score' => $collection->engagement_score !== null ? (float) $collection->engagement_score : null, 'health_score' => $collection->health_score !== null ? (float) $collection->health_score : null, 'placement_eligibility' => (bool) $collection->placement_eligibility, 'analytics_enabled' => (bool) $collection->analytics_enabled, 'presentation_style' => $collection->presentation_style, 'emphasis_mode' => $collection->emphasis_mode, 'theme_token' => $collection->theme_token, 'series_key' => $collection->series_key, 'series_title' => $collection->series_title, 'series_description' => $collection->series_description, 'series_order' => $collection->series_order, 'campaign_key' => $collection->campaign_key, 'campaign_label' => $collection->campaign_label, 'commercial_eligibility' => (bool) $collection->commercial_eligibility, 'promotion_tier' => $collection->promotion_tier, 'sponsorship_label' => $collection->sponsorship_label, 'sponsorship_state' => $collection->sponsorship_state, 'partner_label' => $collection->partner_label, 'ownership_domain' => $collection->ownership_domain, 'commercial_review_state' => $collection->commercial_review_state, 'legal_review_state' => $collection->legal_review_state, 'partner_key' => $collection->partner_key, 'program_key' => $collection->program_key, 'trust_tier' => $collection->trust_tier, 'experiment_key' => $collection->experiment_key, 'experiment_treatment' => $collection->experiment_treatment, 'placement_variant' => $collection->placement_variant, 'ranking_mode_variant' => $collection->ranking_mode_variant, 'collection_pool_version' => $collection->collection_pool_version, 'test_label' => $collection->test_label, 'recommendation_tier' => $collection->recommendation_tier, 'ranking_bucket' => $collection->ranking_bucket, 'search_boost_tier' => $collection->search_boost_tier, 'monetization_ready_status' => $collection->monetization_ready_status, 'brand_safe_status' => $collection->brand_safe_status, 'editorial_notes' => $collection->editorial_notes, 'staff_commercial_notes' => $collection->staff_commercial_notes, 'description' => $collection->description, 'summary' => $collection->summary, 'owner' => $this->mapCollectionOwnerPayload($collection), 'visibility' => $collection->visibility, 'mode' => $collection->mode, 'sort_mode' => $collection->sort_mode, 'canonical_collection_id' => $collection->canonical_collection_id ? (int) $collection->canonical_collection_id : null, 'duplicate_cluster_key' => $collection->duplicate_cluster_key, 'artworks_count' => (int) $collection->artworks_count, 'comments_count' => (int) $collection->comments_count, 'is_featured' => (bool) $collection->is_featured, 'views_count' => (int) $collection->views_count, 'likes_count' => (int) $collection->likes_count, 'followers_count' => (int) $collection->followers_count, 'shares_count' => (int) $collection->shares_count, 'saves_count' => (int) $collection->saves_count, 'collaborators_count' => (int) $collection->collaborators_count, 'updated_at' => optional($collection->updated_at)?->toISOString(), 'last_activity_at' => optional($collection->last_activity_at)?->toISOString(), 'featured_at' => optional($collection->featured_at)?->toISOString(), 'last_health_check_at' => optional($collection->last_health_check_at)?->toISOString(), 'last_recommendation_refresh_at' => optional($collection->last_recommendation_refresh_at)?->toISOString(), 'published_at' => optional($collection->published_at)?->toISOString(), 'unpublished_at' => optional($collection->unpublished_at)?->toISOString(), 'archived_at' => optional($collection->archived_at)?->toISOString(), 'expired_at' => optional($collection->expired_at)?->toISOString(), 'history_count' => (int) $collection->history_count, 'cover_image' => $cover ? $this->mapArtworkThumb($cover) : null, 'cover_artwork_id' => $collection->cover_artwork_id, 'smart_rules_json' => $collection->smart_rules_json, 'layout_modules' => $this->normalizeLayoutModules($collection->layout_modules_json, $collection->type, (bool) $collection->allow_comments, (bool) $collection->allow_submissions), 'smart_summary' => $collection->isSmart() ? $this->smartCollections->smartSummary($collection->smart_rules_json) : null, 'can_publicly_engage' => $collection->isPubliclyEngageable(), 'public_url' => route('profile.collections.show', [ 'username' => strtolower((string) $collection->user->username), 'slug' => $collection->slug, ]), ]; } public function mapArtworkPaginator(LengthAwarePaginator $paginator): array { return [ 'data' => collect($paginator->items()) ->map(fn (Artwork $artwork) => $this->mapArtworkPayload($artwork)) ->values() ->all(), 'meta' => [ 'current_page' => $paginator->currentPage(), 'last_page' => $paginator->lastPage(), 'per_page' => $paginator->perPage(), 'total' => $paginator->total(), ], 'links' => [ 'next' => $paginator->nextPageUrl(), 'prev' => $paginator->previousPageUrl(), ], ]; } public function mapAttachedArtworks(Collection $collection): array { if ($collection->isSmart()) { return []; } $artworks = $collection->artworks() ->with([ 'stats:artwork_id,views,downloads,favorites', 'categories' => function ($query) { $query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order') ->with(['contentType:id,slug,name']); }, ]) ->whereNull('artworks.deleted_at') ->select('artworks.*') ->get(); return $artworks->map(fn (Artwork $artwork) => $this->mapArtworkPayload($artwork, [ 'pivot_order' => (int) ($artwork->pivot?->order_num ?? 0), 'can_remove' => true, 'remove_url' => route('settings.collections.artworks.remove', [ 'collection' => $collection->id, 'artwork' => $artwork->id, ]), ]))->all(); } private function syncCollectionArtworkState(Collection $collection): void { $collection->refresh(); $collection->syncArtworksCount(); if ($collection->cover_artwork_id !== null) { $coverStillAttached = DB::table('collection_artwork') ->where('collection_id', $collection->id) ->where('artwork_id', $collection->cover_artwork_id) ->exists(); if (! $coverStillAttached) { $collection->forceFill(['cover_artwork_id' => null])->save(); } } $collection->forceFill([ 'last_activity_at' => now(), ])->save(); $this->postMutationSync($collection->fresh()); } private function postMutationSync(Collection $collection): Collection { $fresh = app(CollectionLifecycleService::class)->syncState($collection->fresh()); $fresh = app(CollectionQualityService::class)->sync($fresh); $fresh = app(CollectionHealthService::class)->refresh($fresh, null, 'post-mutation'); $fresh = app(CollectionRankingService::class)->refresh($fresh); if ($fresh->supportsAnalytics()) { app(CollectionAnalyticsService::class)->snapshot($fresh); } return $fresh; } private function normalizeArtworkOrder(Collection $collection): void { $ids = DB::table('collection_artwork') ->where('collection_id', $collection->id) ->orderBy('order_num') ->orderBy('id') ->pluck('artwork_id'); foreach ($ids as $index => $artworkId) { DB::table('collection_artwork') ->where('collection_id', $collection->id) ->where('artwork_id', $artworkId) ->update([ 'order_num' => $index, 'updated_at' => now(), ]); } } /** * @param array $collectionIds * @return SupportCollection */ private function firstArtworkMapForCollections(array $collectionIds, bool $publicOnly): SupportCollection { if ($collectionIds === []) { return collect(); } $rows = DB::table('collection_artwork as ca') ->join('artworks as a', 'a.id', '=', 'ca.artwork_id') ->whereIn('ca.collection_id', $collectionIds) ->whereNull('a.deleted_at') ->when($publicOnly, function ($query): void { $query->where('a.is_public', true) ->where('a.is_approved', true) ->whereNotNull('a.published_at') ->where('a.published_at', '<=', now()); }) ->orderBy('ca.collection_id') ->orderBy('ca.order_num') ->select(['ca.collection_id', 'a.id']) ->get(); $firstIdsByCollection = $rows ->unique('collection_id') ->pluck('id', 'collection_id'); if ($firstIdsByCollection->isEmpty()) { return collect(); } $artworks = Artwork::query() ->whereIn('id', $firstIdsByCollection->values()->all()) ->get() ->keyBy('id'); return $firstIdsByCollection->map(fn ($artworkId) => $artworks->get($artworkId))->filter(); } private function mapArtworkPayload(Artwork $artwork, array $extra = []): array { $category = $artwork->categories->first(); $contentType = $category?->contentType; $stats = $artwork->stats; return array_merge([ 'id' => $artwork->id, 'title' => $artwork->title, 'slug' => $artwork->slug, 'thumb' => $this->mapArtworkThumb($artwork), 'url' => route('art.show', ['id' => $artwork->id, 'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: (string) $artwork->id]), 'width' => $artwork->width, 'height' => $artwork->height, 'content_type' => $contentType?->name, 'content_type_slug' => $contentType?->slug, 'category' => $category?->name, 'category_slug' => $category?->slug, 'views' => (int) ($stats?->views ?? $artwork->view_count ?? 0), 'downloads' => (int) ($stats?->downloads ?? 0), 'likes' => (int) ($stats?->favorites ?? $artwork->favourite_count ?? 0), 'published_at' => optional($artwork->published_at)?->toISOString(), 'is_public' => (bool) $artwork->is_public, 'is_approved' => (bool) $artwork->is_approved, 'author' => $artwork->relationLoaded('user') && $artwork->user ? [ 'id' => (int) $artwork->user->id, 'name' => $artwork->user->name, 'username' => $artwork->user->username, 'profile_url' => route('profile.show', ['username' => strtolower((string) $artwork->user->username)]), ] : null, ], $extra); } private function normalizeLayoutModules(?array $modules, string $type, bool $allowComments, bool $allowSubmissions, bool $includePresentation = true): array { $definitions = config('collections.layout_modules', []); $submitted = collect($modules ?? [])->values(); $submittedByKey = $submitted ->filter(fn ($item) => is_array($item) && isset($item['key'])) ->keyBy(fn ($item) => (string) $item['key']); $submittedOrder = $submitted ->filter(fn ($item) => is_array($item) && isset($item['key'])) ->pluck('key') ->values() ->flip(); $submittedCount = $submittedOrder->count(); $normalized = []; foreach ($definitions as $key => $definition) { $input = $submittedByKey->get($key, []); $slots = $definition['slots'] ?? ['main']; $slot = (string) ($input['slot'] ?? $definition['default_slot'] ?? $slots[0]); if (! in_array($slot, $slots, true)) { $slot = (string) ($definition['default_slot'] ?? $slots[0]); } $locked = (bool) ($definition['locked'] ?? false); $enabled = array_key_exists('enabled', $input) ? (bool) $input['enabled'] : $this->defaultEnabledForLayoutModule((string) $key, $type, $allowComments, $allowSubmissions); if ($locked) { $enabled = true; } $normalized[] = [ 'key' => (string) $key, 'label' => (string) ($definition['label'] ?? Str::headline((string) $key)), 'description' => (string) ($definition['description'] ?? ''), 'slot' => $slot, 'slots' => array_values($slots), 'enabled' => $enabled, 'locked' => $locked, '_order' => (int) ($submittedOrder->get($key, $submittedCount + count($normalized))), ]; } usort($normalized, static fn (array $left, array $right): int => $left['_order'] <=> $right['_order']); return array_map(function (array $module) use ($includePresentation): array { unset($module['_order']); if (! $includePresentation) { return [ 'key' => $module['key'], 'slot' => $module['slot'], 'enabled' => $module['enabled'], ]; } return $module; }, $normalized); } private function defaultEnabledForLayoutModule(string $key, string $type, bool $allowComments, bool $allowSubmissions): bool { return match ($key) { 'intro_block' => true, 'artwork_grid' => true, 'featured_artworks' => false, 'discussion' => $allowComments, 'submissions' => $allowSubmissions, 'editorial_note' => $type === Collection::TYPE_EDITORIAL, default => true, }; } private function resolveOwnershipContext(User $actor, array $attributes, ?Collection $collection, string $type): array { if ($type !== Collection::TYPE_EDITORIAL) { $ownerUser = $collection && ! $collection->hasSystemEditorialOwner() && (int) $collection->user_id !== (int) $actor->id ? $collection->user : $actor; return [ 'owner_user' => $ownerUser, 'managed_by_user_id' => null, 'editorial_owner_mode' => Collection::EDITORIAL_OWNER_CREATOR, 'editorial_owner_user_id' => null, 'editorial_owner_label' => null, ]; } $ownerMode = (string) ($attributes['editorial_owner_mode'] ?? $collection?->editorial_owner_mode ?? Collection::EDITORIAL_OWNER_CREATOR); $ownerUser = $actor; $managedByUserId = null; $editorialOwnerUserId = null; $editorialOwnerLabel = null; if ($ownerMode === Collection::EDITORIAL_OWNER_STAFF_ACCOUNT) { $ownerUsername = trim((string) ($attributes['editorial_owner_username'] ?? '')); $target = User::query()->whereRaw('LOWER(username) = ?', [Str::lower($ownerUsername)])->first(); if (! $target || ! ($target->isAdmin() || $target->isModerator())) { throw ValidationException::withMessages([ 'editorial_owner_username' => 'The editorial owner must be an admin or moderator account.', ]); } $ownerUser = $target; $managedByUserId = (int) $target->id !== (int) $actor->id ? (int) $actor->id : null; $editorialOwnerUserId = (int) $target->id; } if ($ownerMode === Collection::EDITORIAL_OWNER_SYSTEM) { $systemLabel = trim((string) ($attributes['editorial_owner_label'] ?? config('collections.editorial.system_owner_label', 'Skinbase Editorial'))); $systemOwnerUsername = trim((string) config('collections.editorial.system_owner_username', '')); $systemOwner = $systemOwnerUsername !== '' ? User::query()->whereRaw('LOWER(username) = ?', [Str::lower($systemOwnerUsername)])->first() : null; if ($systemOwner) { $ownerUser = $systemOwner; $editorialOwnerUserId = (int) $systemOwner->id; $managedByUserId = (int) $systemOwner->id !== (int) $actor->id ? (int) $actor->id : null; } $editorialOwnerLabel = $systemLabel !== '' ? $systemLabel : 'Skinbase Editorial'; } return [ 'owner_user' => $ownerUser, 'managed_by_user_id' => $managedByUserId, 'editorial_owner_mode' => $ownerMode, 'editorial_owner_user_id' => $editorialOwnerUserId, 'editorial_owner_label' => $editorialOwnerLabel, ]; } private function mapCollectionOwnerPayload(Collection $collection): array { $owner = $collection->relationLoaded('user') ? $collection->user : $collection->user()->first(); $username = $collection->displayOwnerUsername(); $avatarUrl = null; if (! $collection->hasSystemEditorialOwner() && $owner && $owner->relationLoaded('profile')) { $avatarUrl = AvatarUrl::forUser((int) $owner->id, $owner->profile?->avatar_hash, 128); } return [ 'name' => $collection->displayOwnerName(), 'username' => $username, 'profile_url' => $username ? route('profile.tab', ['username' => strtolower($username), 'tab' => 'collections']) : null, 'is_system' => $collection->hasSystemEditorialOwner(), 'mode' => $collection->editorial_owner_mode, 'managed_by_user_id' => $collection->managed_by_user_id ? (int) $collection->managed_by_user_id : null, 'avatar_url' => $avatarUrl, ]; } private function mapArtworkThumb(Artwork $artwork): ?string { $presented = ThumbnailPresenter::present($artwork, 'md'); return $presented['url'] ?? $artwork->thumbUrl('md'); } private function slugExistsForUser(User $user, string $slug, ?int $ignoreCollectionId = null): bool { return Collection::query() ->where('user_id', $user->id) ->where('slug', $slug) ->when($ignoreCollectionId !== null, fn ($query) => $query->where('id', '!=', $ignoreCollectionId)) ->withTrashed() ->exists(); } private function featuredLimit(): int { return max(1, (int) config('collections.featured_limit', 3)); } private function nextProfileOrder(User $user): int { return (int) (Collection::query() ->ownedBy((int) $user->id) ->max('profile_order') ?? -1) + 1; } private function resolvePublishedAt(array $attributes, mixed $fallback = null): ?Carbon { if (! array_key_exists('published_at', $attributes)) { return $fallback instanceof Carbon ? $fallback : ($fallback ? Carbon::parse((string) $fallback) : now()); } $value = $attributes['published_at']; if ($value === null || $value === '') { return null; } return Carbon::parse((string) $value); } private function resolveUnpublishedAt(array $attributes, mixed $fallback = null): ?Carbon { if (! array_key_exists('unpublished_at', $attributes)) { return $fallback instanceof Carbon ? $fallback : ($fallback ? Carbon::parse((string) $fallback) : null); } $value = $attributes['unpublished_at']; if ($value === null || $value === '') { return null; } return Carbon::parse((string) $value); } /** * @return array */ private function contributorIds(Collection $collection): array { return $collection->isCollaborative() ? $this->collaborators->activeContributorIds($collection) : [(int) $collection->user_id]; } private function syncSmartCollectionState(Collection $collection): void { $count = $this->smartCollections->countMatching($collection, null, true); $collection->forceFill([ 'artworks_count' => $count, 'cover_artwork_id' => null, ])->save(); } }