Files
SkinbaseNova/app/Services/GroupArtworkReviewService.php

398 lines
15 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Category;
use App\Models\Group;
use App\Jobs\IndexArtworkJob;
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
{
IndexArtworkJob::dispatch((int) $artwork->id);
}
}