Skip to content

Commit 322cce3

Browse files
authored
Merge pull request #37 from mubbi/feature/article-comments-api
feat(comments): added Get Article comments API
2 parents 703b6f1 + fbff564 commit 322cce3

File tree

8 files changed

+381
-3
lines changed

8 files changed

+381
-3
lines changed
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Http\Controllers\Api\V1\Article;
6+
7+
use App\Http\Controllers\Controller;
8+
use App\Http\Requests\V1\Article\GetCommentsRequest;
9+
use App\Http\Resources\MetaResource;
10+
use App\Http\Resources\V1\Comment\CommentResource;
11+
use App\Models\Article;
12+
use App\Services\ArticleService;
13+
use Dedoc\Scramble\Attributes\Group;
14+
use Illuminate\Http\JsonResponse;
15+
use Symfony\Component\HttpFoundation\Response;
16+
17+
#[Group('Comments', weight: 2)]
18+
class GetCommentsController extends Controller
19+
{
20+
public function __construct(private readonly ArticleService $articleService) {}
21+
22+
/**
23+
* Get Comments List
24+
*
25+
* Retrieve a paginated list of comments for an article (with 1 child level)
26+
*
27+
* @unauthenticated
28+
*
29+
* @response array{status: true, message: string, data: array{comments: CommentResource[], meta: MetaResource}}
30+
*/
31+
public function __invoke(GetCommentsRequest $request, Article $article): JsonResponse
32+
{
33+
$params = $request->withDefaults();
34+
35+
try {
36+
$parentId = $params['parent_id'] !== null ? (int) $params['parent_id'] : null;
37+
38+
$commentsDataResponse = CommentResource::collection($this->articleService->getArticleComments(
39+
$article->id,
40+
$parentId,
41+
(int) $params['per_page'],
42+
(int) $params['page']
43+
));
44+
/** @var array{data: array, meta: array} $commentsData */
45+
$commentsData = $commentsDataResponse->response()->getData(true);
46+
47+
return response()->apiSuccess(
48+
[
49+
'comments' => $commentsData['data'],
50+
'meta' => MetaResource::make($commentsData['meta']),
51+
],
52+
__('common.success')
53+
);
54+
} catch (\Throwable $e) {
55+
return response()->apiError(
56+
__('common.something_went_wrong'),
57+
Response::HTTP_INTERNAL_SERVER_ERROR
58+
);
59+
}
60+
}
61+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Http\Requests\V1\Article;
6+
7+
use Illuminate\Foundation\Http\FormRequest;
8+
9+
class GetCommentsRequest extends FormRequest
10+
{
11+
public function authorize(): bool
12+
{
13+
return true;
14+
}
15+
16+
/**
17+
* @return array<string, mixed>
18+
*/
19+
public function rules(): array
20+
{
21+
return [
22+
'per_page' => 'integer|min:1|max:100',
23+
'page' => 'integer|min:1',
24+
'parent_id' => 'nullable|integer|exists:comments,id',
25+
];
26+
}
27+
28+
/**
29+
* @return array<string, mixed>
30+
*/
31+
public function withDefaults(): array
32+
{
33+
return [
34+
'per_page' => $this->input('per_page', 10),
35+
'page' => $this->input('page', 1),
36+
'parent_id' => $this->input('parent_id'),
37+
];
38+
}
39+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Http\Resources\V1\Comment;
6+
7+
use App\Http\Resources\V1\Auth\UserResource;
8+
use App\Models\Comment;
9+
use Illuminate\Http\Request;
10+
use Illuminate\Http\Resources\Json\JsonResource;
11+
12+
/**
13+
* @mixin Comment
14+
*/
15+
class CommentResource extends JsonResource
16+
{
17+
/**
18+
* Transform the resource into an array.
19+
*
20+
* @param Request $request
21+
* @return array<string, mixed>
22+
*/
23+
public function toArray($request): array
24+
{
25+
return [
26+
'id' => $this->id,
27+
'user' => new UserResource($this->whenLoaded('user')),
28+
'content' => $this->content,
29+
'created_at' => $this->created_at,
30+
'replies_count' => $this->replies_count,
31+
'replies' => CommentResource::collection($this->whenLoaded('replies_page')),
32+
];
33+
}
34+
}

app/Models/Comment.php

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
* @property int $user_id
1515
* @property string $content
1616
* @property int|null $parent_comment_id
17+
* @property-read int $replies_count
18+
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Comment>|null $replies_page
1719
*
1820
* @mixin \Eloquent
1921
*
@@ -54,10 +56,12 @@ public function user(): BelongsTo
5456
}
5557

5658
/**
57-
* @return BelongsTo<Comment,Comment>
59+
* Get the replies (child comments) for this comment.
60+
*
61+
* @return \Illuminate\Database\Eloquent\Relations\HasMany<\App\Models\Comment, Comment>
5862
*/
59-
public function parent(): BelongsTo
63+
public function replies(): \Illuminate\Database\Eloquent\Relations\HasMany
6064
{
61-
return $this->belongsTo(Comment::class, 'parent_comment_id');
65+
return $this->hasMany(Comment::class, 'parent_comment_id');
6266
}
6367
}

app/Services/ArticleService.php

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use App\Models\Article;
88
use App\Models\Category;
9+
use App\Models\Comment;
910
use App\Models\Tag;
1011
use Illuminate\Database\Eloquent\Builder;
1112
use Illuminate\Pagination\LengthAwarePaginator;
@@ -145,4 +146,53 @@ public function getAllTags()
145146
{
146147
return Tag::query()->get(['id', 'name', 'slug']);
147148
}
149+
150+
/**
151+
* Get paginated comments for an article (with 1 child level or for a parent comment).
152+
*
153+
* Loads the comment's user, count of replies, and top replies (limited by $repliesPerPage).
154+
*
155+
* @param int $articleId The ID of the article.
156+
* @param int|null $parentId The ID of the parent comment (if loading child comments).
157+
* @param int $perPage Number of parent comments per page.
158+
* @param int $page Current page number.
159+
* @param int $repliesPerPage Number of child comments per parent.
160+
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator<int, \App\Models\Comment>
161+
*/
162+
public function getArticleComments(
163+
int $articleId,
164+
?int $parentId = null,
165+
int $perPage = 10,
166+
int $page = 1,
167+
int $repliesPerPage = 3
168+
): \Illuminate\Contracts\Pagination\LengthAwarePaginator {
169+
$query = Comment::query()
170+
->where('article_id', $articleId)
171+
->when($parentId !== null, fn ($q) => $q->where('parent_comment_id', $parentId))
172+
->when($parentId === null, fn ($q) => $q->whereNull('parent_comment_id'))
173+
->orderBy('created_at');
174+
175+
$paginator = $query->paginate($perPage, ['*'], 'page', $page);
176+
177+
/** @var \Illuminate\Database\Eloquent\Collection<int, \App\Models\Comment> $comments */
178+
$comments = $paginator->getCollection();
179+
180+
$comments->load(['user']);
181+
$comments->loadCount('replies');
182+
183+
// Load replies for each parent comment
184+
$comments->each(function (Comment $comment) use ($repliesPerPage) {
185+
$replies = $comment->replies()
186+
->with('user')
187+
->withCount('replies')
188+
->orderBy('created_at')
189+
->limit($repliesPerPage)
190+
->get();
191+
192+
$comment->setRelation('replies_page', $replies);
193+
});
194+
195+
// Replace the collection on paginator so it's returned with relations loaded
196+
return $paginator->setCollection($comments);
197+
}
148198
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Database\Seeders;
6+
7+
use App\Models\Article;
8+
use App\Models\Comment;
9+
use App\Models\User;
10+
use Illuminate\Database\Seeder;
11+
12+
class ArticleCommentSeeder extends Seeder
13+
{
14+
/**
15+
* Run this seeder for API testing purpose only.
16+
* NOTE: DON'T RUN THIS IN PRODUCTION, this is for testing purposes only.
17+
*/
18+
public function run(): void
19+
{
20+
// Create 10 users for comments
21+
$users = User::factory(10)->create();
22+
23+
// Create 20 categories and 30 tags
24+
$categories = \App\Models\Category::factory(20)->create();
25+
$tags = \App\Models\Tag::factory(30)->create();
26+
27+
// Create 100 articles
28+
$articles = Article::factory(100)->create();
29+
30+
foreach ($articles as $article) {
31+
// Attach 1-3 random categories to each article
32+
$article->categories()->attach($categories->random(rand(1, 3))->pluck('id')->toArray());
33+
// Attach 2-5 random tags to each article
34+
$article->tags()->attach($tags->random(rand(2, 5))->pluck('id')->toArray());
35+
36+
// Create 10 top-level comments for each article
37+
$topComments = [];
38+
for ($i = 0; $i < 10; $i++) {
39+
$topComments[$i] = Comment::factory()->create([
40+
'article_id' => $article->id,
41+
'user_id' => $users->random()->id,
42+
'parent_comment_id' => null,
43+
]);
44+
}
45+
// For each top-level comment, create 2 child comments (level 1)
46+
foreach ($topComments as $parentComment) {
47+
for ($j = 0; $j < 2; $j++) {
48+
$child = Comment::factory()->create([
49+
'article_id' => $article->id,
50+
'user_id' => $users->random()->id,
51+
'parent_comment_id' => $parentComment->id,
52+
]);
53+
// For each child comment, create 2 more child comments (level 2)
54+
for ($k = 0; $k < 2; $k++) {
55+
Comment::factory()->create([
56+
'article_id' => $article->id,
57+
'user_id' => $users->random()->id,
58+
'parent_comment_id' => $child->id,
59+
]);
60+
}
61+
}
62+
}
63+
}
64+
}
65+
}

routes/api_v1.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
Route::prefix('articles')->group(function () {
2424
Route::get('/', \App\Http\Controllers\Api\V1\Article\GetArticlesController::class)->name('api.v1.articles.index');
2525
Route::get('/{slug}', \App\Http\Controllers\Api\V1\Article\ShowArticleController::class)->name('api.v1.articles.show');
26+
Route::get('/{article:slug}/comments', \App\Http\Controllers\Api\V1\Article\GetCommentsController::class)->name('api.v1.articles.comments.index');
2627
});
2728

2829
// Category Routes (Public)

0 commit comments

Comments
 (0)