Batch-load comment reaction counts on artwork comments.

Avoid per-comment reaction queries on the deferred comments API by aggregating counts in one service call.
This commit is contained in:
2026-08-29 12:25:37 +02:00
parent bfcbfec357
commit bf902f509a
3 changed files with 249 additions and 5 deletions
@@ -11,6 +11,7 @@ use App\Models\UserMention;
use App\Notifications\ArtworkCommentedNotification;
use App\Notifications\ArtworkMentionedNotification;
use App\Services\ContentSanitizer;
use App\Services\CommentReactionService;
use App\Support\AvatarUrl;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
@@ -30,6 +31,8 @@ class ArtworkCommentController extends Controller
{
private const MAX_LENGTH = 10_000;
public function __construct(private readonly CommentReactionService $commentReactions) {}
// ─────────────────────────────────────────────────────────────────────────
// List
// ─────────────────────────────────────────────────────────────────────────
@@ -53,7 +56,9 @@ class ArtworkCommentController extends Controller
->paginate($perPage, ['*'], 'page', $page);
$userId = $request->user()?->id;
$items = $comments->getCollection()->map(fn ($c) => $this->formatComment($c, $userId, true));
$commentIds = $this->commentIds($comments->getCollection());
$reactionTotals = $this->commentReactions->forComments($commentIds, $userId);
$items = $comments->getCollection()->map(fn ($c) => $this->formatComment($c, $userId, true, $reactionTotals));
return response()->json([
'data' => $items,
@@ -138,7 +143,9 @@ class ArtworkCommentController extends Controller
);
} catch (\Throwable) {}
return response()->json(['data' => $this->formatComment($comment, $request->user()->id, false)], 201);
$reactionTotals = $this->commentReactions->forComments([$comment->id], $request->user()->id);
return response()->json(['data' => $this->formatComment($comment, $request->user()->id, false, $reactionTotals)], 201);
}
// ─────────────────────────────────────────────────────────────────────────
@@ -197,7 +204,7 @@ class ArtworkCommentController extends Controller
// Helpers
// ─────────────────────────────────────────────────────────────────────────
private function formatComment(ArtworkComment $c, ?int $currentUserId, bool $includeReplies = false): array
private function formatComment(ArtworkComment $c, ?int $currentUserId, bool $includeReplies = false, array $reactionTotals = []): array
{
$user = $c->user;
$userId = (int) ($c->user_id ?? 0);
@@ -221,12 +228,13 @@ class ArtworkCommentController extends Controller
'level' => (int) ($user?->level ?? 1),
'rank' => (string) ($user?->rank ?? 'Newbie'),
],
'reactions' => $reactionTotals[(int) $c->id] ?? [],
];
if ($includeReplies && $c->relationLoaded('approvedReplies')) {
$data['replies'] = $c->approvedReplies->map(fn ($r) => $this->formatComment($r, $currentUserId, true))->values()->toArray();
$data['replies'] = $c->approvedReplies->map(fn ($r) => $this->formatComment($r, $currentUserId, true, $reactionTotals))->values()->toArray();
} elseif ($includeReplies && $c->relationLoaded('replies')) {
$data['replies'] = $c->replies->map(fn ($r) => $this->formatComment($r, $currentUserId, true))->values()->toArray();
$data['replies'] = $c->replies->map(fn ($r) => $this->formatComment($r, $currentUserId, true, $reactionTotals))->values()->toArray();
} else {
$data['replies'] = [];
}
@@ -234,6 +242,24 @@ class ArtworkCommentController extends Controller
return $data;
}
/** @param iterable<int, ArtworkComment> $comments */
private function commentIds(iterable $comments): array
{
$ids = [];
$walk = function (iterable $items) use (&$walk, &$ids): void {
foreach ($items as $comment) {
$ids[] = (int) $comment->id;
if ($comment->relationLoaded('approvedReplies')) {
$walk($comment->approvedReplies);
}
}
};
$walk($comments);
return array_values(array_unique($ids));
}
private function renderCommentContent(ArtworkComment $comment): string
{
$rawContent = (string) ($comment->raw_content ?? $comment->content ?? '');
+72
View File
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Enums\ReactionType;
use Illuminate\Support\Facades\DB;
/**
* Bulk reaction read model for artwork comment trees.
*
* The returned value intentionally matches ReactionController's `totals`
* contract so SSR and API comment payloads can hydrate ReactionBar directly.
*/
final class CommentReactionService
{
/**
* @param iterable<int|string> $commentIds
* @return array<int, array<string, array{emoji:string, label:string, count:int, mine:bool}>>
*/
public function forComments(iterable $commentIds, ?int $userId = null): array
{
$ids = collect($commentIds)
->map(static fn ($id): int => (int) $id)
->filter(static fn (int $id): bool => $id > 0)
->unique()
->values()
->all();
if ($ids === []) {
return [];
}
$counts = DB::table('comment_reactions')
->whereIn('comment_id', $ids)
->selectRaw('comment_id, reaction, COUNT(*) as total')
->groupBy('comment_id', 'reaction')
->get()
->groupBy('comment_id');
$mine = collect();
if ($userId !== null) {
$mine = DB::table('comment_reactions')
->whereIn('comment_id', $ids)
->where('user_id', $userId)
->get(['comment_id', 'reaction'])
->groupBy('comment_id');
}
$result = [];
foreach ($ids as $commentId) {
$commentCounts = $counts->get($commentId, collect())->keyBy('reaction');
$commentMine = $mine->get($commentId, collect())->pluck('reaction')->flip();
$totals = [];
foreach (ReactionType::cases() as $type) {
$slug = $type->value;
$totals[$slug] = [
'emoji' => $type->emoji(),
'label' => $type->label(),
'count' => (int) ($commentCounts->get($slug)->total ?? 0),
'mine' => $commentMine->has($slug),
];
}
$result[$commentId] = $totals;
}
return $result;
}
}