408 lines
15 KiB
PHP
408 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\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(),
|
|
]);
|
|
}
|
|
}
|
|
} |