*/ 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> */ 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>> */ 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> $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 $links * @return SupportCollection> */ 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 $ids * @return SupportCollection */ 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 */ 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> */ 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(); } }