published()->findOrFail($artworkId); $page = max(1, (int) $request->query('page', 1)); $perPage = 20; // Only fetch top-level comments (no parent). Replies are recursively eager-loaded. $comments = ArtworkComment::with([ 'user', 'user.profile', 'approvedReplies', ]) ->where('artwork_id', $artwork->id) ->where('is_approved', true) ->whereNull('parent_id') ->orderByDesc('created_at') ->paginate($perPage, ['*'], 'page', $page); $userId = $request->user()?->id; $items = $comments->getCollection()->map(fn ($c) => $this->formatComment($c, $userId, true)); return response()->json([ 'data' => $items, 'meta' => [ 'current_page' => $comments->currentPage(), 'last_page' => $comments->lastPage(), 'total' => $comments->total(), 'per_page' => $comments->perPage(), ], ]); } // ───────────────────────────────────────────────────────────────────────── // Store // ───────────────────────────────────────────────────────────────────────── public function store(Request $request, int $artworkId): JsonResponse { $artwork = Artwork::public()->published()->findOrFail($artworkId); $request->validate([ 'content' => ['required', 'string', 'min:1', 'max:' . self::MAX_LENGTH], 'parent_id' => ['nullable', 'integer', 'exists:artwork_comments,id'], ]); $raw = $request->input('content'); $parentId = $request->input('parent_id'); // If replying, validate parent belongs to same artwork and is approved if ($parentId) { $parent = ArtworkComment::where('artwork_id', $artwork->id) ->where('is_approved', true) ->find($parentId); if (! $parent) { return response()->json([ 'errors' => ['parent_id' => ['The comment you are replying to is no longer available.']], ], 422); } } // Validate markdown-lite content $errors = ContentSanitizer::validate($raw); if ($errors) { return response()->json(['errors' => ['content' => $errors]], 422); } $rendered = ContentSanitizer::render($raw); $comment = ArtworkComment::create([ 'artwork_id' => $artwork->id, 'user_id' => $request->user()->id, 'parent_id' => $parentId, 'content' => $raw, // legacy column (plain text fallback) 'raw_content' => $raw, 'rendered_content' => $rendered, 'is_approved' => true, // auto-approve; extend with moderation as needed ]); // Bust the comments cache for this user's 'all' feed Cache::forget('comments.latest.all.page1'); $comment->load(['user', 'user.profile']); $this->notifyRecipients($artwork, $comment, $request->user(), $parentId ? (int) $parentId : null); // Record activity event (fire-and-forget; never break the response) try { \App\Models\ActivityEvent::record( actorId: $request->user()->id, type: \App\Models\ActivityEvent::TYPE_COMMENT, targetType: \App\Models\ActivityEvent::TARGET_ARTWORK, targetId: $artwork->id, ); } catch (\Throwable) {} return response()->json(['data' => $this->formatComment($comment, $request->user()->id, false)], 201); } // ───────────────────────────────────────────────────────────────────────── // Update // ───────────────────────────────────────────────────────────────────────── public function update(Request $request, int $artworkId, int $commentId): JsonResponse { $comment = ArtworkComment::where('artwork_id', $artworkId) ->findOrFail($commentId); Gate::authorize('update', $comment); $request->validate([ 'content' => ['required', 'string', 'min:1', 'max:' . self::MAX_LENGTH], ]); $raw = $request->input('content'); $errors = ContentSanitizer::validate($raw); if ($errors) { return response()->json(['errors' => ['content' => $errors]], 422); } $rendered = ContentSanitizer::render($raw); $comment->update([ 'content' => $raw, 'raw_content' => $raw, 'rendered_content' => $rendered, ]); Cache::forget('comments.latest.all.page1'); $comment->load(['user', 'user.profile']); return response()->json(['data' => $this->formatComment($comment, $request->user()->id, false)]); } // ───────────────────────────────────────────────────────────────────────── // Delete // ───────────────────────────────────────────────────────────────────────── public function destroy(Request $request, int $artworkId, int $commentId): JsonResponse { $comment = ArtworkComment::where('artwork_id', $artworkId)->findOrFail($commentId); Gate::authorize('delete', $comment); $comment->delete(); Cache::forget('comments.latest.all.page1'); return response()->json(['message' => 'Comment deleted.'], 200); } // ───────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────── private function formatComment(ArtworkComment $c, ?int $currentUserId, bool $includeReplies = false): array { $user = $c->user; $userId = (int) ($c->user_id ?? 0); $avatarHash = $user?->profile?->avatar_hash ?? null; $data = [ 'id' => $c->id, 'parent_id' => $c->parent_id, 'raw_content' => $c->raw_content ?? $c->content, 'rendered_content' => $c->rendered_content ?? e(strip_tags($c->content ?? '')), 'created_at' => $c->created_at?->toIso8601String(), 'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null, 'can_edit' => $currentUserId === $userId, 'can_delete' => $currentUserId === $userId, 'user' => [ 'id' => $userId, 'username' => $user?->username, 'display' => $user?->username ?? $user?->name ?? 'User', 'profile_url' => $user?->username ? '/@' . $user->username : '/profile/' . $userId, 'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64), 'level' => (int) ($user?->level ?? 1), 'rank' => (string) ($user?->rank ?? 'Newbie'), ], ]; if ($includeReplies && $c->relationLoaded('approvedReplies')) { $data['replies'] = $c->approvedReplies->map(fn ($r) => $this->formatComment($r, $currentUserId, true))->values()->toArray(); } elseif ($includeReplies && $c->relationLoaded('replies')) { $data['replies'] = $c->replies->map(fn ($r) => $this->formatComment($r, $currentUserId, true))->values()->toArray(); } else { $data['replies'] = []; } return $data; } private function notifyRecipients(Artwork $artwork, ArtworkComment $comment, User $actor, ?int $parentId): void { $notifiedUserIds = []; $creatorId = (int) ($artwork->user_id ?? 0); if ($creatorId > 0 && $creatorId !== (int) $actor->id) { $creator = User::query()->find($creatorId); if ($creator) { $creator->notify(new ArtworkCommentedNotification($artwork, $comment, $actor)); $notifiedUserIds[] = (int) $creator->id; } } if ($parentId) { $parentUserId = (int) (ArtworkComment::query()->whereKey($parentId)->value('user_id') ?? 0); if ($parentUserId > 0 && $parentUserId !== (int) $actor->id && ! in_array($parentUserId, $notifiedUserIds, true)) { $parentUser = User::query()->find($parentUserId); if ($parentUser) { $parentUser->notify(new ArtworkCommentedNotification($artwork, $comment, $actor)); $notifiedUserIds[] = (int) $parentUser->id; } } } User::query() ->whereIn( 'id', UserMention::query() ->where('comment_id', (int) $comment->id) ->pluck('mentioned_user_id') ->map(fn ($id) => (int) $id) ->unique() ->all() ) ->get() ->each(function (User $mentionedUser) use ($artwork, $comment, $actor): void { if ((int) $mentionedUser->id === (int) $actor->id) { return; } $mentionedUser->notify(new ArtworkMentionedNotification($artwork, $comment, $actor)); }); } }