Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions app/Http/Controllers/Api/V1/Article/GetCommentsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api\V1\Article;

use App\Http\Controllers\Controller;
use App\Http\Requests\V1\Article\GetCommentsRequest;
use App\Http\Resources\MetaResource;
use App\Http\Resources\V1\Comment\CommentResource;
use App\Models\Article;
use App\Services\ArticleService;
use Dedoc\Scramble\Attributes\Group;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;

#[Group('Comments', weight: 2)]
class GetCommentsController extends Controller
{
public function __construct(private readonly ArticleService $articleService) {}

/**
* Get Comments List
*
* Retrieve a paginated list of comments for an article (with 1 child level)
*
* @unauthenticated
*
* @response array{status: true, message: string, data: array{comments: CommentResource[], meta: MetaResource}}
*/
public function __invoke(GetCommentsRequest $request, Article $article): JsonResponse
{
$params = $request->withDefaults();

try {
$parentId = $params['parent_id'] !== null ? (int) $params['parent_id'] : null;

$commentsDataResponse = CommentResource::collection($this->articleService->getArticleComments(
$article->id,
$parentId,
(int) $params['per_page'],
(int) $params['page']
));
/** @var array{data: array, meta: array} $commentsData */
$commentsData = $commentsDataResponse->response()->getData(true);

return response()->apiSuccess(
[
'comments' => $commentsData['data'],
'meta' => MetaResource::make($commentsData['meta']),
],
__('common.success')
);
} catch (\Throwable $e) {
return response()->apiError(
__('common.something_went_wrong'),
Response::HTTP_INTERNAL_SERVER_ERROR
);
}
}
}
39 changes: 39 additions & 0 deletions app/Http/Requests/V1/Article/GetCommentsRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests\V1\Article;

use Illuminate\Foundation\Http\FormRequest;

class GetCommentsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'per_page' => 'integer|min:1|max:100',
'page' => 'integer|min:1',
'parent_id' => 'nullable|integer|exists:comments,id',
];
}

/**
* @return array<string, mixed>
*/
public function withDefaults(): array
{
return [
'per_page' => $this->input('per_page', 10),
'page' => $this->input('page', 1),
'parent_id' => $this->input('parent_id'),
];
}
}
34 changes: 34 additions & 0 deletions app/Http/Resources/V1/Comment/CommentResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace App\Http\Resources\V1\Comment;

use App\Http\Resources\V1\Auth\UserResource;
use App\Models\Comment;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

/**
* @mixin Comment
*/
class CommentResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param Request $request
* @return array<string, mixed>
*/
public function toArray($request): array
{
return [
'id' => $this->id,
'user' => new UserResource($this->whenLoaded('user')),
'content' => $this->content,
'created_at' => $this->created_at,
'replies_count' => $this->replies_count,
'replies' => CommentResource::collection($this->whenLoaded('replies_page')),
];
}
}
10 changes: 7 additions & 3 deletions app/Models/Comment.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
* @property int $user_id
* @property string $content
* @property int|null $parent_comment_id
* @property-read int $replies_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Comment>|null $replies_page
*
* @mixin \Eloquent
*
Expand Down Expand Up @@ -54,10 +56,12 @@ public function user(): BelongsTo
}

/**
* @return BelongsTo<Comment,Comment>
* Get the replies (child comments) for this comment.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany<\App\Models\Comment, Comment>
*/
public function parent(): BelongsTo
public function replies(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->belongsTo(Comment::class, 'parent_comment_id');
return $this->hasMany(Comment::class, 'parent_comment_id');
}
}
50 changes: 50 additions & 0 deletions app/Services/ArticleService.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use App\Models\Article;
use App\Models\Category;
use App\Models\Comment;
use App\Models\Tag;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
Expand Down Expand Up @@ -145,4 +146,53 @@ public function getAllTags()
{
return Tag::query()->get(['id', 'name', 'slug']);
}

/**
* Get paginated comments for an article (with 1 child level or for a parent comment).
*
* Loads the comment's user, count of replies, and top replies (limited by $repliesPerPage).
*
* @param int $articleId The ID of the article.
* @param int|null $parentId The ID of the parent comment (if loading child comments).
* @param int $perPage Number of parent comments per page.
* @param int $page Current page number.
* @param int $repliesPerPage Number of child comments per parent.
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator<int, \App\Models\Comment>
*/
public function getArticleComments(
int $articleId,
?int $parentId = null,
int $perPage = 10,
int $page = 1,
int $repliesPerPage = 3
): \Illuminate\Contracts\Pagination\LengthAwarePaginator {
$query = Comment::query()
->where('article_id', $articleId)
->when($parentId !== null, fn ($q) => $q->where('parent_comment_id', $parentId))
->when($parentId === null, fn ($q) => $q->whereNull('parent_comment_id'))
->orderBy('created_at');

$paginator = $query->paginate($perPage, ['*'], 'page', $page);

/** @var \Illuminate\Database\Eloquent\Collection<int, \App\Models\Comment> $comments */
$comments = $paginator->getCollection();

$comments->load(['user']);
$comments->loadCount('replies');

// Load replies for each parent comment
$comments->each(function (Comment $comment) use ($repliesPerPage) {
$replies = $comment->replies()
->with('user')
->withCount('replies')
->orderBy('created_at')
->limit($repliesPerPage)
->get();

$comment->setRelation('replies_page', $replies);
});

// Replace the collection on paginator so it's returned with relations loaded
return $paginator->setCollection($comments);
}
}
65 changes: 65 additions & 0 deletions database/seeders/ArticleCommentSeeder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

declare(strict_types=1);

namespace Database\Seeders;

use App\Models\Article;
use App\Models\Comment;
use App\Models\User;
use Illuminate\Database\Seeder;

class ArticleCommentSeeder extends Seeder
{
/**
* Run this seeder for API testing purpose only.
* NOTE: DON'T RUN THIS IN PRODUCTION, this is for testing purposes only.
*/
public function run(): void
{
// Create 10 users for comments
$users = User::factory(10)->create();

// Create 20 categories and 30 tags
$categories = \App\Models\Category::factory(20)->create();
$tags = \App\Models\Tag::factory(30)->create();

// Create 100 articles
$articles = Article::factory(100)->create();

foreach ($articles as $article) {
// Attach 1-3 random categories to each article
$article->categories()->attach($categories->random(rand(1, 3))->pluck('id')->toArray());
// Attach 2-5 random tags to each article
$article->tags()->attach($tags->random(rand(2, 5))->pluck('id')->toArray());

// Create 10 top-level comments for each article
$topComments = [];
for ($i = 0; $i < 10; $i++) {
$topComments[$i] = Comment::factory()->create([
'article_id' => $article->id,
'user_id' => $users->random()->id,
'parent_comment_id' => null,
]);
}
// For each top-level comment, create 2 child comments (level 1)
foreach ($topComments as $parentComment) {
for ($j = 0; $j < 2; $j++) {
$child = Comment::factory()->create([
'article_id' => $article->id,
'user_id' => $users->random()->id,
'parent_comment_id' => $parentComment->id,
]);
// For each child comment, create 2 more child comments (level 2)
for ($k = 0; $k < 2; $k++) {
Comment::factory()->create([
'article_id' => $article->id,
'user_id' => $users->random()->id,
'parent_comment_id' => $child->id,
]);
}
}
}
}
}
}
1 change: 1 addition & 0 deletions routes/api_v1.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
Route::prefix('articles')->group(function () {
Route::get('/', \App\Http\Controllers\Api\V1\Article\GetArticlesController::class)->name('api.v1.articles.index');
Route::get('/{slug}', \App\Http\Controllers\Api\V1\Article\ShowArticleController::class)->name('api.v1.articles.show');
Route::get('/{article:slug}/comments', \App\Http\Controllers\Api\V1\Article\GetCommentsController::class)->name('api.v1.articles.comments.index');
});

// Category Routes (Public)
Expand Down
Loading