1481 lines
69 KiB
PHP
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();
|
|
}
|
|
}
|