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

335 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Collection;
use App\Models\CollectionSave;
use App\Models\CollectionSavedNote;
use App\Models\CollectionSavedList;
use App\Models\CollectionSavedListItem;
use App\Models\User;
use Illuminate\Support\Collection as SupportCollection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class CollectionSavedLibraryService
{
/**
* @param array<int, int> $collectionIds
* @return array<int, array{saved_because:?string,last_viewed_at:?string}>
*/
public function saveMetadataFor(User $user, array $collectionIds): array
{
if ($collectionIds === []) {
return [];
}
return CollectionSave::query()
->where('user_id', $user->id)
->whereIn('collection_id', $collectionIds)
->get(['collection_id', 'save_context', 'save_context_meta_json', 'last_viewed_at'])
->mapWithKeys(function (CollectionSave $save): array {
return [
(int) $save->collection_id => [
'saved_because' => $this->savedBecauseLabel($save),
'last_viewed_at' => $save->last_viewed_at?->toIso8601String(),
],
];
})
->all();
}
public function recentlyRevisited(User $user, int $limit = 6): SupportCollection
{
$savedIds = CollectionSave::query()
->where('user_id', $user->id)
->whereNotNull('last_viewed_at')
->orderByDesc('last_viewed_at')
->limit(max(1, min($limit, 12)))
->pluck('collection_id')
->map(static fn ($id): int => (int) $id)
->all();
if ($savedIds === []) {
return collect();
}
$collections = Collection::query()
->public()
->with([
'user:id,username,name',
'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at',
])
->whereIn('id', $savedIds)
->get()
->keyBy('id');
return collect($savedIds)
->map(fn (int $collectionId) => $collections->get($collectionId))
->filter()
->values();
}
public function listsFor(User $user): array
{
return CollectionSavedList::query()
->withCount('items')
->where('user_id', $user->id)
->orderBy('title')
->get()
->map(fn (CollectionSavedList $list) => [
'id' => (int) $list->id,
'title' => $list->title,
'slug' => $list->slug,
'items_count' => (int) $list->items_count,
])
->all();
}
public function findListBySlugForUser(User $user, string $slug): CollectionSavedList
{
return CollectionSavedList::query()
->withCount('items')
->where('user_id', $user->id)
->where('slug', $slug)
->firstOrFail();
}
/**
* @param array<int, int> $collectionIds
* @return array<int, array<int, int>>
*/
public function membershipsFor(User $user, array $collectionIds): array
{
if ($collectionIds === []) {
return [];
}
return DB::table('collection_saved_list_items as items')
->join('collection_saved_lists as lists', 'lists.id', '=', 'items.saved_list_id')
->where('lists.user_id', $user->id)
->whereIn('items.collection_id', $collectionIds)
->orderBy('items.saved_list_id')
->get(['items.collection_id', 'items.saved_list_id'])
->groupBy('collection_id')
->map(fn ($rows) => collect($rows)->pluck('saved_list_id')->map(static fn ($id) => (int) $id)->values()->all())
->mapWithKeys(fn ($listIds, $collectionId) => [(int) $collectionId => $listIds])
->all();
}
/**
* @param array<int, int> $collectionIds
* @return array<int, string>
*/
public function notesFor(User $user, array $collectionIds): array
{
if ($collectionIds === []) {
return [];
}
return CollectionSavedNote::query()
->where('user_id', $user->id)
->whereIn('collection_id', $collectionIds)
->pluck('note', 'collection_id')
->mapWithKeys(fn ($note, $collectionId) => [(int) $collectionId => (string) $note])
->all();
}
/**
* @return array<int, int>
*/
public function collectionIdsForList(User $user, CollectionSavedList $list): array
{
abort_unless((int) $list->user_id === (int) $user->id, 403);
return CollectionSavedListItem::query()
->where('saved_list_id', $list->id)
->orderBy('order_num')
->pluck('collection_id')
->map(static fn ($id) => (int) $id)
->all();
}
public function createList(User $user, string $title): CollectionSavedList
{
$slug = $this->uniqueSlug($user, $title);
return CollectionSavedList::query()->create([
'user_id' => $user->id,
'title' => $title,
'slug' => $slug,
]);
}
public function addToList(User $user, CollectionSavedList $list, Collection $collection): CollectionSavedListItem
{
abort_unless((int) $list->user_id === (int) $user->id, 403);
$nextOrder = (int) (CollectionSavedListItem::query()->where('saved_list_id', $list->id)->max('order_num') ?? -1) + 1;
return CollectionSavedListItem::query()->firstOrCreate(
[
'saved_list_id' => $list->id,
'collection_id' => $collection->id,
],
[
'order_num' => $nextOrder,
'created_at' => now(),
]
);
}
public function removeFromList(User $user, CollectionSavedList $list, Collection $collection): bool
{
abort_unless((int) $list->user_id === (int) $user->id, 403);
$deleted = CollectionSavedListItem::query()
->where('saved_list_id', $list->id)
->where('collection_id', $collection->id)
->delete();
if ($deleted > 0) {
$this->normalizeOrder($list);
}
return $deleted > 0;
}
/**
* @param array<int, int|string> $orderedCollectionIds
*/
public function reorderList(User $user, CollectionSavedList $list, array $orderedCollectionIds): void
{
abort_unless((int) $list->user_id === (int) $user->id, 403);
$normalizedIds = collect($orderedCollectionIds)
->map(static fn ($id) => (int) $id)
->filter(static fn (int $id) => $id > 0)
->values();
$currentIds = collect($this->collectionIdsForList($user, $list))->values();
if ($normalizedIds->count() !== $currentIds->count() || $normalizedIds->diff($currentIds)->isNotEmpty() || $currentIds->diff($normalizedIds)->isNotEmpty()) {
throw ValidationException::withMessages([
'collection_ids' => 'The submitted saved-list order is invalid.',
]);
}
DB::transaction(function () use ($list, $normalizedIds): void {
/** @var SupportCollection<int, int> $itemIds */
$itemIds = CollectionSavedListItem::query()
->where('saved_list_id', $list->id)
->whereIn('collection_id', $normalizedIds->all())
->pluck('id', 'collection_id')
->mapWithKeys(static fn ($id, $collectionId) => [(int) $collectionId => (int) $id]);
foreach ($normalizedIds as $index => $collectionId) {
$itemId = $itemIds->get($collectionId);
if (! $itemId) {
continue;
}
CollectionSavedListItem::query()
->whereKey($itemId)
->update(['order_num' => $index]);
}
});
}
public function itemsCount(CollectionSavedList $list): int
{
return (int) CollectionSavedListItem::query()
->where('saved_list_id', $list->id)
->count();
}
public function upsertNote(User $user, Collection $collection, ?string $note): ?CollectionSavedNote
{
$hasSavedCollection = DB::table('collection_saves')
->where('user_id', $user->id)
->where('collection_id', $collection->id)
->exists();
if (! $hasSavedCollection) {
throw ValidationException::withMessages([
'collection' => 'You can only add notes to collections saved in your library.',
]);
}
$normalizedNote = trim((string) ($note ?? ''));
if ($normalizedNote === '') {
CollectionSavedNote::query()
->where('user_id', $user->id)
->where('collection_id', $collection->id)
->delete();
return null;
}
return CollectionSavedNote::query()->updateOrCreate(
[
'user_id' => $user->id,
'collection_id' => $collection->id,
],
[
'note' => $normalizedNote,
]
);
}
private function uniqueSlug(User $user, string $title): string
{
$base = Str::slug(Str::limit($title, 80, '')) ?: 'saved-list';
$slug = $base;
$suffix = 2;
while (CollectionSavedList::query()->where('user_id', $user->id)->where('slug', $slug)->exists()) {
$slug = $base . '-' . $suffix;
$suffix++;
}
return $slug;
}
private function normalizeOrder(CollectionSavedList $list): void
{
$itemIds = CollectionSavedListItem::query()
->where('saved_list_id', $list->id)
->orderBy('order_num')
->orderBy('id')
->pluck('id');
foreach ($itemIds as $index => $itemId) {
CollectionSavedListItem::query()
->whereKey($itemId)
->update(['order_num' => $index]);
}
}
private function savedBecauseLabel(CollectionSave $save): ?string
{
$context = trim((string) ($save->save_context ?? ''));
$meta = is_array($save->save_context_meta_json) ? $save->save_context_meta_json : [];
return match ($context) {
'collection_detail' => 'Saved from the collection page',
'featured_collections' => 'Saved from featured collections',
'featured_landing' => 'Saved from featured collections',
'recommended_landing' => 'Saved from recommended collections',
'trending_landing' => 'Saved from trending collections',
'community_landing' => 'Saved from community collections',
'editorial_landing' => 'Saved from editorial collections',
'seasonal_landing' => 'Saved from seasonal collections',
'collection_search' => ! empty($meta['query']) ? sprintf('Saved from search for "%s"', (string) $meta['query']) : 'Saved from collection search',
'community_row', 'trending_row', 'editorial_row', 'seasonal_row', 'recent_row' => ! empty($meta['surface_label']) ? sprintf('Saved from %s', (string) $meta['surface_label']) : 'Saved from a collection rail',
'program_landing' => ! empty($meta['program_label']) ? sprintf('Saved from the %s program', (string) $meta['program_label']) : (! empty($meta['program_key']) ? sprintf('Saved from the %s program', (string) $meta['program_key']) : 'Saved from a program landing'),
'campaign_landing' => ! empty($meta['campaign_label']) ? sprintf('Saved during %s', (string) $meta['campaign_label']) : (! empty($meta['campaign_key']) ? sprintf('Saved during %s', (string) $meta['campaign_key']) : 'Saved from a campaign landing'),
default => $context !== '' ? str_replace('_', ' ', ucfirst($context)) : null,
};
}
}