diff --git a/app/Http/Controllers/Api/ArtworkCommentController.php b/app/Http/Controllers/Api/ArtworkCommentController.php index 3e7ef985..36618337 100644 --- a/app/Http/Controllers/Api/ArtworkCommentController.php +++ b/app/Http/Controllers/Api/ArtworkCommentController.php @@ -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 $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 ?? ''); diff --git a/app/Services/CommentReactionService.php b/app/Services/CommentReactionService.php new file mode 100644 index 00000000..0c80adc8 --- /dev/null +++ b/app/Services/CommentReactionService.php @@ -0,0 +1,72 @@ + $commentIds + * @return array> + */ + 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; + } +} diff --git a/tests/Feature/Artworks/M18B1CommentReactionReadTest.php b/tests/Feature/Artworks/M18B1CommentReactionReadTest.php new file mode 100644 index 00000000..2acf605c --- /dev/null +++ b/tests/Feature/Artworks/M18B1CommentReactionReadTest.php @@ -0,0 +1,146 @@ +create(); + $author = User::factory()->create(); + $viewer = User::factory()->create(); + $other = User::factory()->create(); + $comments = []; + + for ($i = 0; $i < $topLevelCount; $i++) { + $comment = ArtworkComment::factory()->create([ + 'artwork_id' => $artwork->id, + 'user_id' => $author->id, + 'parent_id' => null, + ]); + $reply = ArtworkComment::factory()->create([ + 'artwork_id' => $artwork->id, + 'user_id' => $other->id, + 'parent_id' => $comment->id, + ]); + + CommentReaction::create([ + 'comment_id' => $comment->id, + 'user_id' => $other->id, + 'reaction' => ReactionType::Heart->value, + ]); + CommentReaction::create([ + 'comment_id' => $reply->id, + 'user_id' => $viewer->id, + 'reaction' => ReactionType::Fire->value, + ]); + CommentReaction::create([ + 'comment_id' => $reply->id, + 'user_id' => $other->id, + 'reaction' => ReactionType::Clap->value, + ]); + + $comments[] = [$comment, $reply]; + } + + return [$artwork, $viewer, $comments]; +} + +function m18b1ReactionQueryCount(array $queries): int +{ + return collect($queries) + ->filter(fn (array $query): bool => str_contains(strtolower((string) $query['query']), 'comment_reactions')) + ->count(); +} + +it('includes bulk reaction totals for guests with one reaction query', function (): void { + [$artwork, , $comments] = m18b1ArtworkCommentTree(); + + DB::enableQueryLog(); + DB::flushQueryLog(); + + $response = $this->getJson("/api/artworks/{$artwork->id}/comments")->assertOk(); + $payload = $response->json('data'); + $withoutReactions = json_decode(json_encode($payload, JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR); + $stripReactions = function (array &$items) use (&$stripReactions): void { + foreach ($items as &$item) { + unset($item['reactions']); + if (isset($item['replies']) && is_array($item['replies'])) { + $stripReactions($item['replies']); + } + } + }; + $stripReactions($withoutReactions); + $commentCount = 0; + $countComments = function (array $items) use (&$countComments, &$commentCount): void { + foreach ($items as $item) { + $commentCount++; + if (isset($item['replies']) && is_array($item['replies'])) { + $countComments($item['replies']); + } + } + }; + $countComments($payload); + + fwrite(STDERR, sprintf( + "M18B1 guest payload bytes: before=%d after=%d added=%d comments/replies=%d\n", + strlen(json_encode($withoutReactions, JSON_THROW_ON_ERROR)), + strlen(json_encode($payload, JSON_THROW_ON_ERROR)), + strlen(json_encode($payload, JSON_THROW_ON_ERROR)) - strlen(json_encode($withoutReactions, JSON_THROW_ON_ERROR)), + $commentCount, + )); + + expect(m18b1ReactionQueryCount(DB::getQueryLog()))->toBe(1) + ->and($payload[0])->toHaveKey('reactions') + ->and($payload[0]['reactions'][ReactionType::Heart->value]['count'])->toBe(1) + ->and($payload[0]['reactions'][ReactionType::Heart->value]['mine'])->toBeFalse() + ->and($payload[0]['replies'][0]['reactions'][ReactionType::Fire->value]['count'])->toBe(1) + ->and(count($comments))->toBe(2); + + DB::disableQueryLog(); +}); + +it('includes viewer reaction state for comments and replies with one extra query', function (): void { + [$artwork, $viewer] = m18b1ArtworkCommentTree(); + + DB::enableQueryLog(); + DB::flushQueryLog(); + + $response = $this->actingAs($viewer) + ->getJson("/api/artworks/{$artwork->id}/comments") + ->assertOk(); + $payload = $response->json('data'); + + expect(m18b1ReactionQueryCount(DB::getQueryLog()))->toBe(2) + ->and($payload[0]['reactions'][ReactionType::Heart->value]['mine'])->toBeFalse() + ->and($payload[0]['replies'][0]['reactions'][ReactionType::Fire->value]['mine'])->toBeTrue(); + + DB::disableQueryLog(); +}); + +it('keeps reaction query count constant as the comment tree grows', function (): void { + [$smallArtwork] = m18b1ArtworkCommentTree(2); + [$largeArtwork] = m18b1ArtworkCommentTree(20); + + DB::enableQueryLog(); + + DB::flushQueryLog(); + $this->getJson("/api/artworks/{$smallArtwork->id}/comments")->assertOk(); + $smallCount = m18b1ReactionQueryCount(DB::getQueryLog()); + + DB::flushQueryLog(); + $this->getJson("/api/artworks/{$largeArtwork->id}/comments")->assertOk(); + $largeCount = m18b1ReactionQueryCount(DB::getQueryLog()); + + DB::disableQueryLog(); + + expect($smallCount)->toBe(1)->and($largeCount)->toBe(1); +});