Commit workspace changes
This commit is contained in:
408
app/Services/GroupArtworkReviewService.php
Normal file
408
app/Services/GroupArtworkReviewService.php
Normal file
@@ -0,0 +1,408 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Models\Group;
|
||||
use App\Models\Tag;
|
||||
use App\Models\User;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class GroupArtworkReviewService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ArtworkAttributionService $attribution,
|
||||
private readonly GroupHistoryService $history,
|
||||
private readonly NotificationService $notifications,
|
||||
private readonly GroupMembershipService $memberships,
|
||||
) {
|
||||
}
|
||||
|
||||
public function submit(Group $group, Artwork $artwork, User $actor, array $attributes): Artwork
|
||||
{
|
||||
if (! $group->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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user