Files
SkinbaseNova/.deploy/artwork-evolution-release/app/Services/GroupService.php
2026-04-18 17:02:56 +02:00

842 lines
37 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Collection;
use App\Models\Group;
use App\Models\User;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\UploadedFile;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class GroupService
{
public function __construct(
private readonly GroupMembershipService $memberships,
private readonly GroupCardService $cards,
private readonly CollectionService $collections,
private readonly GroupMediaService $media,
private readonly GroupJoinRequestService $joinRequests,
private readonly GroupRecruitmentService $recruitment,
private readonly GroupPostService $posts,
private readonly GroupProjectService $projects,
private readonly GroupReleaseService $releases,
private readonly GroupChallengeService $challenges,
private readonly GroupEventService $events,
private readonly GroupAssetService $assets,
private readonly GroupActivityService $activity,
private readonly GroupHistoryService $history,
private readonly GroupReputationService $reputation,
private readonly ArtworkMaturityService $maturity,
) {
}
public function makeUniqueSlug(string $source, ?int $ignoreGroupId = null): string
{
$base = Str::slug(Str::limit($source, 90, ''));
$base = $base !== '' ? $base : 'group';
$slug = $base;
$suffix = 2;
while (Group::query()
->where('slug', $slug)
->when($ignoreGroupId !== null, fn ($query) => $query->where('id', '!=', $ignoreGroupId))
->exists()) {
$slug = Str::limit($base, 84, '') . '-' . $suffix;
$suffix++;
}
return $slug;
}
public function createGroup(User $owner, array $attributes): Group
{
$storedAvatarPath = null;
$storedBannerPath = null;
try {
return DB::transaction(function () use ($owner, $attributes, &$storedAvatarPath, &$storedBannerPath): Group {
$group = new Group();
$group->owner()->associate($owner);
$group->featured_artwork_id = null;
$group->is_verified = false;
$group->founded_at = $attributes['founded_at'] ?? null;
$group->name = (string) $attributes['name'];
$group->slug = $this->makeUniqueSlug((string) ($attributes['slug'] ?? $attributes['name']));
$group->headline = $attributes['headline'] ?? null;
$group->bio = $attributes['bio'] ?? null;
$group->type = $attributes['type'] ?? null;
$group->visibility = (string) ($attributes['visibility'] ?? Group::VISIBILITY_PUBLIC);
$group->status = Group::LIFECYCLE_ACTIVE;
$group->membership_policy = (string) ($attributes['membership_policy'] ?? Group::MEMBERSHIP_INVITE_ONLY);
$group->website_url = $attributes['website_url'] ?? null;
$group->links_json = $this->normalizeLinks($attributes['links_json'] ?? []);
$group->avatar_path = $this->normalizeMediaPath($attributes['avatar_path'] ?? null);
$group->banner_path = $this->normalizeMediaPath($attributes['banner_path'] ?? null);
$group->artworks_count = 0;
$group->collections_count = 0;
$group->followers_count = 0;
$group->last_activity_at = now();
$group->save();
if (($attributes['avatar_file'] ?? null) instanceof UploadedFile) {
$storedAvatarPath = $this->media->storeUploadedImage($group, $attributes['avatar_file'], 'avatar');
$group->avatar_path = $storedAvatarPath;
}
if (($attributes['banner_file'] ?? null) instanceof UploadedFile) {
$storedBannerPath = $this->media->storeUploadedImage($group, $attributes['banner_file'], 'banner');
$group->banner_path = $storedBannerPath;
}
if ($storedAvatarPath !== null || $storedBannerPath !== null) {
$group->save();
}
$this->memberships->ensureOwnerMembership($group);
return $group->fresh(['owner.profile']);
});
} catch (\Throwable $exception) {
$this->media->deleteIfManaged($storedAvatarPath);
$this->media->deleteIfManaged($storedBannerPath);
throw $exception;
}
}
public function updateGroup(Group $group, array $attributes, User $actor): Group
{
$storedAvatarPath = null;
$storedBannerPath = null;
$obsoleteAvatarPath = null;
$obsoleteBannerPath = null;
try {
$updatedGroup = DB::transaction(function () use ($group, $attributes, $actor, &$storedAvatarPath, &$storedBannerPath, &$obsoleteAvatarPath, &$obsoleteBannerPath): Group {
$originalAvatarPath = $group->avatar_path;
$originalBannerPath = $group->banner_path;
$group->fill([
'name' => (string) ($attributes['name'] ?? $group->name),
'slug' => $this->makeUniqueSlug((string) ($attributes['slug'] ?? $attributes['name'] ?? $group->slug), (int) $group->id),
'headline' => $attributes['headline'] ?? null,
'bio' => $attributes['bio'] ?? null,
'type' => $attributes['type'] ?? $group->type,
'visibility' => (string) ($attributes['visibility'] ?? $group->visibility),
'membership_policy' => (string) ($attributes['membership_policy'] ?? $group->membership_policy ?? Group::MEMBERSHIP_INVITE_ONLY),
'founded_at' => $attributes['founded_at'] ?? $group->founded_at,
'website_url' => $attributes['website_url'] ?? null,
'links_json' => $this->normalizeLinks($attributes['links_json'] ?? $group->links_json ?? []),
'avatar_path' => array_key_exists('avatar_path', $attributes) ? $this->normalizeMediaPath($attributes['avatar_path']) : $group->avatar_path,
'banner_path' => array_key_exists('banner_path', $attributes) ? $this->normalizeMediaPath($attributes['banner_path']) : $group->banner_path,
'featured_artwork_id' => $this->normalizeFeaturedArtworkId($group, $attributes['featured_artwork_id'] ?? $group->featured_artwork_id),
'last_activity_at' => now(),
]);
$group->save();
if (($attributes['avatar_file'] ?? null) instanceof UploadedFile) {
$storedAvatarPath = $this->media->storeUploadedImage($group, $attributes['avatar_file'], 'avatar');
$group->avatar_path = $storedAvatarPath;
}
if (($attributes['banner_file'] ?? null) instanceof UploadedFile) {
$storedBannerPath = $this->media->storeUploadedImage($group, $attributes['banner_file'], 'banner');
$group->banner_path = $storedBannerPath;
}
if ($storedAvatarPath !== null || $storedBannerPath !== null) {
$group->save();
}
$this->memberships->ensureOwnerMembership($group);
$obsoleteAvatarPath = $originalAvatarPath !== $group->avatar_path ? $originalAvatarPath : null;
$obsoleteBannerPath = $originalBannerPath !== $group->banner_path ? $originalBannerPath : null;
return $group->fresh(['owner.profile']);
});
} catch (\Throwable $exception) {
$this->media->deleteIfManaged($storedAvatarPath);
$this->media->deleteIfManaged($storedBannerPath);
throw $exception;
}
$this->media->deleteIfManaged($obsoleteAvatarPath);
$this->media->deleteIfManaged($obsoleteBannerPath);
return $updatedGroup;
}
public function syncArtworkCount(Group $group): void
{
$group->forceFill([
'artworks_count' => Artwork::query()
->where('group_id', $group->id)
->whereNull('deleted_at')
->count(),
'last_activity_at' => now(),
])->save();
}
public function syncCollectionCount(Group $group): void
{
$group->forceFill([
'collections_count' => Collection::query()
->where('group_id', $group->id)
->whereNull('deleted_at')
->count(),
'last_activity_at' => now(),
])->save();
}
public function studioOptionsForUser(User $user): array
{
$groups = Group::query()
->with(['owner.profile', 'members'])
->where('status', '!=', Group::LIFECYCLE_SUSPENDED)
->where(function ($query) use ($user): void {
$query->where('owner_user_id', $user->id)
->orWhereHas('members', function ($memberQuery) use ($user): void {
$memberQuery->where('user_id', $user->id)
->where('status', Group::STATUS_ACTIVE);
});
})
->orderBy('name')
->get();
return $groups->map(function (Group $group) use ($user): array {
$canPublishArtworks = $group->canPublishArtworks($user);
$canSubmitArtworkForReview = $group->canSubmitArtworkForReview($user);
$canManageReleases = $group->canManageReleases($user);
$canViewReputation = $group->canViewReputationDashboard($user);
return [
'id' => (int) $group->id,
'name' => (string) $group->name,
'slug' => (string) $group->slug,
'role' => $group->activeRoleFor($user),
'role_label' => Group::displayRole($group->activeRoleFor($user)),
'status' => (string) ($group->status ?? Group::LIFECYCLE_ACTIVE),
'avatar_url' => $group->avatarUrl(),
'artworks_count' => (int) $group->artworks_count,
'collections_count' => (int) $group->collections_count,
'followers_count' => (int) $group->followers_count,
'permissions' => [
'can_publish_artworks' => $canPublishArtworks,
'can_submit_artwork_for_review' => $canSubmitArtworkForReview,
],
'public_url' => $group->publicUrl(),
'studio_url' => route('studio.groups.show', ['group' => $group]),
'studio_artworks_url' => route('studio.groups.artworks', ['group' => $group]),
'studio_collections_url' => route('studio.groups.collections', ['group' => $group]),
'studio_members_url' => route('studio.groups.members', ['group' => $group]),
'studio_invitations_url' => route('studio.groups.invitations', ['group' => $group]),
'studio_join_requests_url' => route('studio.groups.join-requests', ['group' => $group]),
'studio_review_url' => route('studio.groups.review', ['group' => $group]),
'studio_recruitment_url' => route('studio.groups.recruitment', ['group' => $group]),
'studio_posts_url' => route('studio.groups.posts.index', ['group' => $group]),
'studio_settings_url' => route('studio.groups.settings', ['group' => $group]),
'studio_projects_url' => route('studio.groups.projects.index', ['group' => $group]),
'studio_releases_url' => $canManageReleases ? route('studio.groups.releases.index', ['group' => $group]) : null,
'studio_challenges_url' => route('studio.groups.challenges.index', ['group' => $group]),
'studio_events_url' => route('studio.groups.events.index', ['group' => $group]),
'studio_assets_url' => route('studio.groups.assets.index', ['group' => $group]),
'studio_reputation_url' => $canViewReputation ? route('studio.groups.reputation', ['group' => $group]) : null,
'studio_activity_url' => route('studio.groups.activity', ['group' => $group]),
'upload_url' => ($canPublishArtworks || $canSubmitArtworkForReview) ? route('upload', ['group' => $group->slug]) : null,
'collection_create_url' => route('settings.collections.create', ['group' => $group->slug]),
];
})->values()->all();
}
public function mapGroupCard(Group $group, ?User $viewer = null): array
{
return $this->cards->mapGroupCard($group, $viewer);
}
public function mapGroupDetail(Group $group, ?User $viewer = null): array
{
$recruitment = $this->recruitment->payloadForGroup($group);
return array_merge($this->mapGroupCard($group, $viewer), [
'website_url' => $group->website_url,
'bio' => $group->bio,
'links' => $this->normalizeLinks($group->links_json ?? []),
'avatar_path' => $group->avatar_path,
'banner_path' => $group->banner_path,
'featured_artwork_id' => $group->featured_artwork_id ? (int) $group->featured_artwork_id : null,
'founded_at' => $group->founded_at?->toISOString(),
'last_activity_at' => $group->last_activity_at?->toISOString(),
'created_at' => $group->created_at?->toISOString(),
'current_join_request' => $this->joinRequests->currentRequestFor($group, $viewer),
'recruitment' => $recruitment,
'pinned_post' => $this->posts->pinnedPost($group),
'featured_release' => $this->releases->featuredRelease($group, $viewer),
'featured_project' => $this->projects->featuredProject($group, $viewer),
'active_challenge' => $this->challenges->activeChallenge($group, $viewer),
'upcoming_event' => $this->events->upcomingEvent($group, $viewer),
'badge_showcase' => $this->reputation->groupBadges($group, 8),
'top_contributors' => $this->reputation->topContributors($group, 6),
'trust_signals' => $this->reputation->trustSignals($group),
]);
}
public function recentPostCards(Group $group, int $limit = 3): array
{
return $this->posts->recentPosts($group, $limit);
}
public function recentProjectCards(Group $group, ?User $viewer = null, int $limit = 3): array
{
return $this->projects->publicListing($group, $viewer, $limit);
}
public function recentReleaseCards(Group $group, ?User $viewer = null, int $limit = 3): array
{
return $this->releases->publicListing($group, $viewer, $limit);
}
public function recentChallengeCards(Group $group, ?User $viewer = null, int $limit = 3): array
{
return $this->challenges->publicListing($group, $viewer, $limit);
}
public function recentEventCards(Group $group, ?User $viewer = null, int $limit = 3): array
{
return $this->events->publicListing($group, $viewer, $limit);
}
public function publicProjectListing(Group $group, ?User $viewer = null, int $limit = 12): array
{
return $this->projects->publicListing($group, $viewer, $limit);
}
public function publicReleaseListing(Group $group, ?User $viewer = null, int $limit = 12): array
{
return $this->releases->publicListing($group, $viewer, $limit);
}
public function publicChallengeListing(Group $group, ?User $viewer = null, int $limit = 12): array
{
return $this->challenges->publicListing($group, $viewer, $limit);
}
public function publicEventListing(Group $group, ?User $viewer = null, int $limit = 12): array
{
return $this->events->publicListing($group, $viewer, $limit);
}
public function publicAssetListing(Group $group, int $limit = 12): array
{
return $this->assets->publicListing($group, $limit);
}
public function publicActivityFeed(Group $group, int $limit = 8): array
{
return $this->activity->publicFeed($group, $limit);
}
public function studioActivityFeed(Group $group, User $viewer, int $limit = 20): array
{
return $this->activity->studioFeed($group, $viewer, $limit);
}
public function publicPostListing(Group $group, int $limit = 12): array
{
return $this->posts->publicPosts($group, $limit);
}
public function recruitmentPayload(Group $group): ?array
{
return $this->recruitment->payloadForGroup($group);
}
public function recentHistory(Group $group, int $limit = 8): array
{
return $this->history->recentFor($group, $limit);
}
public function featuredArtworkCards(Group $group, int $limit = 4): array
{
$query = Artwork::query()
->with(['user.profile', 'group', 'primaryAuthor.profile'])
->where('group_id', $group->id)
->whereNull('deleted_at')
->where('is_public', true)
->where('is_approved', true)
->whereNotNull('published_at');
$this->maturity->applyViewerFilter($query, request()->user());
if ((int) ($group->featured_artwork_id ?? 0) > 0) {
$featuredArtwork = (clone $query)
->where('id', (int) $group->featured_artwork_id)
->first();
$remaining = (clone $query)
->where('id', '!=', (int) $group->featured_artwork_id)
->latest('published_at')
->limit(max($limit - ($featuredArtwork ? 1 : 0), 0))
->get();
return collect([$featuredArtwork])
->filter()
->concat($remaining)
->map(fn (Artwork $artwork): array => $this->mapPublicArtworkCard($artwork))
->values()
->all();
}
return (clone $query)
->latest('published_at')
->limit($limit)
->get()
->map(fn (Artwork $artwork): array => $this->mapPublicArtworkCard($artwork))
->values()
->all();
}
public function featuredCollectionCards(Group $group, ?User $viewer = null, int $limit = 3): array
{
$collections = Collection::query()
->with(['user.profile', 'group', 'coverArtwork'])
->where('group_id', $group->id)
->whereNull('deleted_at')
->where('is_featured', true)
->latest('featured_at')
->latest('updated_at')
->limit($limit)
->get()
->filter(fn (Collection $collection): bool => $collection->isPubliclyAccessible())
->values();
return $this->collections->mapCollectionCardPayloads($collections, false, $viewer);
}
public function mapLeadershipPreview(array $members, array $owner, int $limit = 4): array
{
$leadership = collect($members)
->filter(fn (array $member): bool => in_array((string) ($member['role'] ?? ''), [Group::ROLE_OWNER, Group::ROLE_ADMIN], true))
->map(function (array $member): array {
return [
'id' => (int) ($member['user']['id'] ?? 0),
'name' => $member['user']['name'] ?? null,
'username' => $member['user']['username'] ?? null,
'avatar_url' => $member['user']['avatar_url'] ?? null,
'profile_url' => $member['user']['profile_url'] ?? null,
'role' => (string) ($member['role'] ?? ''),
'role_label' => $member['role_label'] ?? Group::displayRole((string) ($member['role'] ?? '')),
];
})
->unique('id')
->values();
if ($leadership->isEmpty() && ! empty($owner['id'])) {
$leadership = collect([array_merge($owner, [
'role' => Group::ROLE_OWNER,
'role_label' => Group::displayRole(Group::ROLE_OWNER),
])]);
}
return $leadership->take($limit)->all();
}
public function archiveGroup(Group $group, User $actor): Group
{
if (! $group->canArchive($actor) && ! $actor->isAdmin()) {
abort(403);
}
$group->forceFill([
'status' => Group::LIFECYCLE_ARCHIVED,
'last_activity_at' => now(),
])->save();
return $group->fresh(['owner.profile', 'members']);
}
public function publicArtworkCards(Group $group, int $limit = 18): array
{
$query = Artwork::query()
->with(['user.profile', 'group', 'primaryAuthor.profile'])
->where('group_id', $group->id)
->whereNull('deleted_at')
->where('is_public', true)
->where('is_approved', true)
->whereNotNull('published_at')
->latest('published_at');
$this->maturity->applyViewerFilter($query, request()->user());
return $query
->limit($limit)
->get()
->map(fn (Artwork $artwork): array => $this->mapPublicArtworkCard($artwork))
->values()
->all();
}
public function publicCollectionCards(Group $group, ?User $viewer = null, int $limit = 12): array
{
$collections = Collection::query()
->with(['user.profile', 'group', 'coverArtwork'])
->where('group_id', $group->id)
->whereNull('deleted_at')
->latest('updated_at')
->limit($limit)
->get()
->filter(fn (Collection $collection): bool => $collection->isPubliclyAccessible())
->values();
return $this->collections->mapCollectionCardPayloads($collections, false, $viewer);
}
private function mapPublicArtworkCard(Artwork $artwork): array
{
return $this->maturity->decoratePayload([
'id' => (int) $artwork->id,
'title' => (string) $artwork->title,
'url' => route('art.show', ['id' => $artwork->id, 'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: $artwork->id]),
'thumb' => ThumbnailPresenter::present($artwork, 'md')['url'] ?? $artwork->thumbUrl('md'),
'thumb_srcset' => ThumbnailPresenter::srcsetForArtwork($artwork),
'author' => $artwork->primaryAuthor?->name ?: $artwork->primaryAuthor?->username ?: $artwork->user?->name ?: $artwork->user?->username ?: 'Artist',
'published_at' => $artwork->published_at?->toISOString(),
], $artwork, request()->user());
}
public function studioDashboardSummary(Group $group): array
{
$artworkQuery = Artwork::query()
->where('group_id', $group->id)
->whereNull('deleted_at');
$collectionQuery = Collection::query()
->where('group_id', $group->id)
->whereNull('deleted_at');
return [
'draft_artworks_count' => (clone $artworkQuery)->where('artwork_status', 'draft')->count(),
'scheduled_artworks_count' => (clone $artworkQuery)->where('artwork_status', 'scheduled')->count(),
'published_artworks_count' => (clone $artworkQuery)->where('artwork_status', 'published')->count(),
'pending_reviews_count' => (clone $artworkQuery)->where('group_review_status', 'submitted')->count(),
'draft_collections_count' => (clone $collectionQuery)
->where(function ($builder): void {
$builder->where('lifecycle_state', Collection::LIFECYCLE_DRAFT)
->orWhere('workflow_state', Collection::WORKFLOW_DRAFT)
->orWhere('workflow_state', Collection::WORKFLOW_IN_REVIEW);
})
->count(),
'active_members_count' => (int) $group->members()->where('status', Group::STATUS_ACTIVE)->count(),
'pending_invites_count' => $this->memberships->pendingInviteCount($group),
'pending_join_requests_count' => $this->joinRequests->pendingCount($group),
'published_posts_count' => (int) $group->posts()->where('status', \App\Models\GroupPost::STATUS_PUBLISHED)->count(),
'is_recruiting' => (bool) ($this->recruitment->payloadForGroup($group)['is_recruiting'] ?? false),
'projects_count' => (int) $group->projects()->count(),
'releases_count' => (int) $group->releases()->count(),
'published_releases_count' => (int) $group->releases()->where('status', \App\Models\GroupRelease::STATUS_RELEASED)->count(),
'active_challenges_count' => (int) $group->challenges()->whereIn('status', ['published', 'active'])->count(),
'events_count' => (int) $group->events()->count(),
'assets_count' => (int) $group->assets()->count(),
'activity_count' => (int) $group->activityItems()->count(),
'group_badges_count' => (int) $group->badges()->count(),
'member_badges_count' => (int) $group->memberBadges()->count(),
'trust_score' => (float) ($group->discoveryMetric?->trust_score ?? 0),
];
}
public function studioArtworkPreviewItems(Group $group, string $bucket = 'all', int $limit = 6): array
{
$query = Artwork::query()
->with(['user.profile', 'primaryAuthor.profile', 'stats'])
->where('group_id', $group->id)
->whereNull('deleted_at');
if ($bucket === 'drafts') {
$query->where('artwork_status', 'draft');
} elseif ($bucket === 'scheduled') {
$query->where('artwork_status', 'scheduled');
} elseif ($bucket === 'published') {
$query->where('artwork_status', 'published');
}
return $query->latest('updated_at')
->limit($limit)
->get()
->map(fn (Artwork $artwork): array => $this->mapStudioArtworkItem($artwork))
->values()
->all();
}
public function studioFeaturedArtworkOptions(Group $group, int $limit = 24): array
{
return Artwork::query()
->with(['user.profile', 'primaryAuthor.profile'])
->where('group_id', $group->id)
->whereNull('deleted_at')
->where('artwork_status', 'published')
->where('is_public', true)
->where('is_approved', true)
->whereNotNull('published_at')
->latest('published_at')
->limit($limit)
->get()
->map(fn (Artwork $artwork): array => [
'id' => (int) $artwork->id,
'title' => (string) $artwork->title,
'author' => $artwork->primaryAuthor?->name ?: $artwork->primaryAuthor?->username ?: $artwork->user?->name ?: $artwork->user?->username,
'thumb' => ThumbnailPresenter::present($artwork, 'md')['url'] ?? $artwork->thumbUrl('md'),
'published_at' => $artwork->published_at?->toISOString(),
])
->values()
->all();
}
public function studioCollectionPreviewItems(Group $group, int $limit = 6): array
{
return Collection::query()
->with(['user.profile', 'group', 'coverArtwork'])
->where('group_id', $group->id)
->whereNull('deleted_at')
->latest('updated_at')
->limit($limit)
->get()
->map(fn (Collection $collection): array => $this->mapStudioCollectionItem($collection))
->values()
->all();
}
public function studioArtworkListing(Group $group, array $filters = []): array
{
$bucket = (string) ($filters['bucket'] ?? 'all');
$search = trim((string) ($filters['q'] ?? ''));
$page = max(1, (int) ($filters['page'] ?? 1));
$perPage = min(max((int) ($filters['per_page'] ?? 24), 12), 48);
$query = Artwork::query()
->with(['user.profile', 'primaryAuthor.profile'])
->where('group_id', $group->id)
->whereNull('deleted_at');
if ($bucket === 'drafts') {
$query->where('artwork_status', 'draft');
} elseif ($bucket === 'scheduled') {
$query->where('artwork_status', 'scheduled');
} elseif ($bucket === 'published') {
$query->where('artwork_status', 'published');
}
if ($search !== '') {
$query->where(function ($builder) use ($search): void {
$builder->where('title', 'like', '%' . $search . '%')
->orWhere('description', 'like', '%' . $search . '%');
});
}
$paginator = $query->latest('updated_at')->paginate($perPage, ['*'], 'page', $page);
return $this->mapStudioListing(
$paginator,
fn (Artwork $artwork): array => $this->mapStudioArtworkItem($artwork),
[
['value' => 'all', 'label' => 'All'],
['value' => 'published', 'label' => 'Published'],
['value' => 'drafts', 'label' => 'Drafts'],
['value' => 'scheduled', 'label' => 'Scheduled'],
],
$bucket,
$search
);
}
public function studioCollectionListing(Group $group, array $filters = []): array
{
$bucket = (string) ($filters['bucket'] ?? 'all');
$search = trim((string) ($filters['q'] ?? ''));
$page = max(1, (int) ($filters['page'] ?? 1));
$perPage = min(max((int) ($filters['per_page'] ?? 24), 12), 48);
$query = Collection::query()
->with(['user.profile', 'group', 'coverArtwork'])
->where('group_id', $group->id)
->whereNull('deleted_at');
if ($bucket === 'drafts') {
$query->where(function ($builder): void {
$builder->where('lifecycle_state', Collection::LIFECYCLE_DRAFT)
->orWhere('workflow_state', Collection::WORKFLOW_DRAFT)
->orWhere('workflow_state', Collection::WORKFLOW_IN_REVIEW);
});
} elseif ($bucket === 'scheduled') {
$query->where('lifecycle_state', Collection::LIFECYCLE_SCHEDULED);
} elseif ($bucket === 'published') {
$query->whereIn('lifecycle_state', [Collection::LIFECYCLE_PUBLISHED, Collection::LIFECYCLE_FEATURED, Collection::LIFECYCLE_SCHEDULED]);
}
if ($search !== '') {
$query->where(function ($builder) use ($search): void {
$builder->where('title', 'like', '%' . $search . '%')
->orWhere('description', 'like', '%' . $search . '%');
});
}
$paginator = $query->latest('updated_at')->paginate($perPage, ['*'], 'page', $page);
return $this->mapStudioListing(
$paginator,
fn (Collection $collection): array => $this->mapStudioCollectionItem($collection),
[
['value' => 'all', 'label' => 'All'],
['value' => 'published', 'label' => 'Published'],
['value' => 'drafts', 'label' => 'Drafts'],
['value' => 'scheduled', 'label' => 'Scheduled'],
],
$bucket,
$search
);
}
private function mapStudioListing(LengthAwarePaginator $paginator, callable $mapper, array $bucketOptions, string $bucket, string $search): array
{
return [
'items' => collect($paginator->items())->map($mapper)->values()->all(),
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
'filters' => [
'bucket' => $bucket,
'q' => $search,
'sort' => 'updated_desc',
],
'module_options' => [],
'bucket_options' => $bucketOptions,
'sort_options' => [
['value' => 'updated_desc', 'label' => 'Recently updated'],
],
'advanced_filters' => [],
'default_view' => 'grid',
];
}
private function mapStudioArtworkItem(Artwork $artwork): array
{
$status = (string) ($artwork->artwork_status ?: ($artwork->published_at ? 'published' : 'draft'));
return [
'id' => 'artworks:' . (int) $artwork->id,
'numeric_id' => (int) $artwork->id,
'module' => 'artworks',
'module_label' => 'Artworks',
'module_icon' => 'fa-solid fa-images',
'title' => (string) $artwork->title,
'subtitle' => $artwork->primaryAuthor?->name ?: $artwork->primaryAuthor?->username ?: $artwork->user?->name ?: $artwork->user?->username,
'description' => $artwork->description,
'status' => $status,
'visibility' => (string) ($artwork->visibility ?: Artwork::VISIBILITY_PRIVATE),
'image_url' => ThumbnailPresenter::present($artwork, 'md')['url'] ?? $artwork->thumbUrl('md'),
'preview_url' => route('art.show', ['id' => $artwork->id, 'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: $artwork->id]),
'view_url' => route('art.show', ['id' => $artwork->id, 'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: $artwork->id]),
'edit_url' => route('studio.artworks.edit', ['id' => $artwork->id]),
'manage_url' => route('studio.artworks.edit', ['id' => $artwork->id]),
'analytics_url' => route('studio.artworks.analytics', ['id' => $artwork->id]),
'created_at' => $artwork->created_at?->toISOString(),
'updated_at' => $artwork->updated_at?->toISOString(),
'published_at' => $artwork->published_at?->toISOString(),
'metrics' => [
'views' => (int) ($artwork->stats?->views ?? 0),
'appreciation' => (int) ($artwork->stats?->favorites ?? 0),
'comments' => (int) $artwork->comments()->count(),
],
'actions' => [],
];
}
private function mapStudioCollectionItem(Collection $collection): array
{
$mapped = $this->collections->mapCollectionCardPayloads([$collection->loadMissing(['user.profile', 'group', 'coverArtwork'])], true)[0];
$status = $mapped['lifecycle_state'] === Collection::LIFECYCLE_FEATURED ? 'published' : ($mapped['lifecycle_state'] ?? 'draft');
return [
'id' => 'collections:' . (int) $collection->id,
'numeric_id' => (int) $collection->id,
'module' => 'collections',
'module_label' => 'Collections',
'module_icon' => 'fa-solid fa-layer-group',
'title' => (string) $mapped['title'],
'subtitle' => $mapped['subtitle'] ?: ucfirst((string) ($mapped['type'] ?? 'collection')),
'description' => $mapped['summary'] ?: $mapped['description'],
'status' => $status,
'visibility' => (string) $mapped['visibility'],
'image_url' => $mapped['cover_image'],
'preview_url' => $mapped['url'],
'view_url' => $mapped['url'],
'edit_url' => $mapped['edit_url'] ?: $mapped['manage_url'],
'manage_url' => $mapped['manage_url'],
'analytics_url' => route('settings.collections.analytics', ['collection' => $collection->id]),
'created_at' => ($mapped['published_at'] ?? null) ?: ($mapped['updated_at'] ?? null),
'updated_at' => $mapped['updated_at'] ?? null,
'published_at' => $mapped['published_at'] ?? null,
'metrics' => [
'views' => (int) ($mapped['views_count'] ?? 0),
'appreciation' => (int) (($mapped['likes_count'] ?? 0) + ($mapped['followers_count'] ?? 0)),
'comments' => (int) ($mapped['comments_count'] ?? 0),
],
'actions' => [],
];
}
private function normalizeLinks(mixed $links): array
{
$items = is_array($links) ? $links : [];
return collect($items)
->filter(fn ($item): bool => is_array($item))
->map(function (array $item): array {
return [
'label' => trim((string) ($item['label'] ?? '')),
'url' => trim((string) ($item['url'] ?? '')),
];
})
->filter(fn (array $item): bool => $item['label'] !== '' && $item['url'] !== '')
->values()
->all();
}
private function normalizeMediaPath(mixed $path): ?string
{
$trimmed = trim((string) $path);
return $trimmed !== '' ? $trimmed : null;
}
private function normalizeFeaturedArtworkId(Group $group, mixed $featuredArtworkId): ?int
{
$id = (int) $featuredArtworkId;
if ($id <= 0) {
return null;
}
$exists = Artwork::query()
->where('id', $id)
->where('group_id', $group->id)
->whereNull('deleted_at')
->where('artwork_status', 'published')
->where('is_public', true)
->where('is_approved', true)
->whereNotNull('published_at')
->exists();
return $exists ? $id : null;
}
}