Files
SkinbaseNova/app/Services/News/NewsService.php

1109 lines
45 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Services\News;
use App\Models\Artwork;
use App\Models\Collection;
use App\Models\Group;
use App\Models\GroupChallenge;
use App\Models\GroupEvent;
use App\Models\GroupProject;
use App\Models\GroupRelease;
use App\Models\NewsArticleComment;
use App\Models\User;
use App\Support\News\NewsCoverImage;
use App\Support\AvatarUrl;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use cPad\Plugins\News\Models\NewsArticle;
use cPad\Plugins\News\Models\NewsArticleRelation;
use cPad\Plugins\News\Models\NewsCategory;
use cPad\Plugins\News\Models\NewsTag;
use cPad\Plugins\News\Services\NewsArticleService;
final class NewsService
{
private const PUBLIC_CACHE_VERSION_KEY = 'news.public.cache.version';
public const RELATION_GROUP = 'group';
public const RELATION_ARTWORK = 'artwork';
public const RELATION_COLLECTION = 'collection';
public const RELATION_RELEASE = 'release';
public const RELATION_PROJECT = 'project';
public const RELATION_CHALLENGE = 'challenge';
public const RELATION_EVENT = 'event';
public const RELATION_USER = 'user';
public const RELATION_SOURCE = 'source';
public const RELATION_LABELS = [
self::RELATION_GROUP => 'Group',
self::RELATION_ARTWORK => 'Artwork',
self::RELATION_COLLECTION => 'Collection',
self::RELATION_RELEASE => 'Release',
self::RELATION_PROJECT => 'Project',
self::RELATION_CHALLENGE => 'Challenge',
self::RELATION_EVENT => 'Event',
self::RELATION_USER => 'Profile',
self::RELATION_SOURCE => 'Source',
];
private ?bool $artworkStatsViewsColumnExists = null;
public function __construct(private readonly NewsArticleService $articleService)
{
}
public function articleTypeOptions(): array
{
return \collect(NewsArticle::TYPE_LABELS)
->map(fn (string $label, string $value): array => ['value' => $value, 'label' => $label])
->values()
->all();
}
public function editorialStatusOptions(): array
{
return [
['value' => NewsArticle::EDITORIAL_STATUS_DRAFT, 'label' => 'Draft'],
['value' => NewsArticle::EDITORIAL_STATUS_IN_REVIEW, 'label' => 'In review'],
['value' => NewsArticle::EDITORIAL_STATUS_SCHEDULED, 'label' => 'Scheduled'],
['value' => NewsArticle::EDITORIAL_STATUS_PUBLISHED, 'label' => 'Published'],
['value' => NewsArticle::EDITORIAL_STATUS_ARCHIVED, 'label' => 'Archived'],
];
}
public function relationTypeOptions(): array
{
return \collect(self::RELATION_LABELS)
->map(fn (string $label, string $value): array => ['value' => $value, 'label' => $label])
->values()
->all();
}
public function categoryOptions(): array
{
return NewsCategory::query()
->ordered()
->get(['id', 'name'])
->map(fn (NewsCategory $category): array => [
'id' => (int) $category->id,
'name' => (string) $category->name,
])
->all();
}
public function tagOptions(): array
{
return NewsTag::query()
->orderBy('name')
->get(['id', 'name'])
->map(fn (NewsTag $tag): array => [
'id' => (int) $tag->id,
'name' => (string) $tag->name,
])
->all();
}
public function sidebarData(): array
{
return Cache::remember($this->publicCacheKey('sidebar'), $this->publicCacheTtl(), function (): array {
return [
'categories' => NewsCategory::active()->withCount('publishedArticles')->ordered()->get(),
'trending' => NewsArticle::published()
->with('category')
->orderByDesc('views')
->limit(config('news.trending_limit', 5))
->get(['id', 'title', 'slug', 'views', 'published_at', 'category_id', 'type']),
'tags' => NewsTag::query()
->whereHas('articles', fn ($query) => $query->published())
->withCount(['articles as published_articles_count' => fn ($query) => $query->published()])
->orderByDesc('published_articles_count')
->orderBy('name')
->limit((int) config('news.sidebar_tags_limit', 18))
->get(),
];
});
}
public function studioListing(array $filters = []): array
{
$query = NewsArticle::query()
->with(['author:id,username,name', 'category:id,name,slug', 'tags:id,name,slug'])
->editorialOrder();
$status = trim((string) ($filters['status'] ?? ''));
$type = trim((string) ($filters['type'] ?? ''));
$categoryId = (int) ($filters['category_id'] ?? 0);
$search = trim((string) ($filters['q'] ?? ''));
$perPage = max(10, min(50, (int) ($filters['per_page'] ?? 15)));
$order = trim((string) ($filters['order'] ?? ''));
$direction = trim((string) ($filters['direction'] ?? ''));
if ($status !== '') {
$query->where('editorial_status', $status);
}
if ($type !== '') {
$query->where('type', $type);
}
if ($categoryId > 0) {
$query->where('category_id', $categoryId);
}
if ($search !== '') {
$query->where(function (Builder $builder) use ($search): void {
$builder->where('title', 'like', '%' . $search . '%')
->orWhere('excerpt', 'like', '%' . $search . '%')
->orWhere('content', 'like', '%' . $search . '%')
->orWhere('meta_title', 'like', '%' . $search . '%');
});
}
if ($order !== '') {
$map = [
'date' => 'published_at',
'title' => 'title',
'views' => 'views',
];
if (array_key_exists($order, $map)) {
$dir = in_array(Str::lower($direction), ['asc', 'desc'], true) ? Str::lower($direction) : 'desc';
// Replace any existing ordering (editorialOrder) with the user-specified ordering.
$query->reorder($map[$order], $dir);
}
}
$paginator = $query->paginate($perPage)->withQueryString();
return [
'items' => $paginator->getCollection()->map(fn (NewsArticle $article): array => $this->mapStudioListItem($article))->all(),
'meta' => $this->paginationMeta($paginator),
'filters' => [
'q' => $search,
'status' => $status,
'type' => $type,
'category_id' => $categoryId > 0 ? $categoryId : '',
'per_page' => $perPage,
'order' => $order,
'direction' => in_array(Str::lower($direction), ['asc', 'desc'], true) ? Str::lower($direction) : '',
],
];
}
public function mapStudioArticle(NewsArticle $article, ?User $viewer = null): array
{
$article->loadMissing(['author.profile', 'category', 'tags', 'relatedEntities']);
return [
'id' => (int) $article->id,
'title' => $this->decodeLegacyHtml((string) $article->title),
'slug' => (string) $article->slug,
'excerpt' => (string) ($article->excerpt ?? ''),
'content' => (string) ($article->content ?? ''),
'cover_image' => (string) ($article->cover_image ?? ''),
'cover_url' => $article->cover_url,
'cover_mobile_url' => $article->cover_mobile_url,
'cover_desktop_url' => $article->cover_desktop_url,
'cover_srcset' => $article->cover_srcset,
'type' => (string) ($article->type ?? NewsArticle::TYPE_ANNOUNCEMENT),
'editorial_status' => (string) ($article->editorial_status ?? NewsArticle::EDITORIAL_STATUS_DRAFT),
'published_at' => \optional($article->published_at)?->toIso8601String(),
'is_featured' => (bool) $article->is_featured,
'is_pinned' => (bool) ($article->is_pinned ?? false),
'comments_enabled' => (bool) ($article->comments_enabled ?? false),
'category_id' => $article->category_id ? (int) $article->category_id : null,
'author_id' => (int) $article->author_id,
'author' => $article->author ? $this->mapUserLookupResult($article->author) : null,
'tag_ids' => $article->tags->pluck('id')->map(fn (mixed $id): int => (int) $id)->all(),
'meta_title' => (string) ($article->meta_title ?? ''),
'meta_description' => (string) ($article->meta_description ?? ''),
'meta_keywords' => (string) ($article->meta_keywords ?? ''),
'canonical_url' => (string) ($article->canonical_url ?? ''),
'og_title' => (string) ($article->og_title ?? ''),
'og_description' => (string) ($article->og_description ?? ''),
'og_image' => (string) ($article->og_image ?? ''),
'relations' => $article->relatedEntities
->map(function (NewsArticleRelation $relation) use ($viewer): array {
$entityType = (string) $relation->entity_type;
$externalUrl = $entityType === self::RELATION_SOURCE
? (string) ($relation->external_url ?? '')
: '';
return [
'entity_type' => $entityType,
'entity_id' => $entityType === self::RELATION_SOURCE ? '' : (int) $relation->entity_id,
'external_url' => $externalUrl,
'context_label' => (string) ($relation->context_label ?? ''),
'preview' => $entityType === self::RELATION_SOURCE
? $this->resolveSourcePreview($externalUrl, (string) ($relation->context_label ?? ''))
: $this->resolveEntityPreview($entityType, (int) $relation->entity_id, $viewer),
];
})
->values()
->all(),
];
}
public function storeArticle(User $editor, array $data): NewsArticle
{
$article = new NewsArticle();
$article->author_id = (int) ($data['author_id'] ?? $editor->id);
return $this->persistArticle($article, $editor, $data);
}
public function updateArticle(NewsArticle $article, User $editor, array $data): NewsArticle
{
return $this->persistArticle($article, $editor, $data);
}
public function deleteArticle(NewsArticle $article): void
{
$article->delete();
$this->invalidatePublicCache();
}
public function publish(NewsArticle $article): NewsArticle
{
$article->forceFill([
'editorial_status' => NewsArticle::EDITORIAL_STATUS_PUBLISHED,
'status' => 'published',
'published_at' => $article->published_at ?? \now(),
])->save();
$this->articleService->createForumThread($article);
$this->invalidatePublicCache();
return $article->fresh(['author', 'category', 'tags', 'relatedEntities']);
}
public function archive(NewsArticle $article): NewsArticle
{
$article->forceFill([
'editorial_status' => NewsArticle::EDITORIAL_STATUS_ARCHIVED,
'status' => 'draft',
])->save();
$this->invalidatePublicCache();
return $article->fresh(['author', 'category', 'tags', 'relatedEntities']);
}
public function toggleFeature(NewsArticle $article): NewsArticle
{
$article->forceFill(['is_featured' => ! $article->is_featured])->save();
$this->invalidatePublicCache();
return $article->fresh(['author', 'category', 'tags', 'relatedEntities']);
}
public function togglePin(NewsArticle $article): NewsArticle
{
$article->forceFill(['is_pinned' => ! (bool) $article->is_pinned])->save();
$this->invalidatePublicCache();
return $article->fresh(['author', 'category', 'tags', 'relatedEntities']);
}
public function searchEntities(string $type, string $query, ?User $viewer = null): array
{
$type = trim(Str::lower($type));
$query = trim($query);
return match ($type) {
self::RELATION_GROUP => $this->searchGroups($query, $viewer),
self::RELATION_ARTWORK => $this->searchArtworks($query),
self::RELATION_COLLECTION => $this->searchCollections($query, $viewer),
self::RELATION_RELEASE => $this->searchReleases($query, $viewer),
self::RELATION_PROJECT => $this->searchProjects($query, $viewer),
self::RELATION_CHALLENGE => $this->searchChallenges($query, $viewer),
self::RELATION_EVENT => $this->searchEvents($query, $viewer),
self::RELATION_USER => $this->searchUsers($query),
self::RELATION_SOURCE => [],
default => [],
};
}
public function resolveRelatedEntities(NewsArticle $article, ?User $viewer = null): array
{
$article->loadMissing('relatedEntities');
return $article->relatedEntities
->map(function (NewsArticleRelation $relation) use ($viewer): ?array {
$entityType = (string) $relation->entity_type;
if ($entityType === self::RELATION_SOURCE) {
return $this->resolveSourcePreview((string) ($relation->external_url ?? ''), (string) ($relation->context_label ?? ''));
}
return $this->resolveEntityPreview($entityType, (int) $relation->entity_id, $viewer, (string) ($relation->context_label ?? ''));
})
->filter()
->values()
->all();
}
public function publicArticleShowData(NewsArticle $article, ?User $viewer = null): array
{
if ($viewer !== null) {
return $this->buildPublicArticleShowData($article, $viewer);
}
return Cache::remember(
$this->publicCacheKey('article.show.' . $article->id),
$this->publicCacheTtl(),
fn (): array => $this->buildPublicArticleShowData($article, null),
);
}
private function buildPublicArticleShowData(NewsArticle $article, ?User $viewer = null): array
{
$related = NewsArticle::with('author', 'category')
->published()
->when($article->category_id, fn ($query) => $query->where('category_id', $article->category_id))
->where('id', '!=', $article->id)
->editorialOrder()
->limit(config('news.related_limit', 4))
->get();
$comments = collect();
$commentsCount = 0;
if ($article->commentsAreEnabled()) {
$comments = NewsArticleComment::query()
->where('article_id', $article->id)
->whereNull('parent_id')
->where('status', 'visible')
->with(['user.profile'])
->orderBy('created_at')
->orderBy('id')
->get();
$commentsCount = (int) NewsArticleComment::query()
->where('article_id', $article->id)
->where('status', 'visible')
->count();
}
return [
'related' => $related,
'relatedEntities' => $this->resolveRelatedEntities($article, $viewer),
'comments' => $comments,
'commentsCount' => $commentsCount,
];
}
public function invalidatePublicCache(): void
{
Cache::forever(self::PUBLIC_CACHE_VERSION_KEY, $this->publicCacheVersion() + 1);
Cache::forget('news.rss.feed');
}
public function syncRelations(NewsArticle $article, array $relations): void
{
$normalized = \collect($relations)
->map(function (array $relation): ?array {
$entityType = trim(Str::lower((string) ($relation['entity_type'] ?? '')));
$externalUrl = $entityType === self::RELATION_SOURCE
? $this->normalizeExternalRelationUrl($relation['external_url'] ?? $relation['entity_id'] ?? null)
: null;
$entityId = $entityType === self::RELATION_SOURCE ? null : (int) ($relation['entity_id'] ?? 0);
if (! array_key_exists($entityType, self::RELATION_LABELS)) {
return null;
}
if ($entityType === self::RELATION_SOURCE && $externalUrl === null) {
return null;
}
if ($entityType !== self::RELATION_SOURCE && $entityId < 1) {
return null;
}
return [
'entity_type' => $entityType,
'entity_id' => $entityId,
'external_url' => $externalUrl,
'context_label' => Str::limit(trim((string) ($relation['context_label'] ?? '')), 120, ''),
];
})
->filter()
->unique(fn (array $relation): string => $relation['entity_type'] . ':' . ($relation['entity_type'] === self::RELATION_SOURCE ? ($relation['external_url'] ?? '') : $relation['entity_id']))
->values();
$article->relatedEntities()->delete();
foreach ($normalized as $index => $relation) {
$article->relatedEntities()->create([
'entity_type' => $relation['entity_type'],
'entity_id' => $relation['entity_id'],
'external_url' => $relation['external_url'],
'context_label' => $relation['context_label'] !== '' ? $relation['context_label'] : null,
'sort_order' => $index,
]);
}
$this->invalidatePublicCache();
}
private function publicCacheKey(string $suffix): string
{
return 'news.public.v' . $this->publicCacheVersion() . '.' . $suffix;
}
private function publicCacheTtl(): int
{
return max(60, (int) config('news.public_cache_ttl', 120));
}
private function publicCacheVersion(): int
{
return (int) Cache::get(self::PUBLIC_CACHE_VERSION_KEY, 1);
}
private function persistArticle(NewsArticle $article, User $editor, array $data): NewsArticle
{
$title = trim((string) ($data['title'] ?? $article->title ?? 'Untitled News Article'));
if ($title === '') {
$title = 'Untitled News Article';
}
$slug = $this->resolveSlug($title, $article, $data);
$previousCoverImage = trim((string) ($article->cover_image ?? ''));
$editorialStatus = $this->normalizeEditorialStatus((string) ($data['editorial_status'] ?? $article->editorial_status ?? NewsArticle::EDITORIAL_STATUS_DRAFT));
$publishedAt = $this->normalizePublishedAt($editorialStatus, $data['published_at'] ?? $article->published_at);
$authorId = (int) ($data['author_id'] ?? $article->author_id ?? $editor->id);
$article->fill([
'title' => $title,
'slug' => $slug,
'excerpt' => $this->nullableText($data['excerpt'] ?? null),
'content' => (string) ($data['content'] ?? ''),
'cover_image' => $this->nullableText($data['cover_image'] ?? null),
'type' => (string) ($data['type'] ?? NewsArticle::TYPE_ANNOUNCEMENT),
'author_id' => $authorId,
'category_id' => ! empty($data['category_id']) ? (int) $data['category_id'] : null,
'editorial_status' => $editorialStatus,
'status' => $this->legacyStatusFor($editorialStatus),
'published_at' => $publishedAt,
'is_featured' => (bool) ($data['is_featured'] ?? false),
'is_pinned' => (bool) ($data['is_pinned'] ?? false),
'comments_enabled' => (bool) ($data['comments_enabled'] ?? false),
'meta_title' => $this->nullableText($data['meta_title'] ?? null),
'meta_description' => $this->nullableText($data['meta_description'] ?? null),
'meta_keywords' => $this->nullableText($data['meta_keywords'] ?? null),
'canonical_url' => route('news.show', ['slug' => $slug]),
'og_title' => $this->nullableText($data['og_title'] ?? null),
'og_description' => $this->nullableText($data['og_description'] ?? null),
'og_image' => $this->nullableText($data['og_image'] ?? null),
]);
if (! $article->save()) {
throw new \RuntimeException('Failed to save NewsArticle.');
}
$nextCoverImage = trim((string) ($article->cover_image ?? ''));
if ($previousCoverImage !== '' && $previousCoverImage !== $nextCoverImage) {
$this->deleteManagedCoverImage($previousCoverImage);
}
$article->tags()->sync($this->resolveArticleTagIds($data));
$this->syncRelations($article, $data['relations'] ?? []);
$this->invalidatePublicCache();
return $article->fresh(['author.profile', 'category', 'tags', 'relatedEntities']);
}
private function mapStudioListItem(NewsArticle $article): array
{
return [
'id' => (int) $article->id,
'title' => $this->decodeLegacyHtml((string) $article->title),
'slug' => (string) $article->slug,
'type' => (string) ($article->type ?? NewsArticle::TYPE_ANNOUNCEMENT),
'type_label' => (string) $article->type_label,
'editorial_status' => (string) ($article->editorial_status ?? NewsArticle::EDITORIAL_STATUS_DRAFT),
'published_at' => \optional($article->published_at)?->toIso8601String(),
'cover_url' => $article->cover_url,
'cover_srcset' => $article->cover_srcset,
'author_name' => (string) ($article->author?->name ?? 'Skinbase'),
'category_name' => (string) ($article->category?->name ?? ''),
'is_featured' => (bool) $article->is_featured,
'is_pinned' => (bool) ($article->is_pinned ?? false),
'views' => (int) $article->views,
'edit_url' => route('studio.news.edit', ['article' => $article->id]),
'delete_url' => route('studio.news.destroy', ['article' => $article->id]),
'preview_url' => route('studio.news.preview', ['article' => $article->id]),
'public_url' => route('news.show', ['slug' => $article->slug]),
];
}
private function paginationMeta(LengthAwarePaginator $paginator): array
{
return [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'from' => $paginator->firstItem(),
'to' => $paginator->lastItem(),
];
}
private function resolveSlug(string $title, NewsArticle $article, array $data): string
{
$requested = trim(Str::slug((string) ($data['slug'] ?? '')));
if ($requested !== '' && $requested !== (string) $article->slug) {
return NewsArticle::generateUniqueSlug($requested, $article->exists ? (int) $article->id : null);
}
if ($article->exists && trim((string) $article->slug) !== '') {
return (string) $article->slug;
}
return NewsArticle::generateUniqueSlug($title, $article->exists ? (int) $article->id : null);
}
private function normalizeEditorialStatus(string $status): string
{
return in_array($status, array_column($this->editorialStatusOptions(), 'value'), true)
? $status
: NewsArticle::EDITORIAL_STATUS_DRAFT;
}
private function normalizePublishedAt(string $editorialStatus, mixed $value): ?Carbon
{
if ($editorialStatus === NewsArticle::EDITORIAL_STATUS_PUBLISHED) {
return $value ? Carbon::parse((string) $value) : \now();
}
if ($editorialStatus === NewsArticle::EDITORIAL_STATUS_SCHEDULED) {
return $value ? Carbon::parse((string) $value) : \now()->addHour();
}
if ($value instanceof Carbon) {
return $value;
}
return $value ? Carbon::parse((string) $value) : null;
}
private function legacyStatusFor(string $editorialStatus): string
{
return match ($editorialStatus) {
NewsArticle::EDITORIAL_STATUS_PUBLISHED => 'published',
NewsArticle::EDITORIAL_STATUS_SCHEDULED => 'scheduled',
default => 'draft',
};
}
private function nullableText(mixed $value): ?string
{
$text = trim((string) ($value ?? ''));
return $text === '' ? null : $text;
}
private function resolveArticleTagIds(array $data): array
{
$existingIds = \collect($data['tag_ids'] ?? [])
->map(fn (mixed $id): int => (int) $id)
->filter()
->values();
$createdIds = \collect($data['new_tag_names'] ?? [])
->map(fn (mixed $name): string => trim(preg_replace('/\s+/', ' ', (string) $name) ?? ''))
->filter()
->unique(fn (string $name): string => Str::lower($name))
->map(function (string $name): ?int {
if (Str::slug($name) === '') {
return null;
}
return (int) NewsTag::findOrCreateByName($name)->id;
})
->filter()
->values();
return $existingIds
->merge($createdIds)
->unique()
->values()
->all();
}
private function deleteManagedCoverImage(string $path): void
{
$paths = NewsCoverImage::managedPaths($path);
if ($paths === []) {
return;
}
Storage::disk((string) config('uploads.object_storage.disk', 's3'))->delete($paths);
}
private function decodeLegacyHtml(string $value): string
{
$decoded = $value;
for ($pass = 0; $pass < 5; $pass++) {
$next = html_entity_decode($decoded, ENT_QUOTES | ENT_HTML5, 'UTF-8');
if ($next === $decoded) {
break;
}
$decoded = $next;
}
return str_replace(['´', '&acute;'], ["'", "'"], $decoded);
}
private function searchGroups(string $query, ?User $viewer): array
{
return Group::query()
->with('owner')
->where('visibility', Group::VISIBILITY_PUBLIC)
->when($query !== '', function (Builder $builder) use ($query): void {
$builder->where(function (Builder $nested) use ($query): void {
$nested->where('name', 'like', '%' . $query . '%')
->orWhere('slug', 'like', '%' . $query . '%')
->orWhere('headline', 'like', '%' . $query . '%');
});
})
->orderByDesc('followers_count')
->limit(8)
->get()
->map(fn (Group $group): ?array => $this->resolveGroupPreview((int) $group->id, $viewer, ''))
->filter()
->values()
->all();
}
private function searchArtworks(string $query): array
{
$queryBuilder = Artwork::query()
->with(['user.profile'])
->select('artworks.*')
->where('artwork_status', 'published')
->where('visibility', Artwork::VISIBILITY_PUBLIC)
->when($query !== '', function (Builder $builder) use ($query): void {
$builder->where(function (Builder $nested) use ($query): void {
$nested->where('title', 'like', '%' . $query . '%')
->orWhere('slug', 'like', '%' . $query . '%')
->orWhere('description', 'like', '%' . $query . '%');
});
})
->limit(8);
if ($this->artworkStatsViewsAvailable()) {
$queryBuilder->leftJoin('artwork_stats as stats', 'stats.artwork_id', '=', 'artworks.id')
->orderByRaw('COALESCE(stats.views, 0) DESC');
} else {
$queryBuilder->orderByDesc('published_at');
}
return $queryBuilder
->orderByDesc('artworks.id')
->get()
->map(fn (Artwork $artwork): ?array => $this->resolveArtworkPreview((int) $artwork->id, ''))
->filter()
->values()
->all();
}
private function artworkStatsViewsAvailable(): bool
{
if ($this->artworkStatsViewsColumnExists === null) {
$this->artworkStatsViewsColumnExists = Schema::hasTable('artwork_stats')
&& Schema::hasColumn('artwork_stats', 'views');
}
return $this->artworkStatsViewsColumnExists;
}
private function searchCollections(string $query, ?User $viewer): array
{
return Collection::query()
->with(['user', 'coverArtwork'])
->public()
->when($query !== '', function (Builder $builder) use ($query): void {
$builder->where(function (Builder $nested) use ($query): void {
$nested->where('title', 'like', '%' . $query . '%')
->orWhere('slug', 'like', '%' . $query . '%')
->orWhere('summary', 'like', '%' . $query . '%');
});
})
->orderByDesc('followers_count')
->limit(8)
->get()
->map(fn (Collection $collection): ?array => $this->resolveCollectionPreview((int) $collection->id, $viewer, ''))
->filter()
->values()
->all();
}
private function searchReleases(string $query, ?User $viewer): array
{
return GroupRelease::query()
->with('group')
->when($query !== '', fn (Builder $builder): Builder => $builder->where('title', 'like', '%' . $query . '%'))
->orderByDesc('published_at')
->limit(8)
->get()
->map(fn (GroupRelease $release): ?array => $this->resolveReleasePreview((int) $release->id, $viewer, ''))
->filter()
->values()
->all();
}
private function searchProjects(string $query, ?User $viewer): array
{
return GroupProject::query()
->with('group')
->when($query !== '', fn (Builder $builder): Builder => $builder->where('title', 'like', '%' . $query . '%'))
->orderByDesc('updated_at')
->limit(8)
->get()
->map(fn (GroupProject $project): ?array => $this->resolveProjectPreview((int) $project->id, $viewer, ''))
->filter()
->values()
->all();
}
private function searchChallenges(string $query, ?User $viewer): array
{
return GroupChallenge::query()
->with('group')
->when($query !== '', fn (Builder $builder): Builder => $builder->where('title', 'like', '%' . $query . '%'))
->orderByDesc('start_at')
->limit(8)
->get()
->map(fn (GroupChallenge $challenge): ?array => $this->resolveChallengePreview((int) $challenge->id, $viewer, ''))
->filter()
->values()
->all();
}
private function searchEvents(string $query, ?User $viewer): array
{
return GroupEvent::query()
->with('group')
->when($query !== '', fn (Builder $builder): Builder => $builder->where('title', 'like', '%' . $query . '%'))
->orderByDesc('start_at')
->limit(8)
->get()
->map(fn (GroupEvent $event): ?array => $this->resolveEventPreview((int) $event->id, $viewer, ''))
->filter()
->values()
->all();
}
private function searchUsers(string $query): array
{
return User::query()
->with('profile')
->when($query !== '', function (Builder $builder) use ($query): void {
$builder->where(function (Builder $nested) use ($query): void {
$nested->where('username', 'like', '%' . $query . '%')
->orWhere('name', 'like', '%' . $query . '%');
});
})
->orderBy('username')
->limit(8)
->get()
->map(fn (User $user): array => $this->mapUserLookupResult($user))
->values()
->all();
}
private function resolveEntityPreview(string $type, int $entityId, ?User $viewer = null, string $contextLabel = ''): ?array
{
return match ($type) {
self::RELATION_GROUP => $this->resolveGroupPreview($entityId, $viewer, $contextLabel),
self::RELATION_ARTWORK => $this->resolveArtworkPreview($entityId, $contextLabel),
self::RELATION_COLLECTION => $this->resolveCollectionPreview($entityId, $viewer, $contextLabel),
self::RELATION_RELEASE => $this->resolveReleasePreview($entityId, $viewer, $contextLabel),
self::RELATION_PROJECT => $this->resolveProjectPreview($entityId, $viewer, $contextLabel),
self::RELATION_CHALLENGE => $this->resolveChallengePreview($entityId, $viewer, $contextLabel),
self::RELATION_EVENT => $this->resolveEventPreview($entityId, $viewer, $contextLabel),
self::RELATION_USER => $this->resolveUserPreview($entityId, $contextLabel),
default => null,
};
}
private function resolveSourcePreview(string $externalUrl, string $contextLabel): ?array
{
$normalizedUrl = $this->normalizeExternalRelationUrl($externalUrl);
if ($normalizedUrl === null) {
return null;
}
$host = \parse_url($normalizedUrl, PHP_URL_HOST);
$host = \is_string($host) ? preg_replace('/^www\./i', '', $host) : null;
return [
'id' => $normalizedUrl,
'entity_type' => self::RELATION_SOURCE,
'entity_label' => self::RELATION_LABELS[self::RELATION_SOURCE],
'title' => $host ?: 'External source',
'subtitle' => 'Reference link',
'description' => Str::limit($normalizedUrl, 140),
'url' => $normalizedUrl,
'image' => null,
'avatar' => null,
'context_label' => $contextLabel !== '' ? $contextLabel : 'Source link',
'meta' => array_values(array_filter([
$host,
])),
];
}
private function resolveGroupPreview(int $entityId, ?User $viewer, string $contextLabel): ?array
{
$group = Group::query()->with('owner')->find($entityId);
if (! $group || ! $group->canBeViewedBy($viewer)) {
return null;
}
return [
'id' => (int) $group->id,
'entity_type' => self::RELATION_GROUP,
'entity_label' => self::RELATION_LABELS[self::RELATION_GROUP],
'title' => (string) $group->name,
'subtitle' => '@' . $group->slug,
'description' => Str::limit((string) ($group->headline ?: $group->bio ?: ''), 120),
'url' => $group->publicUrl(),
'image' => $group->bannerUrl(),
'avatar' => $group->avatarUrl(),
'context_label' => $contextLabel !== '' ? $contextLabel : 'Related Group',
'meta' => array_values(array_filter([
(int) $group->artworks_count > 0 ? number_format((int) $group->artworks_count) . ' artworks' : null,
(int) $group->followers_count > 0 ? number_format((int) $group->followers_count) . ' followers' : null,
])),
];
}
private function resolveArtworkPreview(int $entityId, string $contextLabel): ?array
{
$artwork = Artwork::query()->with(['user.profile'])->find($entityId);
if (! $artwork || (string) $artwork->artwork_status !== 'published' || (string) $artwork->visibility !== Artwork::VISIBILITY_PUBLIC) {
return null;
}
return [
'id' => (int) $artwork->id,
'entity_type' => self::RELATION_ARTWORK,
'entity_label' => self::RELATION_LABELS[self::RELATION_ARTWORK],
'title' => (string) ($artwork->title ?: 'Untitled artwork'),
'subtitle' => $artwork->user?->username ? '@' . $artwork->user->username : null,
'description' => Str::limit(trim(strip_tags((string) ($artwork->description ?? ''))), 120),
'url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug ?: Str::slug((string) $artwork->title)]),
'image' => $artwork->thumbUrl('lg') ?? $artwork->thumbUrl('md'),
'avatar' => null,
'context_label' => $contextLabel !== '' ? $contextLabel : 'Mentioned artwork',
'meta' => array_values(array_filter([
(int) $artwork->views > 0 ? number_format((int) $artwork->views) . ' views' : null,
$artwork->categories()->first()?->name,
])),
];
}
private function resolveCollectionPreview(int $entityId, ?User $viewer, string $contextLabel): ?array
{
$collection = Collection::query()->with(['user', 'coverArtwork'])->find($entityId);
if (! $collection || ! $collection->canBeViewedBy($viewer) || ! $collection->user?->username) {
return null;
}
return [
'id' => (int) $collection->id,
'entity_type' => self::RELATION_COLLECTION,
'entity_label' => self::RELATION_LABELS[self::RELATION_COLLECTION],
'title' => (string) $collection->title,
'subtitle' => '@' . $collection->user->username,
'description' => Str::limit((string) ($collection->summary ?: $collection->description ?: ''), 120),
'url' => route('profile.collections.show', ['username' => $collection->user->username, 'slug' => $collection->slug]),
'image' => $collection->coverArtwork?->thumbUrl('lg') ?? $collection->coverArtwork?->thumbUrl('md'),
'avatar' => null,
'context_label' => $contextLabel !== '' ? $contextLabel : 'Featured collection',
'meta' => array_values(array_filter([
(int) $collection->artworks_count > 0 ? number_format((int) $collection->artworks_count) . ' items' : null,
(int) $collection->followers_count > 0 ? number_format((int) $collection->followers_count) . ' followers' : null,
])),
];
}
private function resolveReleasePreview(int $entityId, ?User $viewer, string $contextLabel): ?array
{
$release = GroupRelease::query()->with('group')->find($entityId);
if (! $release || ! $release->group || ! $release->canBeViewedBy($viewer)) {
return null;
}
return [
'id' => (int) $release->id,
'entity_type' => self::RELATION_RELEASE,
'entity_label' => self::RELATION_LABELS[self::RELATION_RELEASE],
'title' => (string) $release->title,
'subtitle' => (string) $release->group->name,
'description' => Str::limit((string) ($release->summary ?: $release->description ?: ''), 120),
'url' => route('groups.releases.show', ['group' => $release->group, 'release' => $release]),
'image' => $release->coverUrl(),
'avatar' => $release->group->avatarUrl(),
'context_label' => $contextLabel !== '' ? $contextLabel : 'Featured release',
'meta' => array_values(array_filter([
$release->published_at?->format('d M Y'),
Str::headline((string) $release->status),
])),
];
}
private function resolveProjectPreview(int $entityId, ?User $viewer, string $contextLabel): ?array
{
$project = GroupProject::query()->with('group')->find($entityId);
if (! $project || ! $project->group || ! $project->canBeViewedBy($viewer)) {
return null;
}
return [
'id' => (int) $project->id,
'entity_type' => self::RELATION_PROJECT,
'entity_label' => self::RELATION_LABELS[self::RELATION_PROJECT],
'title' => (string) $project->title,
'subtitle' => (string) $project->group->name,
'description' => Str::limit((string) ($project->summary ?: $project->description ?: ''), 120),
'url' => route('groups.projects.show', ['group' => $project->group, 'project' => $project]),
'image' => $project->coverUrl(),
'avatar' => $project->group->avatarUrl(),
'context_label' => $contextLabel !== '' ? $contextLabel : 'Related project',
'meta' => array_values(array_filter([
Str::headline((string) $project->status),
$project->target_date?->format('d M Y'),
])),
];
}
private function resolveChallengePreview(int $entityId, ?User $viewer, string $contextLabel): ?array
{
$challenge = GroupChallenge::query()->with('group')->find($entityId);
if (! $challenge || ! $challenge->group || ! $challenge->canBeViewedBy($viewer)) {
return null;
}
return [
'id' => (int) $challenge->id,
'entity_type' => self::RELATION_CHALLENGE,
'entity_label' => self::RELATION_LABELS[self::RELATION_CHALLENGE],
'title' => (string) $challenge->title,
'subtitle' => (string) $challenge->group->name,
'description' => Str::limit((string) ($challenge->summary ?: $challenge->description ?: ''), 120),
'url' => route('groups.challenges.show', ['group' => $challenge->group, 'challenge' => $challenge]),
'image' => $challenge->coverUrl(),
'avatar' => $challenge->group->avatarUrl(),
'context_label' => $contextLabel !== '' ? $contextLabel : 'Join this challenge',
'meta' => array_values(array_filter([
$challenge->start_at?->format('d M Y'),
Str::headline((string) $challenge->status),
])),
];
}
private function resolveEventPreview(int $entityId, ?User $viewer, string $contextLabel): ?array
{
$event = GroupEvent::query()->with('group')->find($entityId);
if (! $event || ! $event->group || ! $event->canBeViewedBy($viewer)) {
return null;
}
return [
'id' => (int) $event->id,
'entity_type' => self::RELATION_EVENT,
'entity_label' => self::RELATION_LABELS[self::RELATION_EVENT],
'title' => (string) $event->title,
'subtitle' => (string) $event->group->name,
'description' => Str::limit((string) ($event->summary ?: $event->description ?: ''), 120),
'url' => route('groups.events.show', ['group' => $event->group, 'event' => $event]),
'image' => $event->coverUrl(),
'avatar' => $event->group->avatarUrl(),
'context_label' => $contextLabel !== '' ? $contextLabel : 'Upcoming event',
'meta' => array_values(array_filter([
$event->start_at?->format('d M Y H:i'),
Str::headline((string) $event->event_type),
])),
];
}
private function resolveUserPreview(int $entityId, string $contextLabel): ?array
{
$user = User::query()->with('profile')->find($entityId);
if (! $user || trim((string) $user->username) === '') {
return null;
}
return $this->mapUserLookupResult($user, $contextLabel !== '' ? $contextLabel : 'Meet the creator');
}
private function mapUserLookupResult(User $user, string $contextLabel = 'Profile'): array
{
return [
'id' => (int) $user->id,
'entity_type' => self::RELATION_USER,
'entity_label' => self::RELATION_LABELS[self::RELATION_USER],
'title' => (string) ($user->name ?: $user->username),
'subtitle' => $user->username ? '@' . $user->username : null,
'description' => Str::limit(trim((string) ($user->profile?->bio ?? '')), 120),
'url' => $user->username ? route('profile.show', ['username' => $user->username]) : null,
'image' => null,
'avatar' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash ?? null, 96),
'context_label' => $contextLabel,
'meta' => [],
];
}
private function normalizeExternalRelationUrl(mixed $value): ?string
{
$url = trim((string) ($value ?? ''));
if ($url === '') {
return null;
}
if (preg_match('/^\[[^\]]+\]\((https?:\/\/[^)]+)\)$/i', $url, $matches) === 1) {
$url = trim((string) ($matches[1] ?? ''));
}
if ($url === '') {
return null;
}
return Str::limit($url, 2048, '');
}
}