canSubmitArtworkForReview($actor)) { throw ValidationException::withMessages([ 'group' => 'You are not allowed to submit artwork for this group.', ]); } if ($group->status !== Group::LIFECYCLE_ACTIVE) { throw ValidationException::withMessages([ 'group' => 'Archived or suspended groups cannot accept new submissions.', ]); } if ((int) $artwork->user_id !== (int) $actor->id && (int) ($artwork->uploaded_by_user_id ?? 0) !== (int) $actor->id) { throw ValidationException::withMessages([ 'artwork' => 'You can only submit your own group draft for review.', ]); } $before = [ 'group_review_status' => $artwork->group_review_status, 'artwork_status' => $artwork->artwork_status, ]; $this->applyDraftMetadata($artwork, $actor, $attributes); $artwork->save(); $artwork = $this->attribution->apply($artwork->fresh(['group.members']), $actor, $attributes, false); $artwork->forceFill([ 'visibility' => (string) ($attributes['visibility'] ?? $artwork->visibility ?? Artwork::VISIBILITY_PUBLIC), 'is_public' => false, 'is_approved' => false, 'published_at' => null, 'publish_at' => null, 'artwork_status' => 'draft', 'group_review_status' => 'submitted', 'group_review_submitted_at' => now(), 'group_reviewed_by_user_id' => null, 'group_reviewed_at' => null, 'group_review_notes' => null, ])->save(); $this->syncSearchIndex($artwork); $this->history->record( $group, $actor, 'artwork_submitted_for_review', sprintf('Submitted "%s" for group review.', $artwork->title), 'artwork', (int) $artwork->id, $before, [ 'group_review_status' => 'submitted', 'visibility' => $artwork->visibility, ], ); foreach ($this->reviewRecipients($group, $actor->id) as $recipient) { $this->notifications->notifyGroupArtworkSubmittedForReview($recipient, $actor, $group, $artwork); } return $artwork->fresh(['group', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile']); } public function approve(Group $group, Artwork $artwork, User $actor, ?string $notes = null): Artwork { $this->guardReviewAbility($group, $artwork, $actor); $before = [ 'group_review_status' => $artwork->group_review_status, 'artwork_status' => $artwork->artwork_status, 'published_at' => optional($artwork->published_at)->toISOString(), ]; $artwork->forceFill([ 'group_review_status' => 'approved', 'group_reviewed_by_user_id' => $actor->id, 'group_reviewed_at' => now(), 'group_review_notes' => $notes, 'is_approved' => true, 'artwork_status' => 'published', 'published_at' => now(), 'publish_at' => null, 'is_public' => ($artwork->visibility ?: Artwork::VISIBILITY_PUBLIC) !== Artwork::VISIBILITY_PRIVATE, ])->save(); $this->syncSearchIndex($artwork); $this->history->record( $group, $actor, 'artwork_submission_approved', sprintf('Approved group submission "%s".', $artwork->title), 'artwork', (int) $artwork->id, $before, [ 'group_review_status' => 'approved', 'artwork_status' => 'published', 'published_at' => optional($artwork->published_at)->toISOString(), ], ); app(GroupActivityService::class)->record( $group, $actor, 'artwork_published', 'artwork', (int) $artwork->id, sprintf('%s published new artwork: %s', $group->name, $artwork->title), $notes, 'public', ); if ($artwork->uploadedBy) { $this->notifications->notifyGroupArtworkApproved($artwork->uploadedBy, $actor, $group, $artwork); } return $artwork->fresh(['group', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile']); } public function requestChanges(Group $group, Artwork $artwork, User $actor, ?string $notes = null): Artwork { $this->guardReviewAbility($group, $artwork, $actor); $before = [ 'group_review_status' => $artwork->group_review_status, ]; $artwork->forceFill([ 'group_review_status' => 'needs_changes', 'group_reviewed_by_user_id' => $actor->id, 'group_reviewed_at' => now(), 'group_review_notes' => $notes, 'is_public' => false, 'published_at' => null, 'artwork_status' => 'draft', ])->save(); $this->syncSearchIndex($artwork); $this->history->record( $group, $actor, 'artwork_submission_changes_requested', sprintf('Requested changes for "%s".', $artwork->title), 'artwork', (int) $artwork->id, $before, ['group_review_status' => 'needs_changes'], ); if ($artwork->uploadedBy) { $this->notifications->notifyGroupArtworkNeedsChanges($artwork->uploadedBy, $actor, $group, $artwork); } return $artwork->fresh(['group', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile']); } public function reject(Group $group, Artwork $artwork, User $actor, ?string $notes = null): Artwork { $this->guardReviewAbility($group, $artwork, $actor); $before = [ 'group_review_status' => $artwork->group_review_status, ]; $artwork->forceFill([ 'group_review_status' => 'rejected', 'group_reviewed_by_user_id' => $actor->id, 'group_reviewed_at' => now(), 'group_review_notes' => $notes, 'is_public' => false, 'published_at' => null, 'artwork_status' => 'draft', ])->save(); $this->syncSearchIndex($artwork); $this->history->record( $group, $actor, 'artwork_submission_rejected', sprintf('Rejected group submission "%s".', $artwork->title), 'artwork', (int) $artwork->id, $before, ['group_review_status' => 'rejected'], ); if ($artwork->uploadedBy) { $this->notifications->notifyGroupArtworkRejected($artwork->uploadedBy, $actor, $group, $artwork); } return $artwork->fresh(['group', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile']); } public function pendingCount(Group $group): int { return (int) Artwork::query() ->where('group_id', $group->id) ->where('group_review_status', 'submitted') ->whereNull('deleted_at') ->count(); } public function listing(Group $group, User $viewer, array $filters = []): array { $bucket = (string) ($filters['bucket'] ?? 'submitted'); $page = max(1, (int) ($filters['page'] ?? 1)); $perPage = min(max((int) ($filters['per_page'] ?? 20), 10), 50); $canReviewAll = $group->canReviewSubmissions($viewer); $query = Artwork::query() ->with(['uploadedBy.profile', 'primaryAuthor.profile']) ->where('group_id', $group->id) ->whereNull('deleted_at') ->whereIn('group_review_status', ['submitted', 'needs_changes', 'approved', 'rejected']); if (! $canReviewAll) { $query->where(function ($builder) use ($viewer): void { $builder->where('uploaded_by_user_id', $viewer->id) ->orWhere('user_id', $viewer->id); }); } if ($bucket !== 'all') { $query->where('group_review_status', $bucket); } $paginator = $query->orderByRaw("CASE group_review_status WHEN 'submitted' THEN 0 WHEN 'needs_changes' THEN 1 ELSE 2 END") ->orderByDesc('group_review_submitted_at') ->paginate($perPage, ['*'], 'page', $page); return [ 'items' => collect($paginator->items())->map(fn (Artwork $artwork): array => $this->mapReviewItem($group, $artwork, $viewer))->values()->all(), 'meta' => [ 'current_page' => $paginator->currentPage(), 'last_page' => $paginator->lastPage(), 'per_page' => $paginator->perPage(), 'total' => $paginator->total(), ], 'filters' => [ 'bucket' => $bucket, ], 'bucket_options' => [ ['value' => 'submitted', 'label' => 'Submitted'], ['value' => 'needs_changes', 'label' => 'Needs changes'], ['value' => 'approved', 'label' => 'Approved'], ['value' => 'rejected', 'label' => 'Rejected'], ['value' => 'all', 'label' => 'All'], ], 'can_review_all' => $canReviewAll, ]; } public function mapReviewItem(Group $group, Artwork $artwork, User $viewer): array { return [ 'id' => (int) $artwork->id, 'title' => (string) $artwork->title, 'thumb' => $artwork->thumbUrl('sm'), 'group_review_status' => (string) ($artwork->group_review_status ?: 'none'), 'group_review_notes' => $artwork->group_review_notes, 'submitted_at' => $artwork->group_review_submitted_at?->toISOString(), 'reviewed_at' => $artwork->group_reviewed_at?->toISOString(), 'visibility' => (string) ($artwork->visibility ?: Artwork::VISIBILITY_PUBLIC), 'uploader' => $artwork->uploadedBy ? [ 'id' => (int) $artwork->uploadedBy->id, 'name' => $artwork->uploadedBy->name, 'username' => $artwork->uploadedBy->username, ] : null, 'primary_author' => $artwork->primaryAuthor ? [ 'id' => (int) $artwork->primaryAuthor->id, 'name' => $artwork->primaryAuthor->name, 'username' => $artwork->primaryAuthor->username, ] : null, 'urls' => [ 'edit' => route('studio.artworks.edit', ['id' => $artwork->id]), 'approve' => route('studio.groups.artworks.approve', ['group' => $group, 'artwork' => $artwork]), 'reject' => route('studio.groups.artworks.reject', ['group' => $group, 'artwork' => $artwork]), 'needs_changes' => route('studio.groups.artworks.needs-changes', ['group' => $group, 'artwork' => $artwork]), ], 'can_review' => $group->canReviewSubmissions($viewer), ]; } private function guardReviewAbility(Group $group, Artwork $artwork, User $actor): void { if ((int) ($artwork->group_id ?? 0) !== (int) $group->id) { throw ValidationException::withMessages([ 'artwork' => 'This artwork does not belong to the selected group.', ]); } if (! $group->canReviewSubmissions($actor)) { throw ValidationException::withMessages([ 'group' => 'You are not allowed to review submissions for this group.', ]); } if (! in_array((string) $artwork->group_review_status, ['submitted', 'needs_changes'], true)) { throw ValidationException::withMessages([ 'artwork' => 'This artwork is not currently awaiting review.', ]); } } private function applyDraftMetadata(Artwork $artwork, User $actor, array $validated): void { $title = trim((string) ($validated['title'] ?? $artwork->title ?? '')); if ($title === '') { $title = 'Untitled artwork'; } $slugBase = Str::slug($title); if ($slugBase === '') { $slugBase = 'artwork'; } $artwork->title = $title; if (array_key_exists('description', $validated)) { $artwork->description = $validated['description']; } if (array_key_exists('is_mature', $validated)) { $artwork->is_mature = (bool) $validated['is_mature']; } $artwork->slug = Str::limit($slugBase, 160, ''); $artwork->artwork_timezone = $validated['timezone'] ?? $artwork->artwork_timezone; $artwork->uploaded_by_user_id = $artwork->uploaded_by_user_id ?: (int) $actor->id; $artwork->primary_author_user_id = $artwork->primary_author_user_id ?: (int) $actor->id; $categoryId = isset($validated['category']) ? (int) $validated['category'] : null; if ($categoryId > 0 && Category::query()->where('id', $categoryId)->exists()) { $artwork->categories()->sync([$categoryId]); } if (array_key_exists('tags', $validated) && is_array($validated['tags'])) { $tagIds = []; foreach ($validated['tags'] as $tagSlug) { $tag = Tag::firstOrCreate( ['slug' => Str::slug((string) $tagSlug)], ['name' => (string) $tagSlug, 'is_active' => true, 'usage_count' => 0] ); $tagIds[$tag->id] = ['source' => 'user', 'confidence' => 1.0]; } $artwork->tags()->sync($tagIds); } } private function reviewRecipients(Group $group, int $excludeUserId): array { return User::query() ->whereIn('id', $this->memberships->activeContributorIds($group)) ->get() ->filter(fn (User $member): bool => (int) $member->id !== $excludeUserId && $group->canReviewSubmissions($member)) ->values() ->all(); } private function syncSearchIndex(Artwork $artwork): void { try { if ((bool) $artwork->is_public && (bool) $artwork->is_approved && ! empty($artwork->published_at)) { $artwork->searchable(); } else { $artwork->unsearchable(); } } catch (\Throwable $exception) { Log::warning('Failed to sync artwork search index for group review workflow', [ 'artwork_id' => (int) $artwork->id, 'error' => $exception->getMessage(), ]); } } }