canReceiveSubmissionsFrom($actor)) { throw ValidationException::withMessages([ 'collection' => 'This collection is not accepting submissions.', ]); } if ((int) $artwork->user_id !== (int) $actor->id) { throw ValidationException::withMessages([ 'artwork_id' => 'You can only submit your own artwork.', ]); } if ($collection->mode !== Collection::MODE_MANUAL) { throw ValidationException::withMessages([ 'collection' => 'Submissions are only supported for manual collections.', ]); } $this->guardAgainstSubmissionSpam($collection, $actor, $artwork); $submission = CollectionSubmission::query()->firstOrNew([ 'collection_id' => $collection->id, 'artwork_id' => $artwork->id, 'user_id' => $actor->id, ]); if ($submission->exists && $submission->status === Collection::SUBMISSION_PENDING) { throw ValidationException::withMessages([ 'artwork_id' => 'This artwork already has a pending submission.', ]); } $submission->fill([ 'message' => $message, 'status' => Collection::SUBMISSION_PENDING, 'reviewed_by_user_id' => null, 'reviewed_at' => null, ])->save(); $this->notifications->notifyCollectionSubmission($collection->user, $actor, $collection, $artwork); return $submission->fresh(['user.profile', 'artwork']); } private function guardAgainstSubmissionSpam(Collection $collection, User $actor, Artwork $artwork): void { $perHourLimit = max(1, (int) config('collections.submissions.max_per_hour', 8)); $duplicateCooldown = max(1, (int) config('collections.submissions.duplicate_cooldown_minutes', 15)); $recentSubmissions = CollectionSubmission::query() ->where('user_id', $actor->id) ->where('created_at', '>=', now()->subHour()) ->count(); if ($recentSubmissions >= $perHourLimit) { throw ValidationException::withMessages([ 'collection' => 'You have reached the collection submission limit for the last hour. Please wait before submitting again.', ]); } $duplicateAttempt = CollectionSubmission::query() ->where('collection_id', $collection->id) ->where('artwork_id', $artwork->id) ->where('user_id', $actor->id) ->where('created_at', '>=', now()->subMinutes($duplicateCooldown)) ->whereIn('status', [ Collection::SUBMISSION_PENDING, Collection::SUBMISSION_REJECTED, Collection::SUBMISSION_APPROVED, ]) ->exists(); if ($duplicateAttempt) { throw ValidationException::withMessages([ 'artwork_id' => 'This artwork was submitted recently. Please wait before trying again.', ]); } } public function approve(CollectionSubmission $submission, User $actor): CollectionSubmission { $collection = $submission->collection()->with('user')->firstOrFail(); if (! $collection->canBeManagedBy($actor)) { throw ValidationException::withMessages([ 'submission' => 'You are not allowed to review submissions for this collection.', ]); } DB::transaction(function () use ($submission, $collection, $actor): void { $this->collections->attachArtworkIds($collection, [(int) $submission->artwork_id]); $submission->forceFill([ 'status' => Collection::SUBMISSION_APPROVED, 'reviewed_by_user_id' => $actor->id, 'reviewed_at' => now(), ])->save(); }); return $submission->fresh(['user.profile', 'artwork', 'reviewedBy']); } public function reject(CollectionSubmission $submission, User $actor): CollectionSubmission { $collection = $submission->collection; if (! $collection->canBeManagedBy($actor)) { throw ValidationException::withMessages([ 'submission' => 'You are not allowed to review submissions for this collection.', ]); } $submission->forceFill([ 'status' => Collection::SUBMISSION_REJECTED, 'reviewed_by_user_id' => $actor->id, 'reviewed_at' => now(), ])->save(); return $submission->fresh(['user.profile', 'artwork', 'reviewedBy']); } public function withdraw(CollectionSubmission $submission, User $actor): void { if ((int) $submission->user_id !== (int) $actor->id || $submission->status !== Collection::SUBMISSION_PENDING) { throw ValidationException::withMessages([ 'submission' => 'This submission cannot be withdrawn.', ]); } $submission->forceFill([ 'status' => Collection::SUBMISSION_WITHDRAWN, 'reviewed_at' => now(), ])->save(); } public function mapSubmissions(Collection $collection, ?User $viewer = null): array { $submissions = $collection->submissions() ->with(['user.profile', 'artwork', 'reviewedBy']) ->latest() ->get(); return $submissions->map(function (CollectionSubmission $submission) use ($collection, $viewer): array { $user = $submission->user; return [ 'id' => (int) $submission->id, 'status' => (string) $submission->status, 'message' => $submission->message, 'created_at' => $submission->created_at?->toISOString(), 'reviewed_at' => $submission->reviewed_at?->toISOString(), 'artwork' => $submission->artwork ? [ 'id' => (int) $submission->artwork->id, 'title' => (string) $submission->artwork->title, 'thumb' => $submission->artwork->thumbUrl('sm'), 'url' => route('art.show', ['id' => $submission->artwork->id, 'slug' => $submission->artwork->slug]), ] : null, 'user' => [ 'id' => (int) $user->id, 'username' => $user->username, 'name' => $user->name, ], 'can_review' => $viewer !== null && $collection->canBeManagedBy($viewer) && $submission->status === Collection::SUBMISSION_PENDING, 'can_withdraw' => $viewer !== null && (int) $submission->user_id === (int) $viewer->id && $submission->status === Collection::SUBMISSION_PENDING, 'can_report' => $viewer !== null && (int) $submission->user_id !== (int) $viewer->id, ]; })->all(); } }