]> BookStack Code Mirror - bookstack/commitdiff
API: Added comment CUD endpoints, drafted tests
authorDan Brown <redacted>
Thu, 23 Oct 2025 09:21:33 +0000 (10:21 +0100)
committerDan Brown <redacted>
Thu, 23 Oct 2025 09:21:33 +0000 (10:21 +0100)
Move some checks and made some tweaks to the repo to support consistency
between API and UI.

app/Activity/CommentRepo.php
app/Activity/Controllers/CommentApiController.php
app/Activity/Controllers/CommentController.php
app/Http/ApiController.php
app/Permissions/Permission.php
tests/Activity/CommentsApiTest.php [new file with mode: 0644]
tests/Api/BooksApiTest.php

index 1c2333cae83ce64276ba18cedb06be74d432c420..ba12f4d33cb1195d5ef6b036fcfb0d9058230fc0 100644 (file)
@@ -4,6 +4,7 @@ namespace BookStack\Activity;
 
 use BookStack\Activity\Models\Comment;
 use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Page;
 use BookStack\Exceptions\NotifyException;
 use BookStack\Facades\Activity as ActivityService;
 use BookStack\Util\HtmlDescriptionFilter;
@@ -19,6 +20,15 @@ class CommentRepo
         return Comment::query()->findOrFail($id);
     }
 
+    /**
+     * Get a comment by ID, ensuring it is visible to the user based upon access to the page
+     * which the comment is attached to.
+     */
+    public function getVisibleById(int $id): Comment
+    {
+        return $this->getQueryForVisible()->findOrFail($id);
+    }
+
     /**
      * Start a query for comments visible to the user.
      */
@@ -32,6 +42,23 @@ class CommentRepo
      */
     public function create(Entity $entity, string $html, ?int $parentId, string $contentRef): Comment
     {
+        // Prevent comments being added to draft pages
+        if ($entity instanceof Page && $entity->draft) {
+            throw new \Exception(trans('errors.cannot_add_comment_to_draft'));
+        }
+
+        // Validate parent ID
+        if ($parentId !== null) {
+            $parentCommentExists = Comment::query()
+                ->where('entity_id', '=', $entity->id)
+                ->where('entity_type', '=', $entity->getMorphClass())
+                ->where('local_id', '=', $parentId)
+                ->exists();
+            if (!$parentCommentExists) {
+                $parentId = null;
+            }
+        }
+
         $userId = user()->id;
         $comment = new Comment();
 
@@ -67,7 +94,7 @@ class CommentRepo
     /**
      * Archive an existing comment.
      */
-    public function archive(Comment $comment): Comment
+    public function archive(Comment $comment, bool $log = true): Comment
     {
         if ($comment->parent_id) {
             throw new NotifyException('Only top-level comments can be archived.', '/', 400);
@@ -76,7 +103,9 @@ class CommentRepo
         $comment->archived = true;
         $comment->save();
 
-        ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
+        if ($log) {
+            ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
+        }
 
         return $comment;
     }
@@ -84,7 +113,7 @@ class CommentRepo
     /**
      * Un-archive an existing comment.
      */
-    public function unarchive(Comment $comment): Comment
+    public function unarchive(Comment $comment, bool $log = true): Comment
     {
         if ($comment->parent_id) {
             throw new NotifyException('Only top-level comments can be un-archived.', '/', 400);
@@ -93,7 +122,9 @@ class CommentRepo
         $comment->archived = false;
         $comment->save();
 
-        ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
+        if ($log) {
+            ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
+        }
 
         return $comment;
     }
index 3a4c33cd6c7adf44d17d10baeb57491fbbaa664c..7ba9b5b645d86e57e54fe7fa222fc3d195eb1998 100644 (file)
@@ -6,8 +6,12 @@ namespace BookStack\Activity\Controllers;
 
 use BookStack\Activity\CommentRepo;
 use BookStack\Activity\Models\Comment;
+use BookStack\Entities\Queries\PageQueries;
 use BookStack\Http\ApiController;
+use BookStack\Permissions\Permission;
 use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Http\Response;
 
 /**
  * The comment data model has a 'local_id' property, which is a unique integer ID
@@ -18,15 +22,26 @@ use Illuminate\Http\JsonResponse;
 class CommentApiController extends ApiController
 {
     // TODO - Add tree-style comment listing to page-show responses.
-    // TODO - create
-    // TODO - update
-    // TODO - delete
 
     // TODO - Test visibility controls
     // TODO - Test permissions of each action
 
+    protected array $rules = [
+        'create' => [
+            'page_id' => ['required', 'integer'],
+            'reply_to' => ['nullable', 'integer'],
+            'html' => ['required', 'string'],
+            'content_ref' => ['string'],
+        ],
+        'update' => [
+            'html' => ['required', 'string'],
+            'archived' => ['boolean'],
+        ]
+    ];
+
     public function __construct(
         protected CommentRepo $commentRepo,
+        protected PageQueries $pageQueries,
     ) {
     }
 
@@ -42,13 +57,34 @@ class CommentApiController extends ApiController
         ]);
     }
 
+    /**
+     * Create a new comment on a page.
+     * If commenting as a reply to an existing comment, the 'reply_to' parameter
+     * should be provided, set to the 'local_id' of the comment being replied to.
+     */
+    public function create(Request $request): JsonResponse
+    {
+        $this->checkPermission(Permission::CommentCreateAll);
+
+        $input = $this->validate($request, $this->rules()['create']);
+        $page = $this->pageQueries->findVisibleByIdOrFail($input['page_id']);
+
+        $comment = $this->commentRepo->create(
+            $page,
+            $input['html'],
+            $input['reply_to'] ?? null,
+            $input['content_ref'] ?? '',
+        );
+
+        return response()->json($comment);
+    }
+
     /**
      * Read the details of a single comment, along with its direct replies.
      */
     public function read(string $id): JsonResponse
     {
-        $comment = $this->commentRepo->getQueryForVisible()
-            ->where('id', '=', $id)->firstOrFail();
+        $comment = $this->commentRepo->getVisibleById(intval($id));
 
         $replies = $this->commentRepo->getQueryForVisible()
             ->where('parent_id', '=', $comment->local_id)
@@ -67,4 +103,45 @@ class CommentApiController extends ApiController
 
         return response()->json($comment);
     }
+
+
+    /**
+     * Update the content or archived status of an existing comment.
+     *
+     * Only provide a new archived status if needing to actively change the archive state.
+     * Only top-level comments (non-replies) can be archived or unarchived.
+     */
+    public function update(Request $request, string $id): JsonResponse
+    {
+        $comment = $this->commentRepo->getVisibleById(intval($id));
+        $this->checkOwnablePermission(Permission::CommentUpdate, $comment);
+
+        $input = $this->validate($request, $this->rules()['update']);
+
+        if (isset($input['archived'])) {
+            $archived = $input['archived'];
+            if ($archived) {
+                $this->commentRepo->archive($comment, false);
+            } else {
+                $this->commentRepo->unarchive($comment, false);
+            }
+        }
+
+        $comment = $this->commentRepo->update($comment, $input['html']);
+
+        return response()->json($comment);
+    }
+
+    /**
+     * Delete a single comment from the system.
+     */
+    public function delete(string $id): Response
+    {
+        $comment = $this->commentRepo->getVisibleById(intval($id));
+        $this->checkOwnablePermission(Permission::CommentDelete, $comment);
+
+        $this->commentRepo->delete($comment);
+
+        return response('', 204);
+    }
 }
index fd5463dff183fd99b425b1d08264beccbd2b9ef5..f61a2c8df6ec81d1a58dd01412d0f54a5031cb0c 100644 (file)
@@ -22,7 +22,7 @@ class CommentController extends Controller
     /**
      * Save a new comment for a Page.
      *
-     * @throws ValidationException
+     * @throws ValidationException|\Exception
      */
     public function savePageComment(Request $request, int $pageId)
     {
@@ -37,11 +37,6 @@ class CommentController extends Controller
             return response('Not found', 404);
         }
 
-        // Prevent adding comments to draft pages
-        if ($page->draft) {
-            return $this->jsonError(trans('errors.cannot_add_comment_to_draft'), 400);
-        }
-
         // Create a new comment.
         $this->checkPermission(Permission::CommentCreateAll);
         $contentRef = $input['content_ref'] ?? '';
index 1a92afa33801eebc4ef9bff5301caa9c5f606447..ac8844b811ce4ae39f629118a34e080c58df40c8 100644 (file)
@@ -8,6 +8,12 @@ use Illuminate\Http\JsonResponse;
 
 abstract class ApiController extends Controller
 {
+    /**
+     * The validation rules for this controller.
+     * Can alternative be defined in a rules() method is they need to be dynamic.
+     *
+     * @var array<string, string[]>
+     */
     protected array $rules = [];
 
     /**
index a434e54fd615b59e2f65822e7ea273517b200033..04878ada01f29a3cdebcc325069689140ff65a31 100644 (file)
@@ -48,9 +48,7 @@ enum Permission: string
     case AttachmentUpdateAll = 'attachment-update-all';
     case AttachmentUpdateOwn = 'attachment-update-own';
 
-    case CommentCreate = 'comment-create';
     case CommentCreateAll = 'comment-create-all';
-    case CommentCreateOwn = 'comment-create-own';
     case CommentDelete = 'comment-delete';
     case CommentDeleteAll = 'comment-delete-all';
     case CommentDeleteOwn = 'comment-delete-own';
diff --git a/tests/Activity/CommentsApiTest.php b/tests/Activity/CommentsApiTest.php
new file mode 100644 (file)
index 0000000..29769a2
--- /dev/null
@@ -0,0 +1,43 @@
+<?php
+
+namespace Activity;
+
+use BookStack\Activity\ActivityType;
+use BookStack\Facades\Activity;
+use Tests\Api\TestsApi;
+use Tests\TestCase;
+
+class CommentsApiTest extends TestCase
+{
+    use TestsApi;
+
+    public function test_endpoint_permission_controls()
+    {
+        // TODO
+    }
+
+    public function test_index()
+    {
+        // TODO
+    }
+
+    public function test_create()
+    {
+        // TODO
+    }
+
+    public function test_read()
+    {
+        // TODO
+    }
+
+    public function test_update()
+    {
+        // TODO
+    }
+
+    public function test_destroy()
+    {
+        // TODO
+    }
+}
index e5bd77b67d1f826ce53a417240914e36345e5ca3..86e10f58acb7b5671d3a10e26b515f653837f0aa 100644 (file)
@@ -5,7 +5,6 @@ namespace Tests\Api;
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Repos\BaseRepo;
 use Carbon\Carbon;
-use Illuminate\Support\Facades\DB;
 use Tests\TestCase;
 
 class BooksApiTest extends TestCase