515 lines
20 KiB
PHP
515 lines
20 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Artwork;
|
|
use App\Models\Category;
|
|
use App\Models\Collection;
|
|
use App\Models\CollectionEntityLink;
|
|
use App\Models\Story;
|
|
use App\Models\Tag;
|
|
use App\Models\User;
|
|
use App\Support\AvatarUrl;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Support\Collection as SupportCollection;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class CollectionLinkService
|
|
{
|
|
public const TYPE_CREATOR = 'creator';
|
|
public const TYPE_ARTWORK = 'artwork';
|
|
public const TYPE_STORY = 'story';
|
|
public const TYPE_CATEGORY = 'category';
|
|
public const TYPE_TAG = 'tag';
|
|
public const TYPE_CAMPAIGN = 'campaign';
|
|
public const TYPE_EVENT = 'event';
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
public static function supportedTypes(): array
|
|
{
|
|
return [
|
|
self::TYPE_CREATOR,
|
|
self::TYPE_ARTWORK,
|
|
self::TYPE_STORY,
|
|
self::TYPE_CATEGORY,
|
|
self::TYPE_TAG,
|
|
self::TYPE_CAMPAIGN,
|
|
self::TYPE_EVENT,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function links(Collection $collection, bool $publicOnly = false): array
|
|
{
|
|
$links = CollectionEntityLink::query()
|
|
->where('collection_id', $collection->id)
|
|
->orderBy('id')
|
|
->get();
|
|
|
|
return $this->mapLinks($links, $publicOnly)->values()->all();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, array<int, array<string, mixed>>>
|
|
*/
|
|
public function manageableOptions(Collection $collection): array
|
|
{
|
|
$existingIdsByType = CollectionEntityLink::query()
|
|
->where('collection_id', $collection->id)
|
|
->get()
|
|
->groupBy('linked_type')
|
|
->map(fn (SupportCollection $items): array => $items->pluck('linked_id')->map(fn ($id): int => (int) $id)->all());
|
|
|
|
$creatorOptions = User::query()
|
|
->whereNotNull('username')
|
|
->orderByDesc('id')
|
|
->limit(24)
|
|
->get()
|
|
->reject(fn (User $user): bool => in_array((int) $user->id, $existingIdsByType->get(self::TYPE_CREATOR, []), true))
|
|
->map(fn (User $user): array => [
|
|
'id' => (int) $user->id,
|
|
'label' => $user->name ?: (string) $user->username,
|
|
'description' => $user->username ? '@' . strtolower((string) $user->username) : 'Creator',
|
|
])
|
|
->values()
|
|
->all();
|
|
|
|
$artworkOptions = Artwork::query()
|
|
->with(['user:id,username,name', 'categories.contentType:id,name'])
|
|
->public()
|
|
->latest('published_at')
|
|
->latest('id')
|
|
->limit(24)
|
|
->get()
|
|
->reject(fn (Artwork $artwork): bool => in_array((int) $artwork->id, $existingIdsByType->get(self::TYPE_ARTWORK, []), true))
|
|
->map(fn (Artwork $artwork): array => [
|
|
'id' => (int) $artwork->id,
|
|
'label' => (string) $artwork->title,
|
|
'description' => collect([
|
|
$artwork->user?->username ? '@' . strtolower((string) $artwork->user->username) : null,
|
|
$artwork->categories->first()?->contentType?->name,
|
|
])->filter()->join(' • ') ?: 'Published artwork',
|
|
])
|
|
->values()
|
|
->all();
|
|
|
|
$storyOptions = Story::query()
|
|
->with('creator:id,username,name')
|
|
->published()
|
|
->orderByDesc('published_at')
|
|
->limit(24)
|
|
->get()
|
|
->reject(fn (Story $story): bool => in_array((int) $story->id, $existingIdsByType->get(self::TYPE_STORY, []), true))
|
|
->map(fn (Story $story): array => [
|
|
'id' => (int) $story->id,
|
|
'label' => (string) $story->title,
|
|
'description' => $story->creator?->username ? '@' . strtolower((string) $story->creator->username) : 'Published story',
|
|
])
|
|
->values()
|
|
->all();
|
|
|
|
$categoryOptions = Category::query()
|
|
->with('contentType:id,slug,name')
|
|
->active()
|
|
->orderBy('sort_order')
|
|
->orderBy('name')
|
|
->limit(24)
|
|
->get()
|
|
->reject(fn (Category $category): bool => in_array((int) $category->id, $existingIdsByType->get(self::TYPE_CATEGORY, []), true))
|
|
->map(fn (Category $category): array => [
|
|
'id' => (int) $category->id,
|
|
'label' => (string) $category->name,
|
|
'description' => $category->contentType?->name ? $category->contentType->name . ' category' : 'Category',
|
|
])
|
|
->values()
|
|
->all();
|
|
|
|
$tagOptions = Tag::query()
|
|
->where('is_active', true)
|
|
->orderByDesc('usage_count')
|
|
->orderBy('name')
|
|
->limit(24)
|
|
->get()
|
|
->reject(fn (Tag $tag): bool => in_array((int) $tag->id, $existingIdsByType->get(self::TYPE_TAG, []), true))
|
|
->map(fn (Tag $tag): array => [
|
|
'id' => (int) $tag->id,
|
|
'label' => (string) $tag->name,
|
|
'description' => '#' . strtolower((string) $tag->slug),
|
|
])
|
|
->values()
|
|
->all();
|
|
|
|
$campaignOptions = $this->syntheticLinkOptions(self::TYPE_CAMPAIGN);
|
|
$eventOptions = $this->syntheticLinkOptions(self::TYPE_EVENT);
|
|
|
|
return [
|
|
self::TYPE_CREATOR => $creatorOptions,
|
|
self::TYPE_ARTWORK => $artworkOptions,
|
|
self::TYPE_STORY => $storyOptions,
|
|
self::TYPE_CATEGORY => $categoryOptions,
|
|
self::TYPE_TAG => $tagOptions,
|
|
self::TYPE_CAMPAIGN => $campaignOptions,
|
|
self::TYPE_EVENT => $eventOptions,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $links
|
|
*/
|
|
public function syncLinks(Collection $collection, User $actor, array $links): Collection
|
|
{
|
|
$normalized = collect($links)
|
|
->map(function ($item): ?array {
|
|
$type = is_array($item) ? (string) ($item['linked_type'] ?? '') : '';
|
|
$linkedId = is_array($item) ? (int) ($item['linked_id'] ?? 0) : 0;
|
|
$relationshipType = is_array($item) ? trim((string) ($item['relationship_type'] ?? '')) : '';
|
|
|
|
if (! in_array($type, self::supportedTypes(), true) || $linkedId <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'linked_type' => $type,
|
|
'linked_id' => $linkedId,
|
|
'relationship_type' => $relationshipType !== '' ? $relationshipType : null,
|
|
];
|
|
})
|
|
->filter()
|
|
->unique(fn (array $item): string => $item['linked_type'] . ':' . $item['linked_id'])
|
|
->values();
|
|
|
|
foreach (self::supportedTypes() as $type) {
|
|
$ids = $normalized->where('linked_type', $type)->pluck('linked_id')->map(fn ($id): int => (int) $id)->all();
|
|
if ($ids === []) {
|
|
continue;
|
|
}
|
|
|
|
if ($this->isSyntheticType($type)) {
|
|
$resolved = collect($ids)
|
|
->map(fn (int $id): ?array => $this->syntheticLinkDescriptorForId($type, $id))
|
|
->filter()
|
|
->values();
|
|
} else {
|
|
$resolved = $this->resolvedEntities($type, $ids, false);
|
|
}
|
|
|
|
if ($resolved->count() !== count($ids)) {
|
|
throw ValidationException::withMessages([
|
|
'entity_links' => 'Choose valid entities to link to this collection.',
|
|
]);
|
|
}
|
|
}
|
|
|
|
$before = $this->links($collection, false);
|
|
|
|
DB::transaction(function () use ($collection, $normalized): void {
|
|
CollectionEntityLink::query()
|
|
->where('collection_id', $collection->id)
|
|
->delete();
|
|
|
|
if ($normalized->isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
$now = now();
|
|
|
|
CollectionEntityLink::query()->insert($normalized->map(fn (array $item): array => [
|
|
'collection_id' => (int) $collection->id,
|
|
'linked_type' => $item['linked_type'],
|
|
'linked_id' => (int) $item['linked_id'],
|
|
'relationship_type' => $item['relationship_type'],
|
|
'metadata_json' => $this->isSyntheticType((string) $item['linked_type'])
|
|
? json_encode($this->syntheticLinkDescriptorForId((string) $item['linked_type'], (int) $item['linked_id']), JSON_THROW_ON_ERROR)
|
|
: null,
|
|
'created_at' => $now,
|
|
'updated_at' => $now,
|
|
])->all());
|
|
});
|
|
|
|
$fresh = $collection->fresh(['user.profile', 'coverArtwork']);
|
|
|
|
app(\App\Services\CollectionHistoryService::class)->record(
|
|
$fresh,
|
|
$actor,
|
|
'entity_links_updated',
|
|
'Collection entity links updated.',
|
|
['entity_links' => $before],
|
|
['entity_links' => $this->links($fresh, false)]
|
|
);
|
|
|
|
return $fresh;
|
|
}
|
|
|
|
/**
|
|
* @param SupportCollection<int, CollectionEntityLink> $links
|
|
* @return SupportCollection<int, array<string, mixed>>
|
|
*/
|
|
private function mapLinks(SupportCollection $links, bool $publicOnly): SupportCollection
|
|
{
|
|
$entityMaps = collect(self::supportedTypes())
|
|
->reject(fn (string $type): bool => $this->isSyntheticType($type))
|
|
->mapWithKeys(function (string $type) use ($links, $publicOnly): array {
|
|
$ids = $links->where('linked_type', $type)->pluck('linked_id')->map(fn ($id): int => (int) $id)->all();
|
|
|
|
return [$type => $this->resolvedEntities($type, $ids, $publicOnly)];
|
|
});
|
|
|
|
return $links->map(function (CollectionEntityLink $link) use ($entityMaps): ?array {
|
|
if ($this->isSyntheticType((string) $link->linked_type)) {
|
|
return $this->mapSyntheticLink($link);
|
|
}
|
|
|
|
$entity = $entityMaps->get((string) $link->linked_type)?->get((int) $link->linked_id);
|
|
|
|
if (! $entity instanceof Model) {
|
|
return null;
|
|
}
|
|
|
|
return $this->mapLink($link, $entity);
|
|
})->filter()->values();
|
|
}
|
|
|
|
/**
|
|
* @param array<int, int> $ids
|
|
* @return SupportCollection<int, Model>
|
|
*/
|
|
private function resolvedEntities(string $type, array $ids, bool $publicOnly): SupportCollection
|
|
{
|
|
if ($ids === []) {
|
|
return collect();
|
|
}
|
|
|
|
return match ($type) {
|
|
self::TYPE_CREATOR => User::query()
|
|
->whereIn('id', $ids)
|
|
->whereNotNull('username')
|
|
->get()
|
|
->keyBy('id'),
|
|
self::TYPE_ARTWORK => Artwork::query()
|
|
->with(['user:id,username,name', 'categories.contentType:id,name'])
|
|
->whereIn('id', $ids)
|
|
->when($publicOnly, fn ($query) => $query->public())
|
|
->get()
|
|
->keyBy('id'),
|
|
self::TYPE_STORY => Story::query()
|
|
->with('creator:id,username,name')
|
|
->whereIn('id', $ids)
|
|
->when($publicOnly, fn ($query) => $query->published())
|
|
->get()
|
|
->keyBy('id'),
|
|
self::TYPE_CATEGORY => Category::query()
|
|
->with('contentType:id,slug,name')
|
|
->whereIn('id', $ids)
|
|
->when($publicOnly, fn ($query) => $query->active())
|
|
->get()
|
|
->keyBy('id'),
|
|
self::TYPE_TAG => Tag::query()
|
|
->whereIn('id', $ids)
|
|
->when($publicOnly, fn ($query) => $query->where('is_active', true))
|
|
->get()
|
|
->keyBy('id'),
|
|
default => collect(),
|
|
};
|
|
}
|
|
|
|
private function mapLink(CollectionEntityLink $link, Model $entity): array
|
|
{
|
|
return match ((string) $link->linked_type) {
|
|
self::TYPE_CREATOR => [
|
|
'id' => (int) $link->id,
|
|
'linked_type' => self::TYPE_CREATOR,
|
|
'linked_id' => (int) $entity->getKey(),
|
|
'relationship_type' => $link->relationship_type,
|
|
'title' => $entity->name ?: (string) $entity->username,
|
|
'subtitle' => $entity->username ? '@' . strtolower((string) $entity->username) : 'Creator',
|
|
'description' => $link->relationship_type ?: 'Linked creator',
|
|
'url' => route('profile.show', ['username' => strtolower((string) $entity->username)]),
|
|
'image_url' => AvatarUrl::forUser((int) $entity->id),
|
|
'meta' => 'Creator',
|
|
],
|
|
self::TYPE_ARTWORK => [
|
|
'id' => (int) $link->id,
|
|
'linked_type' => self::TYPE_ARTWORK,
|
|
'linked_id' => (int) $entity->getKey(),
|
|
'relationship_type' => $link->relationship_type,
|
|
'title' => (string) $entity->title,
|
|
'subtitle' => collect([
|
|
$entity->user?->username ? '@' . strtolower((string) $entity->user->username) : null,
|
|
$entity->categories->first()?->contentType?->name,
|
|
])->filter()->join(' • ') ?: 'Artwork',
|
|
'description' => $link->relationship_type ?: 'Linked artwork',
|
|
'url' => route('art.show', [
|
|
'id' => (int) $entity->id,
|
|
'slug' => Str::slug((string) ($entity->slug ?: $entity->title)) ?: (string) $entity->id,
|
|
]),
|
|
'image_url' => $entity->thumbUrl('md') ?? $entity->thumbnail_url,
|
|
'meta' => 'Artwork',
|
|
],
|
|
self::TYPE_STORY => [
|
|
'id' => (int) $link->id,
|
|
'linked_type' => self::TYPE_STORY,
|
|
'linked_id' => (int) $entity->getKey(),
|
|
'relationship_type' => $link->relationship_type,
|
|
'title' => (string) $entity->title,
|
|
'subtitle' => $entity->creator?->username ? '@' . strtolower((string) $entity->creator->username) : 'Story',
|
|
'description' => $entity->excerpt ?: ($link->relationship_type ?: 'Linked story'),
|
|
'url' => $entity->url,
|
|
'image_url' => $entity->cover_url,
|
|
'meta' => 'Story',
|
|
],
|
|
self::TYPE_CATEGORY => [
|
|
'id' => (int) $link->id,
|
|
'linked_type' => self::TYPE_CATEGORY,
|
|
'linked_id' => (int) $entity->getKey(),
|
|
'relationship_type' => $link->relationship_type,
|
|
'title' => (string) $entity->name,
|
|
'subtitle' => $entity->contentType?->name ? $entity->contentType->name . ' category' : 'Category',
|
|
'description' => $entity->description ?: ($link->relationship_type ?: 'Linked category'),
|
|
'url' => url($entity->url),
|
|
'image_url' => $entity->image ? asset($entity->image) : null,
|
|
'meta' => 'Category',
|
|
],
|
|
self::TYPE_TAG => [
|
|
'id' => (int) $link->id,
|
|
'linked_type' => self::TYPE_TAG,
|
|
'linked_id' => (int) $entity->getKey(),
|
|
'relationship_type' => $link->relationship_type,
|
|
'title' => (string) $entity->name,
|
|
'subtitle' => '#' . strtolower((string) $entity->slug),
|
|
'description' => $link->relationship_type ?: sprintf('Theme tag · %d uses', (int) $entity->usage_count),
|
|
'url' => route('tags.show', ['tag' => $entity]),
|
|
'image_url' => null,
|
|
'meta' => 'Tag',
|
|
],
|
|
default => [],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{id:int,label:string,description:string}>
|
|
*/
|
|
private function syntheticLinkOptions(string $type): array
|
|
{
|
|
return $this->syntheticLinkDescriptors($type)
|
|
->map(fn (array $item): array => [
|
|
'id' => (int) $item['id'],
|
|
'label' => (string) $item['label'],
|
|
'description' => (string) $item['description'],
|
|
])
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function isSyntheticType(string $type): bool
|
|
{
|
|
return in_array($type, [self::TYPE_CAMPAIGN, self::TYPE_EVENT], true);
|
|
}
|
|
|
|
/**
|
|
* @return SupportCollection<int, array<string, mixed>>
|
|
*/
|
|
private function syntheticLinkDescriptors(string $type): SupportCollection
|
|
{
|
|
return match ($type) {
|
|
self::TYPE_CAMPAIGN => Collection::query()
|
|
->whereNotNull('campaign_key')
|
|
->where('campaign_key', '!=', '')
|
|
->orderBy('campaign_label')
|
|
->orderBy('campaign_key')
|
|
->get(['campaign_key', 'campaign_label'])
|
|
->unique('campaign_key')
|
|
->map(function (Collection $collection): array {
|
|
$key = (string) $collection->campaign_key;
|
|
|
|
return [
|
|
'id' => $this->syntheticLinkId(self::TYPE_CAMPAIGN, $key),
|
|
'key' => $key,
|
|
'label' => (string) ($collection->campaign_label ?: $this->humanizeToken($key)),
|
|
'description' => 'Campaign landing',
|
|
'subtitle' => $key,
|
|
'url' => route('collections.campaign.show', ['campaignKey' => $key]),
|
|
'meta' => 'Campaign',
|
|
];
|
|
})
|
|
->values(),
|
|
self::TYPE_EVENT => Collection::query()
|
|
->whereNotNull('event_key')
|
|
->where('event_key', '!=', '')
|
|
->orderBy('event_label')
|
|
->orderBy('event_key')
|
|
->get(['event_key', 'event_label', 'season_key'])
|
|
->unique('event_key')
|
|
->map(function (Collection $collection): array {
|
|
$key = (string) $collection->event_key;
|
|
$seasonKey = filled($collection->season_key) ? (string) $collection->season_key : null;
|
|
|
|
return [
|
|
'id' => $this->syntheticLinkId(self::TYPE_EVENT, $key),
|
|
'key' => $key,
|
|
'label' => (string) ($collection->event_label ?: $this->humanizeToken($key)),
|
|
'description' => $seasonKey ? 'Event context · ' . $this->humanizeToken($seasonKey) : 'Event context',
|
|
'subtitle' => $seasonKey ? 'Season ' . $this->humanizeToken($seasonKey) : $key,
|
|
'url' => null,
|
|
'meta' => 'Event',
|
|
'season_key' => $seasonKey,
|
|
];
|
|
})
|
|
->values(),
|
|
default => collect(),
|
|
};
|
|
}
|
|
|
|
private function syntheticLinkDescriptorForId(string $type, int $id): ?array
|
|
{
|
|
return $this->syntheticLinkDescriptors($type)
|
|
->first(fn (array $item): bool => (int) $item['id'] === $id);
|
|
}
|
|
|
|
private function syntheticLinkId(string $type, string $key): int
|
|
{
|
|
return (int) hexdec(substr(md5($type . ':' . mb_strtolower($key)), 0, 7));
|
|
}
|
|
|
|
private function mapSyntheticLink(CollectionEntityLink $link): ?array
|
|
{
|
|
$descriptor = is_array($link->metadata_json) && $link->metadata_json !== []
|
|
? $link->metadata_json
|
|
: $this->syntheticLinkDescriptorForId((string) $link->linked_type, (int) $link->linked_id);
|
|
|
|
if (! is_array($descriptor) || empty($descriptor['label'])) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'id' => (int) $link->id,
|
|
'linked_type' => (string) $link->linked_type,
|
|
'linked_id' => (int) $link->linked_id,
|
|
'relationship_type' => $link->relationship_type,
|
|
'title' => (string) $descriptor['label'],
|
|
'subtitle' => $descriptor['subtitle'] ?? ((string) ($descriptor['key'] ?? '')),
|
|
'description' => $link->relationship_type ?: (string) ($descriptor['description'] ?? 'Linked context'),
|
|
'url' => $descriptor['url'] ?? null,
|
|
'image_url' => null,
|
|
'meta' => (string) ($descriptor['meta'] ?? $this->humanizeToken((string) $link->linked_type)),
|
|
'context_key' => $descriptor['key'] ?? null,
|
|
'season_key' => $descriptor['season_key'] ?? null,
|
|
];
|
|
}
|
|
|
|
private function humanizeToken(string $value): string
|
|
{
|
|
return str($value)
|
|
->replace(['_', '-'], ' ')
|
|
->title()
|
|
->value();
|
|
}
|
|
}
|