Files
SkinbaseNova/app/Services/CollectionService.php
2026-03-28 19:15:39 +01:00

1481 lines
69 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services;
use App\Events\Collections\CollectionArtworkAttached;
use App\Events\Collections\CollectionArtworkRemoved;
use App\Events\Collections\CollectionCreated;
use App\Events\Collections\CollectionDeleted;
use App\Events\Collections\CollectionFeatured;
use App\Events\Collections\CollectionUnfeatured;
use App\Events\Collections\CollectionShared;
use App\Events\Collections\CollectionUpdated;
use App\Events\Collections\SmartCollectionRulesUpdated;
use App\Models\Artwork;
use App\Models\Collection;
use App\Models\User;
use App\Support\AvatarUrl;
use App\Services\ThumbnailPresenter;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection as SupportCollection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Support\Carbon;
use Illuminate\Validation\ValidationException;
class CollectionService
{
public function __construct(
private readonly SmartCollectionService $smartCollections,
private readonly CollectionCollaborationService $collaborators,
) {
}
public function getLayoutModuleDefinitions(): array
{
return $this->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<int, int|string> $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<int, int> $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<int, int|string> $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<int, int|string> $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<int, int> $collectionIds
* @return SupportCollection<int, Artwork>
*/
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<int, int>
*/
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();
}
}