optimizations

This commit is contained in:
2026-03-28 19:15:39 +01:00
parent 0b25d9570a
commit cab4fbd83e
509 changed files with 1016804 additions and 1605 deletions

View File

@@ -0,0 +1,514 @@
<?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();
}
}