From 082dbc9944e6ee1f08b16ff445b48498edfdcc1f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 22 Oct 2025 14:58:29 +0100 Subject: [PATCH 1/7] API: Started building comments API endpoints --- app/Activity/CommentRepo.php | 10 ++++- .../Controllers/CommentApiController.php | 43 +++++++++++++++++++ app/Activity/Models/Comment.php | 31 ++++++++++--- app/Entities/Models/Entity.php | 2 +- ...7_update_comments_relation_field_names.php | 30 +++++++++++++ routes/api.php | 6 ++- 6 files changed, 112 insertions(+), 10 deletions(-) create mode 100644 app/Activity/Controllers/CommentApiController.php create mode 100644 database/migrations/2025_10_22_134507_update_comments_relation_field_names.php diff --git a/app/Activity/CommentRepo.php b/app/Activity/CommentRepo.php index 7005f8fcf..1c2333cae 100644 --- a/app/Activity/CommentRepo.php +++ b/app/Activity/CommentRepo.php @@ -5,9 +5,9 @@ namespace BookStack\Activity; use BookStack\Activity\Models\Comment; use BookStack\Entities\Models\Entity; use BookStack\Exceptions\NotifyException; -use BookStack\Exceptions\PrettyException; use BookStack\Facades\Activity as ActivityService; use BookStack\Util\HtmlDescriptionFilter; +use Illuminate\Database\Eloquent\Builder; class CommentRepo { @@ -19,6 +19,14 @@ class CommentRepo return Comment::query()->findOrFail($id); } + /** + * Start a query for comments visible to the user. + */ + public function getQueryForVisible(): Builder + { + return Comment::query()->scopes('visible'); + } + /** * Create a new comment on an entity. */ diff --git a/app/Activity/Controllers/CommentApiController.php b/app/Activity/Controllers/CommentApiController.php new file mode 100644 index 000000000..25421077a --- /dev/null +++ b/app/Activity/Controllers/CommentApiController.php @@ -0,0 +1,43 @@ +commentRepo->getQueryForVisible(); + + return $this->apiListingResponse($query, [ + 'id', 'commentable_id', 'commentable_type', 'parent_id', 'local_id', 'content_ref', 'created_by', 'updated_by', 'created_at', 'updated_at' + ]); + } +} diff --git a/app/Activity/Models/Comment.php b/app/Activity/Models/Comment.php index 0cb83d61e..caca7809f 100644 --- a/app/Activity/Models/Comment.php +++ b/app/Activity/Models/Comment.php @@ -3,12 +3,15 @@ namespace BookStack\Activity\Models; use BookStack\App\Model; +use BookStack\Permissions\Models\JointPermission; +use BookStack\Permissions\PermissionApplicator; use BookStack\Users\Models\HasCreatorAndUpdater; use BookStack\Users\Models\OwnableInterface; -use BookStack\Users\Models\User; use BookStack\Util\HtmlContentFilter; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphTo; /** @@ -17,8 +20,8 @@ use Illuminate\Database\Eloquent\Relations\MorphTo; * @property string $html * @property int|null $parent_id - Relates to local_id, not id * @property int $local_id - * @property string $entity_type - * @property int $entity_id + * @property string $commentable_type + * @property int $commentable_id * @property string $content_ref * @property bool $archived */ @@ -44,8 +47,8 @@ class Comment extends Model implements Loggable, OwnableInterface public function parent(): BelongsTo { return $this->belongsTo(Comment::class, 'parent_id', 'local_id', 'parent') - ->where('entity_type', '=', $this->entity_type) - ->where('entity_id', '=', $this->entity_id); + ->where('commentable_type', '=', $this->commentable_type) + ->where('commentable_id', '=', $this->commentable_id); } /** @@ -58,11 +61,27 @@ class Comment extends Model implements Loggable, OwnableInterface public function logDescriptor(): string { - return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->entity_type} (ID: {$this->entity_id})"; + return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->commentable_type} (ID: {$this->commentable_id})"; } public function safeHtml(): string { return HtmlContentFilter::removeScriptsFromHtmlString($this->html ?? ''); } + + public function jointPermissions(): HasMany + { + return $this->hasMany(JointPermission::class, 'entity_id', 'commentable_id') + ->whereColumn('joint_permissions.entity_type', '=', 'comments.commentable_type'); + } + + /** + * Scope the query to just the comments visible to the user based upon the + * user visibility of what has been commented on. + */ + public function scopeVisible(Builder $query): Builder + { + return app()->make(PermissionApplicator::class) + ->restrictEntityRelationQuery($query, 'comments', 'commentable_id', 'commentable_type'); + } } diff --git a/app/Entities/Models/Entity.php b/app/Entities/Models/Entity.php index b71016ea1..c6839c15a 100644 --- a/app/Entities/Models/Entity.php +++ b/app/Entities/Models/Entity.php @@ -240,7 +240,7 @@ abstract class Entity extends Model implements */ public function comments(bool $orderByCreated = true): MorphMany { - $query = $this->morphMany(Comment::class, 'entity'); + $query = $this->morphMany(Comment::class, 'commentable'); return $orderByCreated ? $query->orderBy('created_at', 'asc') : $query; } diff --git a/database/migrations/2025_10_22_134507_update_comments_relation_field_names.php b/database/migrations/2025_10_22_134507_update_comments_relation_field_names.php new file mode 100644 index 000000000..de13453de --- /dev/null +++ b/database/migrations/2025_10_22_134507_update_comments_relation_field_names.php @@ -0,0 +1,30 @@ +renameColumn('entity_id', 'commentable_id'); + $table->renameColumn('entity_type', 'commentable_type'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('comments', function (Blueprint $table) { + $table->renameColumn('commentable_id', 'entity_id'); + $table->renameColumn('commentable_type', 'entity_type'); + }); + } +}; diff --git a/routes/api.php b/routes/api.php index 99df24aed..b030ca7f7 100644 --- a/routes/api.php +++ b/routes/api.php @@ -6,7 +6,7 @@ * Controllers all end with "ApiController" */ -use BookStack\Activity\Controllers\AuditLogApiController; +use BookStack\Activity\Controllers as ActivityControllers; use BookStack\Api\ApiDocsController; use BookStack\App\SystemApiController; use BookStack\Entities\Controllers as EntityControllers; @@ -70,6 +70,8 @@ Route::delete('image-gallery/{id}', [ImageGalleryApiController::class, 'delete'] Route::get('search', [SearchApiController::class, 'all']); +Route::get('comments', [ActivityControllers\CommentApiController::class, 'list']); + Route::get('shelves', [EntityControllers\BookshelfApiController::class, 'list']); Route::post('shelves', [EntityControllers\BookshelfApiController::class, 'create']); Route::get('shelves/{id}', [EntityControllers\BookshelfApiController::class, 'read']); @@ -101,6 +103,6 @@ Route::delete('recycle-bin/{deletionId}', [EntityControllers\RecycleBinApiContro Route::get('content-permissions/{contentType}/{contentId}', [ContentPermissionApiController::class, 'read']); Route::put('content-permissions/{contentType}/{contentId}', [ContentPermissionApiController::class, 'update']); -Route::get('audit-log', [AuditLogApiController::class, 'list']); +Route::get('audit-log', [ActivityControllers\AuditLogApiController::class, 'list']); Route::get('system', [SystemApiController::class, 'read']); From 3ad1e31fcccd38bf864d54f4f813c81fb4574c4c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 22 Oct 2025 18:44:49 +0100 Subject: [PATCH 2/7] API: Added comment-read endpoint, added api docs section descriptions --- .../Controllers/CommentApiController.php | 39 ++++++++++++++++--- app/Activity/Models/Comment.php | 6 ++- app/Api/ApiDocsGenerator.php | 24 ++++++++++-- .../Controllers/BookApiController.php | 2 +- resources/views/api-docs/index.blade.php | 4 +- routes/api.php | 4 ++ 6 files changed, 67 insertions(+), 12 deletions(-) diff --git a/app/Activity/Controllers/CommentApiController.php b/app/Activity/Controllers/CommentApiController.php index 25421077a..3a4c33cd6 100644 --- a/app/Activity/Controllers/CommentApiController.php +++ b/app/Activity/Controllers/CommentApiController.php @@ -5,30 +5,31 @@ declare(strict_types=1); namespace BookStack\Activity\Controllers; use BookStack\Activity\CommentRepo; +use BookStack\Activity\Models\Comment; use BookStack\Http\ApiController; use Illuminate\Http\JsonResponse; +/** + * The comment data model has a 'local_id' property, which is a unique integer ID + * scoped to the page which the comment is on. The 'parent_id' is used for replies + * and refers to the 'local_id' of the parent comment on the same page, not the main + * globally unique 'id'. + */ class CommentApiController extends ApiController { // TODO - Add tree-style comment listing to page-show responses. - // TODO - list // TODO - create - // TODO - read // TODO - update // TODO - delete // TODO - Test visibility controls // TODO - Test permissions of each action - // TODO - Support intro block for API docs so we can explain the - // properties for comments in a shared kind of way? - public function __construct( protected CommentRepo $commentRepo, ) { } - /** * Get a listing of comments visible to the user. */ @@ -40,4 +41,30 @@ class CommentApiController extends ApiController 'id', 'commentable_id', 'commentable_type', 'parent_id', 'local_id', 'content_ref', 'created_by', 'updated_by', 'created_at', 'updated_at' ]); } + + /** + * 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(); + + $replies = $this->commentRepo->getQueryForVisible() + ->where('parent_id', '=', $comment->local_id) + ->where('commentable_id', '=', $comment->commentable_id) + ->where('commentable_type', '=', $comment->commentable_type) + ->get(); + + /** @var Comment[] $toProcess */ + $toProcess = [$comment, ...$replies]; + foreach ($toProcess as $commentToProcess) { + $commentToProcess->setAttribute('html', $commentToProcess->safeHtml()); + $commentToProcess->makeVisible('html'); + } + + $comment->setRelation('replies', $replies); + + return response()->json($comment); + } } diff --git a/app/Activity/Models/Comment.php b/app/Activity/Models/Comment.php index caca7809f..91c91e7a8 100644 --- a/app/Activity/Models/Comment.php +++ b/app/Activity/Models/Comment.php @@ -16,7 +16,6 @@ use Illuminate\Database\Eloquent\Relations\MorphTo; /** * @property int $id - * @property string $text - Deprecated & now unused (#4821) * @property string $html * @property int|null $parent_id - Relates to local_id, not id * @property int $local_id @@ -31,6 +30,11 @@ class Comment extends Model implements Loggable, OwnableInterface use HasCreatorAndUpdater; protected $fillable = ['parent_id']; + protected $hidden = ['html']; + + protected $casts = [ + 'archived' => 'boolean', + ]; /** * Get the entity that this comment belongs to. diff --git a/app/Api/ApiDocsGenerator.php b/app/Api/ApiDocsGenerator.php index 287c83877..eb8f5508c 100644 --- a/app/Api/ApiDocsGenerator.php +++ b/app/Api/ApiDocsGenerator.php @@ -83,11 +83,19 @@ class ApiDocsGenerator protected function loadDetailsFromControllers(Collection $routes): Collection { return $routes->map(function (array $route) { + $class = $this->getReflectionClass($route['controller']); $method = $this->getReflectionMethod($route['controller'], $route['controller_method']); $comment = $method->getDocComment(); - $route['description'] = $comment ? $this->parseDescriptionFromMethodComment($comment) : null; + $route['description'] = $comment ? $this->parseDescriptionFromDocBlockComment($comment) : null; $route['body_params'] = $this->getBodyParamsFromClass($route['controller'], $route['controller_method']); + // Load class description for the model + // Not ideal to have it here on each route, but adding it in a more structured manner would break + // docs resulting JSON format and therefore be an API break. + // Save refactoring for a more significant set of changes. + $classComment = $class->getDocComment(); + $route['model_description'] = $classComment ? $this->parseDescriptionFromDocBlockComment($classComment) : null; + return $route; }); } @@ -140,7 +148,7 @@ class ApiDocsGenerator /** * Parse out the description text from a class method comment. */ - protected function parseDescriptionFromMethodComment(string $comment): string + protected function parseDescriptionFromDocBlockComment(string $comment): string { $matches = []; preg_match_all('/^\s*?\*\s?($|((?![\/@\s]).*?))$/m', $comment, $matches); @@ -155,6 +163,16 @@ class ApiDocsGenerator * @throws ReflectionException */ protected function getReflectionMethod(string $className, string $methodName): ReflectionMethod + { + return $this->getReflectionClass($className)->getMethod($methodName); + } + + /** + * Get a reflection class from the given class name. + * + * @throws ReflectionException + */ + protected function getReflectionClass(string $className): ReflectionClass { $class = $this->reflectionClasses[$className] ?? null; if ($class === null) { @@ -162,7 +180,7 @@ class ApiDocsGenerator $this->reflectionClasses[$className] = $class; } - return $class->getMethod($methodName); + return $class; } /** diff --git a/app/Entities/Controllers/BookApiController.php b/app/Entities/Controllers/BookApiController.php index 807f5a69c..325f0583c 100644 --- a/app/Entities/Controllers/BookApiController.php +++ b/app/Entities/Controllers/BookApiController.php @@ -58,7 +58,7 @@ class BookApiController extends ApiController /** * View the details of a single book. - * The response data will contain 'content' property listing the chapter and pages directly within, in + * The response data will contain a 'content' property listing the chapter and pages directly within, in * the same structure as you'd see within the BookStack interface when viewing a book. Top-level * contents will have a 'type' property to distinguish between pages & chapters. */ diff --git a/resources/views/api-docs/index.blade.php b/resources/views/api-docs/index.blade.php index 9345a7bce..84c6d21ac 100644 --- a/resources/views/api-docs/index.blade.php +++ b/resources/views/api-docs/index.blade.php @@ -45,7 +45,9 @@ @foreach($docs as $model => $endpoints)

{{ $model }}

- + @if($endpoints[0]['model_description']) +

{{ $endpoints[0]['model_description'] }}

+ @endif @foreach($endpoints as $endpoint) @include('api-docs.parts.endpoint', ['endpoint' => $endpoint, 'loop' => $loop]) @endforeach diff --git a/routes/api.php b/routes/api.php index b030ca7f7..4b661da5d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -71,6 +71,10 @@ Route::delete('image-gallery/{id}', [ImageGalleryApiController::class, 'delete'] Route::get('search', [SearchApiController::class, 'all']); Route::get('comments', [ActivityControllers\CommentApiController::class, 'list']); +Route::post('comments', [ActivityControllers\CommentApiController::class, 'create']); +Route::get('comments/{id}', [ActivityControllers\CommentApiController::class, 'read']); +Route::put('comments/{id}', [ActivityControllers\CommentApiController::class, 'update']); +Route::delete('comments/{id}', [ActivityControllers\CommentApiController::class, 'delete']); Route::get('shelves', [EntityControllers\BookshelfApiController::class, 'list']); Route::post('shelves', [EntityControllers\BookshelfApiController::class, 'create']); From cbf27d70c823c696a831f1945e4a7b869af8ce65 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 23 Oct 2025 10:21:33 +0100 Subject: [PATCH 3/7] API: Added comment CUD endpoints, drafted tests Move some checks and made some tweaks to the repo to support consistency between API and UI. --- app/Activity/CommentRepo.php | 39 ++++++++- .../Controllers/CommentApiController.php | 87 +++++++++++++++++-- .../Controllers/CommentController.php | 7 +- app/Http/ApiController.php | 6 ++ app/Permissions/Permission.php | 2 - tests/Activity/CommentsApiTest.php | 43 +++++++++ tests/Api/BooksApiTest.php | 1 - 7 files changed, 167 insertions(+), 18 deletions(-) create mode 100644 tests/Activity/CommentsApiTest.php diff --git a/app/Activity/CommentRepo.php b/app/Activity/CommentRepo.php index 1c2333cae..ba12f4d33 100644 --- a/app/Activity/CommentRepo.php +++ b/app/Activity/CommentRepo.php @@ -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; } diff --git a/app/Activity/Controllers/CommentApiController.php b/app/Activity/Controllers/CommentApiController.php index 3a4c33cd6..7ba9b5b64 100644 --- a/app/Activity/Controllers/CommentApiController.php +++ b/app/Activity/Controllers/CommentApiController.php @@ -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); + } } diff --git a/app/Activity/Controllers/CommentController.php b/app/Activity/Controllers/CommentController.php index fd5463dff..f61a2c8df 100644 --- a/app/Activity/Controllers/CommentController.php +++ b/app/Activity/Controllers/CommentController.php @@ -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'] ?? ''; diff --git a/app/Http/ApiController.php b/app/Http/ApiController.php index 1a92afa33..ac8844b81 100644 --- a/app/Http/ApiController.php +++ b/app/Http/ApiController.php @@ -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 + */ protected array $rules = []; /** diff --git a/app/Permissions/Permission.php b/app/Permissions/Permission.php index a434e54fd..04878ada0 100644 --- a/app/Permissions/Permission.php +++ b/app/Permissions/Permission.php @@ -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 index 000000000..29769a260 --- /dev/null +++ b/tests/Activity/CommentsApiTest.php @@ -0,0 +1,43 @@ + Date: Thu, 23 Oct 2025 16:52:29 +0100 Subject: [PATCH 4/7] API: Built out tests for comment API endpoints --- app/Activity/CommentRepo.php | 4 +- .../Controllers/CommentApiController.php | 18 +- app/Activity/Models/Comment.php | 2 +- tests/Activity/CommentsApiTest.php | 223 +++++++++++++++++- tests/TestCase.php | 2 +- 5 files changed, 228 insertions(+), 21 deletions(-) diff --git a/app/Activity/CommentRepo.php b/app/Activity/CommentRepo.php index ba12f4d33..20513bcd4 100644 --- a/app/Activity/CommentRepo.php +++ b/app/Activity/CommentRepo.php @@ -50,8 +50,8 @@ class CommentRepo // Validate parent ID if ($parentId !== null) { $parentCommentExists = Comment::query() - ->where('entity_id', '=', $entity->id) - ->where('entity_type', '=', $entity->getMorphClass()) + ->where('commentable_id', '=', $entity->id) + ->where('commentable_type', '=', $entity->getMorphClass()) ->where('local_id', '=', $parentId) ->exists(); if (!$parentCommentExists) { diff --git a/app/Activity/Controllers/CommentApiController.php b/app/Activity/Controllers/CommentApiController.php index 7ba9b5b64..92551bf36 100644 --- a/app/Activity/Controllers/CommentApiController.php +++ b/app/Activity/Controllers/CommentApiController.php @@ -23,9 +23,6 @@ class CommentApiController extends ApiController { // TODO - Add tree-style comment listing to page-show responses. - // TODO - Test visibility controls - // TODO - Test permissions of each action - protected array $rules = [ 'create' => [ 'page_id' => ['required', 'integer'], @@ -34,7 +31,7 @@ class CommentApiController extends ApiController 'content_ref' => ['string'], ], 'update' => [ - 'html' => ['required', 'string'], + 'html' => ['string'], 'archived' => ['boolean'], ] ]; @@ -85,6 +82,7 @@ class CommentApiController extends ApiController public function read(string $id): JsonResponse { $comment = $this->commentRepo->getVisibleById(intval($id)); + $comment->load('createdBy', 'updatedBy'); $replies = $this->commentRepo->getQueryForVisible() ->where('parent_id', '=', $comment->local_id) @@ -117,17 +115,19 @@ class CommentApiController extends ApiController $this->checkOwnablePermission(Permission::CommentUpdate, $comment); $input = $this->validate($request, $this->rules()['update']); + $hasHtml = isset($input['html']); if (isset($input['archived'])) { - $archived = $input['archived']; - if ($archived) { - $this->commentRepo->archive($comment, false); + if ($input['archived']) { + $this->commentRepo->archive($comment, !$hasHtml); } else { - $this->commentRepo->unarchive($comment, false); + $this->commentRepo->unarchive($comment, !$hasHtml); } } - $comment = $this->commentRepo->update($comment, $input['html']); + if ($hasHtml) { + $comment = $this->commentRepo->update($comment, $input['html']); + } return response()->json($comment); } diff --git a/app/Activity/Models/Comment.php b/app/Activity/Models/Comment.php index 91c91e7a8..4d6c7fa41 100644 --- a/app/Activity/Models/Comment.php +++ b/app/Activity/Models/Comment.php @@ -41,7 +41,7 @@ class Comment extends Model implements Loggable, OwnableInterface */ public function entity(): MorphTo { - return $this->morphTo('entity'); + return $this->morphTo('commentable'); } /** diff --git a/tests/Activity/CommentsApiTest.php b/tests/Activity/CommentsApiTest.php index 29769a260..ec4ddba99 100644 --- a/tests/Activity/CommentsApiTest.php +++ b/tests/Activity/CommentsApiTest.php @@ -2,8 +2,8 @@ namespace Activity; -use BookStack\Activity\ActivityType; -use BookStack\Facades\Activity; +use BookStack\Activity\Models\Comment; +use BookStack\Permissions\Permission; use Tests\Api\TestsApi; use Tests\TestCase; @@ -13,31 +13,238 @@ class CommentsApiTest extends TestCase public function test_endpoint_permission_controls() { - // TODO + $user = $this->users->editor(); + $this->permissions->grantUserRolePermissions($user, [Permission::CommentDeleteAll, Permission::CommentUpdateAll]); + + $page = $this->entities->page(); + $comment = Comment::factory()->make(); + $page->comments()->save($comment); + $this->actingAsForApi($user); + + $actions = [ + ['GET', '/api/comments'], + ['GET', "/api/comments/{$comment->id}"], + ['POST', "/api/comments"], + ['PUT', "/api/comments/{$comment->id}"], + ['DELETE', "/api/comments/{$comment->id}"], + ]; + + foreach ($actions as [$method, $endpoint]) { + $resp = $this->call($method, $endpoint); + $this->assertNotPermissionError($resp); + } + + $comment = Comment::factory()->make(); + $page->comments()->save($comment); + $this->getJson("/api/comments")->assertSee(['id' => $comment->id]); + + $this->permissions->removeUserRolePermissions($user, [ + Permission::CommentDeleteAll, Permission::CommentDeleteOwn, + Permission::CommentUpdateAll, Permission::CommentUpdateOwn, + Permission::CommentCreateAll + ]); + + $this->assertPermissionError($this->json('delete', "/api/comments/{$comment->id}")); + $this->assertPermissionError($this->json('put', "/api/comments/{$comment->id}")); + $this->assertPermissionError($this->json('post', "/api/comments")); + $this->assertNotPermissionError($this->json('get', "/api/comments/{$comment->id}")); + + $this->permissions->disableEntityInheritedPermissions($page); + $this->json('get', "/api/comments/{$comment->id}")->assertStatus(404); + $this->getJson("/api/comments")->assertDontSee(['id' => $comment->id]); } public function test_index() { - // TODO + $page = $this->entities->page(); + Comment::query()->delete(); + + $comments = Comment::factory()->count(10)->make(); + $page->comments()->saveMany($comments); + + $firstComment = $comments->first(); + $resp = $this->actingAsApiEditor()->getJson('/api/comments'); + $resp->assertJson([ + 'data' => [ + [ + 'id' => $firstComment->id, + 'commentable_id' => $page->id, + 'commentable_type' => 'page', + 'parent_id' => null, + 'local_id' => $firstComment->local_id, + ], + ], + ]); + $resp->assertJsonCount(10, 'data'); + $resp->assertJson(['total' => 10]); + + $filtered = $this->getJson("/api/comments?filter[id]={$firstComment->id}"); + $filtered->assertJsonCount(1, 'data'); + $filtered->assertJson(['total' => 1]); } public function test_create() { - // TODO + $page = $this->entities->page(); + + $resp = $this->actingAsApiEditor()->postJson('/api/comments', [ + 'page_id' => $page->id, + 'html' => '

My wonderful comment

', + 'content_ref' => 'test-content-ref', + ]); + $resp->assertOk(); + $id = $resp->json('id'); + + $this->assertDatabaseHas('comments', [ + 'id' => $id, + 'commentable_id' => $page->id, + 'commentable_type' => 'page', + 'html' => '

My wonderful comment

', + ]); + + $comment = Comment::query()->findOrFail($id); + $this->assertIsInt($comment->local_id); + + $reply = $this->actingAsApiEditor()->postJson('/api/comments', [ + 'page_id' => $page->id, + 'html' => '

My wonderful reply

', + 'content_ref' => 'test-content-ref', + 'reply_to' => $comment->local_id, + ]); + $reply->assertOk(); + + $this->assertDatabaseHas('comments', [ + 'id' => $reply->json('id'), + 'commentable_id' => $page->id, + 'commentable_type' => 'page', + 'html' => '

My wonderful reply

', + 'parent_id' => $comment->local_id, + ]); } public function test_read() { - // TODO + $page = $this->entities->page(); + $user = $this->users->viewer(); + $comment = Comment::factory()->make([ + 'html' => '

A lovely comment

', + 'created_by' => $user->id, + 'updated_by' => $user->id, + ]); + $page->comments()->save($comment); + $comment->refresh(); + $reply = Comment::factory()->make([ + 'parent_id' => $comment->local_id, + 'html' => '

A lovelyreply

', + ]); + $page->comments()->save($reply); + + $resp = $this->actingAsApiEditor()->getJson("/api/comments/{$comment->id}"); + $resp->assertJson([ + 'id' => $comment->id, + 'commentable_id' => $page->id, + 'commentable_type' => 'page', + 'html' => '

A lovely comment

', + 'archived' => false, + 'created_by' => [ + 'id' => $user->id, + 'name' => $user->name, + ], + 'updated_by' => [ + 'id' => $user->id, + 'name' => $user->name, + ], + 'replies' => [ + [ + 'id' => $reply->id, + 'html' => '

A lovelyreply

' + ] + ] + ]); } public function test_update() { - // TODO + $page = $this->entities->page(); + $user = $this->users->editor(); + $this->permissions->grantUserRolePermissions($user, [Permission::CommentUpdateAll]); + $comment = Comment::factory()->make([ + 'html' => '

A lovely comment

', + 'created_by' => $this->users->viewer()->id, + 'updated_by' => $this->users->viewer()->id, + 'parent_id' => null, + ]); + $page->comments()->save($comment); + + $this->actingAsForApi($user)->putJson("/api/comments/{$comment->id}", [ + 'html' => '

A lovely updated comment

', + ])->assertOk(); + + $this->assertDatabaseHas('comments', [ + 'id' => $comment->id, + 'html' => '

A lovely updated comment

', + 'archived' => 0, + ]); + + $this->putJson("/api/comments/{$comment->id}", [ + 'archived' => true, + ]); + + $this->assertDatabaseHas('comments', [ + 'id' => $comment->id, + 'html' => '

A lovely updated comment

', + 'archived' => 1, + ]); + + $this->putJson("/api/comments/{$comment->id}", [ + 'archived' => false, + 'html' => '

A lovely updated again comment

', + ]); + + $this->assertDatabaseHas('comments', [ + 'id' => $comment->id, + 'html' => '

A lovely updated again comment

', + 'archived' => 0, + ]); + } + + public function test_update_cannot_archive_replies() + { + $page = $this->entities->page(); + $user = $this->users->editor(); + $this->permissions->grantUserRolePermissions($user, [Permission::CommentUpdateAll]); + $comment = Comment::factory()->make([ + 'html' => '

A lovely comment

', + 'created_by' => $this->users->viewer()->id, + 'updated_by' => $this->users->viewer()->id, + 'parent_id' => 90, + ]); + $page->comments()->save($comment); + + $resp = $this->actingAsForApi($user)->putJson("/api/comments/{$comment->id}", [ + 'archived' => true, + ]); + + $this->assertEquals($this->errorResponse('Only top-level comments can be archived.', 400), $resp->json()); + $this->assertDatabaseHas('comments', [ + 'id' => $comment->id, + 'archived' => 0, + ]); } public function test_destroy() { - // TODO + $page = $this->entities->page(); + $user = $this->users->editor(); + $this->permissions->grantUserRolePermissions($user, [Permission::CommentDeleteAll]); + $comment = Comment::factory()->make([ + 'html' => '

A lovely comment

', + ]); + $page->comments()->save($comment); + + $this->actingAsForApi($user)->deleteJson("/api/comments/{$comment->id}")->assertStatus(204); + $this->assertDatabaseMissing('comments', [ + 'id' => $comment->id, + ]); } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 239531748..f69f20d4c 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -199,7 +199,7 @@ abstract class TestCase extends BaseTestCase { if ($response->status() === 403 && $response instanceof JsonResponse) { $errMessage = $response->getData(true)['error']['message'] ?? ''; - return $errMessage === 'You do not have permission to perform the requested action.'; + return str_contains($errMessage, 'do not have permission'); } return $response->status() === 302 From 4627dfd4f7ead66cba4ba2e042f0417c4a5db853 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 24 Oct 2025 10:18:52 +0100 Subject: [PATCH 5/7] API: Added comment tree to pages-read endpoint Includes tests to cover --- .../Controllers/CommentApiController.php | 5 ++-- app/Activity/Tools/CommentTree.php | 20 ++++++++++++-- .../Controllers/PageApiController.php | 18 ++++++++++--- .../Activity/Models/CommentFactory.php | 8 +++++- tests/Api/PagesApiTest.php | 26 +++++++++++++++++++ 5 files changed, 69 insertions(+), 8 deletions(-) diff --git a/app/Activity/Controllers/CommentApiController.php b/app/Activity/Controllers/CommentApiController.php index 92551bf36..6c60de9da 100644 --- a/app/Activity/Controllers/CommentApiController.php +++ b/app/Activity/Controllers/CommentApiController.php @@ -18,11 +18,12 @@ use Illuminate\Http\Response; * scoped to the page which the comment is on. The 'parent_id' is used for replies * and refers to the 'local_id' of the parent comment on the same page, not the main * globally unique 'id'. + * + * If you want to get all comments for a page in a tree-like structure, as reflected in + * the UI, then that is provided on pages-read API responses. */ class CommentApiController extends ApiController { - // TODO - Add tree-style comment listing to page-show responses. - protected array $rules = [ 'create' => [ 'page_id' => ['required', 'integer'], diff --git a/app/Activity/Tools/CommentTree.php b/app/Activity/Tools/CommentTree.php index 66df29430..68f4a94d3 100644 --- a/app/Activity/Tools/CommentTree.php +++ b/app/Activity/Tools/CommentTree.php @@ -13,6 +13,11 @@ class CommentTree * @var CommentTreeNode[] */ protected array $tree; + + /** + * A linear array of loaded comments. + * @var Comment[] + */ protected array $comments; public function __construct( @@ -39,7 +44,7 @@ class CommentTree public function getActive(): array { - return array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived); + return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived)); } public function activeThreadCount(): int @@ -49,7 +54,7 @@ class CommentTree public function getArchived(): array { - return array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived); + return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived)); } public function archivedThreadCount(): int @@ -79,6 +84,14 @@ class CommentTree return false; } + public function loadVisibleHtml(): void + { + foreach ($this->comments as $comment) { + $comment->setAttribute('html', $comment->safeHtml()); + $comment->makeVisible('html'); + } + } + /** * @param Comment[] $comments * @return CommentTreeNode[] @@ -123,6 +136,9 @@ class CommentTree return new CommentTreeNode($byId[$id], $depth, $children); } + /** + * @return Comment[] + */ protected function loadComments(): array { if (!$this->enabled()) { diff --git a/app/Entities/Controllers/PageApiController.php b/app/Entities/Controllers/PageApiController.php index 033c19a7a..197018cca 100644 --- a/app/Entities/Controllers/PageApiController.php +++ b/app/Entities/Controllers/PageApiController.php @@ -2,6 +2,7 @@ namespace BookStack\Entities\Controllers; +use BookStack\Activity\Tools\CommentTree; use BookStack\Entities\Queries\EntityQueries; use BookStack\Entities\Queries\PageQueries; use BookStack\Entities\Repos\PageRepo; @@ -88,21 +89,32 @@ class PageApiController extends ApiController /** * View the details of a single page. * Pages will always have HTML content. They may have markdown content - * if the markdown editor was used to last update the page. + * if the Markdown editor was used to last update the page. * - * The 'html' property is the fully rendered & escaped HTML content that BookStack + * The 'html' property is the fully rendered and escaped HTML content that BookStack * would show on page view, with page includes handled. * The 'raw_html' property is the direct database stored HTML content, which would be * what BookStack shows on page edit. * * See the "Content Security" section of these docs for security considerations when using * the page content returned from this endpoint. + * + * Comments for the page are provided in a tree-structure representing the hierarchy of top-level + * comments and replies, for both archived and active comments. */ public function read(string $id) { $page = $this->queries->findVisibleByIdOrFail($id); - return response()->json($page->forJsonDisplay()); + $page = $page->forJsonDisplay(); + $commentTree = (new CommentTree($page)); + $commentTree->loadVisibleHtml(); + $page->setAttribute('comments', [ + 'active' => $commentTree->getActive(), + 'archived' => $commentTree->getArchived(), + ]); + + return response()->json($page); } /** diff --git a/database/factories/Activity/Models/CommentFactory.php b/database/factories/Activity/Models/CommentFactory.php index 844bc3993..81022e0d4 100644 --- a/database/factories/Activity/Models/CommentFactory.php +++ b/database/factories/Activity/Models/CommentFactory.php @@ -13,6 +13,11 @@ class CommentFactory extends Factory */ protected $model = \BookStack\Activity\Models\Comment::class; + /** + * A static counter to provide a unique local_id for each comment. + */ + protected static int $nextLocalId = 1000; + /** * Define the model's default state. * @@ -22,11 +27,12 @@ class CommentFactory extends Factory { $text = $this->faker->paragraph(1); $html = '

' . $text . '

'; + $nextLocalId = static::$nextLocalId++; return [ 'html' => $html, 'parent_id' => null, - 'local_id' => 1, + 'local_id' => $nextLocalId, 'content_ref' => '', 'archived' => false, ]; diff --git a/tests/Api/PagesApiTest.php b/tests/Api/PagesApiTest.php index 8caf85aff..d71b6c988 100644 --- a/tests/Api/PagesApiTest.php +++ b/tests/Api/PagesApiTest.php @@ -2,6 +2,7 @@ namespace Tests\Api; +use BookStack\Activity\Models\Comment; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Page; use Carbon\Carbon; @@ -199,6 +200,31 @@ class PagesApiTest extends TestCase $this->assertSame(404, $resp->json('error')['code']); } + public function test_read_endpoint_includes_page_comments_tree_structure() + { + $this->actingAsApiEditor(); + $page = $this->entities->page(); + $relation = ['commentable_type' => 'page', 'commentable_id' => $page->id]; + $active = Comment::factory()->create([...$relation, 'html' => '

My active comment

']); + Comment::factory()->count(5)->create([...$relation, 'parent_id' => $active->local_id]); + $archived = Comment::factory()->create([...$relation, 'archived' => true]); + Comment::factory()->count(2)->create([...$relation, 'parent_id' => $archived->local_id]); + + $resp = $this->getJson("{$this->baseEndpoint}/{$page->id}"); + $resp->assertOk(); + + $resp->assertJsonCount(1, 'comments.active'); + $resp->assertJsonCount(1, 'comments.archived'); + $resp->assertJsonCount(5, 'comments.active.0.children'); + $resp->assertJsonCount(2, 'comments.archived.0.children'); + + $resp->assertJsonFragment([ + 'id' => $active->id, + 'local_id' => $active->local_id, + 'html' => '

My active comment

', + ]); + } + public function test_update_endpoint() { $this->actingAsApiEditor(); From 9c4a9225af6f7fdcaf8494e8562197d831d25237 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 24 Oct 2025 14:22:53 +0100 Subject: [PATCH 6/7] Comments API: Addressed failing tests and static testing --- app/Activity/CommentRepo.php | 1 + app/Entities/Models/Entity.php | 1 + app/Http/ApiController.php | 2 +- app/Search/SearchRunner.php | 4 ++-- .../Activity/Models/CommentFactory.php | 5 ++++ tests/Activity/WatchTest.php | 4 ++-- tests/Entity/CommentDisplayTest.php | 6 ++--- tests/Entity/CommentStoreTest.php | 23 ++++++++++--------- 8 files changed, 27 insertions(+), 19 deletions(-) diff --git a/app/Activity/CommentRepo.php b/app/Activity/CommentRepo.php index 20513bcd4..13808903d 100644 --- a/app/Activity/CommentRepo.php +++ b/app/Activity/CommentRepo.php @@ -31,6 +31,7 @@ class CommentRepo /** * Start a query for comments visible to the user. + * @return Builder */ public function getQueryForVisible(): Builder { diff --git a/app/Entities/Models/Entity.php b/app/Entities/Models/Entity.php index c6839c15a..77393cbbc 100644 --- a/app/Entities/Models/Entity.php +++ b/app/Entities/Models/Entity.php @@ -237,6 +237,7 @@ abstract class Entity extends Model implements /** * Get the comments for an entity. + * @return MorphMany */ public function comments(bool $orderByCreated = true): MorphMany { diff --git a/app/Http/ApiController.php b/app/Http/ApiController.php index ac8844b81..8c0f206d0 100644 --- a/app/Http/ApiController.php +++ b/app/Http/ApiController.php @@ -12,7 +12,7 @@ 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 + * @var array> */ protected array $rules = []; diff --git a/app/Search/SearchRunner.php b/app/Search/SearchRunner.php index c14d1c642..a1ffeee50 100644 --- a/app/Search/SearchRunner.php +++ b/app/Search/SearchRunner.php @@ -461,9 +461,9 @@ class SearchRunner { $commentsTable = DB::getTablePrefix() . 'comments'; $morphClass = str_replace('\\', '\\\\', $model->getMorphClass()); - $commentQuery = DB::raw('(SELECT c1.entity_id, c1.entity_type, c1.created_at as last_commented FROM ' . $commentsTable . ' c1 LEFT JOIN ' . $commentsTable . ' c2 ON (c1.entity_id = c2.entity_id AND c1.entity_type = c2.entity_type AND c1.created_at < c2.created_at) WHERE c1.entity_type = \'' . $morphClass . '\' AND c2.created_at IS NULL) as comments'); + $commentQuery = DB::raw('(SELECT c1.commentable_id, c1.commentable_type, c1.created_at as last_commented FROM ' . $commentsTable . ' c1 LEFT JOIN ' . $commentsTable . ' c2 ON (c1.commentable_id = c2.commentable_id AND c1.commentable_type = c2.commentable_type AND c1.created_at < c2.created_at) WHERE c1.commentable_type = \'' . $morphClass . '\' AND c2.created_at IS NULL) as comments'); - $query->join($commentQuery, $model->getTable() . '.id', '=', DB::raw('comments.entity_id')) + $query->join($commentQuery, $model->getTable() . '.id', '=', DB::raw('comments.commentable_id')) ->orderBy('last_commented', $negated ? 'asc' : 'desc'); } } diff --git a/database/factories/Activity/Models/CommentFactory.php b/database/factories/Activity/Models/CommentFactory.php index 81022e0d4..2b7fb9ac9 100644 --- a/database/factories/Activity/Models/CommentFactory.php +++ b/database/factories/Activity/Models/CommentFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories\Activity\Models; +use BookStack\Users\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; class CommentFactory extends Factory @@ -29,12 +30,16 @@ class CommentFactory extends Factory $html = '

' . $text . '

'; $nextLocalId = static::$nextLocalId++; + $user = User::query()->first(); + return [ 'html' => $html, 'parent_id' => null, 'local_id' => $nextLocalId, 'content_ref' => '', 'archived' => false, + 'created_by' => $user ?? User::factory(), + 'updated_by' => $user ?? User::factory(), ]; } } diff --git a/tests/Activity/WatchTest.php b/tests/Activity/WatchTest.php index c405b07ae..f6f3b71c1 100644 --- a/tests/Activity/WatchTest.php +++ b/tests/Activity/WatchTest.php @@ -340,8 +340,8 @@ class WatchTest extends TestCase ActivityType::PAGE_CREATE => $entities['page'], ActivityType::PAGE_UPDATE => $entities['page'], ActivityType::COMMENT_CREATE => Comment::factory()->make([ - 'entity_id' => $entities['page']->id, - 'entity_type' => $entities['page']->getMorphClass(), + 'commentable_id' => $entities['page']->id, + 'commentable_type' => $entities['page']->getMorphClass(), ]), ]; diff --git a/tests/Entity/CommentDisplayTest.php b/tests/Entity/CommentDisplayTest.php index bffe29fa9..80664890a 100644 --- a/tests/Entity/CommentDisplayTest.php +++ b/tests/Entity/CommentDisplayTest.php @@ -72,8 +72,8 @@ class CommentDisplayTest extends TestCase Comment::factory()->create([ 'created_by' => $editor->id, - 'entity_type' => 'page', - 'entity_id' => $page->id, + 'commentable_type' => 'page', + 'commentable_id' => $page->id, ]); $resp = $this->actingAs($editor)->get($page->getUrl()); @@ -84,7 +84,7 @@ class CommentDisplayTest extends TestCase public function test_comment_displays_relative_times() { $page = $this->entities->page(); - $comment = Comment::factory()->create(['entity_id' => $page->id, 'entity_type' => $page->getMorphClass()]); + $comment = Comment::factory()->create(['commentable_id' => $page->id, 'commentable_type' => $page->getMorphClass()]); $comment->created_at = now()->subWeek(); $comment->updated_at = now()->subDay(); $comment->save(); diff --git a/tests/Entity/CommentStoreTest.php b/tests/Entity/CommentStoreTest.php index de093a3a6..c4c959c29 100644 --- a/tests/Entity/CommentStoreTest.php +++ b/tests/Entity/CommentStoreTest.php @@ -14,6 +14,7 @@ class CommentStoreTest extends TestCase $this->asAdmin(); $page = $this->entities->page(); + Comment::factory()->create(['commentable_id' => $page->id, 'commentable_type' => 'page', 'local_id' => 2]); $comment = Comment::factory()->make(['parent_id' => 2]); $resp = $this->postJson("/comment/$page->id", $comment->getAttributes()); @@ -24,9 +25,9 @@ class CommentStoreTest extends TestCase $pageResp->assertSee($comment->html, false); $this->assertDatabaseHas('comments', [ - 'local_id' => 1, - 'entity_id' => $page->id, - 'entity_type' => Page::newModelInstance()->getMorphClass(), + 'local_id' => 3, + 'commentable_id' => $page->id, + 'commentable_type' => 'page', 'parent_id' => 2, ]); @@ -52,9 +53,9 @@ class CommentStoreTest extends TestCase ]); if ($valid) { - $this->assertDatabaseHas('comments', ['entity_id' => $page->id, 'content_ref' => $ref]); + $this->assertDatabaseHas('comments', ['commentable_id' => $page->id, 'content_ref' => $ref]); } else { - $this->assertDatabaseMissing('comments', ['entity_id' => $page->id, 'content_ref' => $ref]); + $this->assertDatabaseMissing('comments', ['commentable_id' => $page->id, 'content_ref' => $ref]); } } } @@ -79,7 +80,7 @@ class CommentStoreTest extends TestCase $this->assertDatabaseHas('comments', [ 'html' => $newHtml, - 'entity_id' => $page->id, + 'commentable_id' => $page->id, ]); $this->assertActivityExists(ActivityType::COMMENT_UPDATE); @@ -218,7 +219,7 @@ class CommentStoreTest extends TestCase $page = $this->entities->page(); Comment::factory()->create([ 'html' => '

scriptincommentest

', - 'entity_type' => 'page', 'entity_id' => $page + 'commentable_type' => 'page', 'commentable_id' => $page ]); $resp = $this->asAdmin()->get($page->getUrl()); @@ -236,8 +237,8 @@ class CommentStoreTest extends TestCase $resp = $this->asAdmin()->post("/comment/{$page->id}", ['html' => $input]); $resp->assertOk(); $this->assertDatabaseHas('comments', [ - 'entity_type' => 'page', - 'entity_id' => $page->id, + 'commentable_type' => 'page', + 'commentable_id' => $page->id, 'html' => $expected, ]); @@ -259,8 +260,8 @@ class CommentStoreTest extends TestCase $resp = $this->asAdmin()->post("/comment/{$page->id}", ['html' => $input]); $resp->assertOk(); $this->assertDatabaseHas('comments', [ - 'entity_type' => 'page', - 'entity_id' => $page->id, + 'commentable_type' => 'page', + 'commentable_id' => $page->id, 'html' => $expected, ]); From a949900570c80c9c46c140a57c17e2d42738232e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 24 Oct 2025 15:14:25 +0100 Subject: [PATCH 7/7] API: Added examples for comments Tweaked comment repo to avoid returning a lot of extra data on API update responses. --- app/Activity/CommentRepo.php | 1 + dev/api/requests/comments-create.json | 5 ++ dev/api/requests/comments-update.json | 4 ++ dev/api/responses/comments-create.json | 13 +++++ dev/api/responses/comments-list.json | 29 ++++++++++ dev/api/responses/comments-read.json | 38 ++++++++++++++ dev/api/responses/comments-update.json | 13 +++++ dev/api/responses/pages-read.json | 73 ++++++++++++++++++++++++++ 8 files changed, 176 insertions(+) create mode 100644 dev/api/requests/comments-create.json create mode 100644 dev/api/requests/comments-update.json create mode 100644 dev/api/responses/comments-create.json create mode 100644 dev/api/responses/comments-list.json create mode 100644 dev/api/responses/comments-read.json create mode 100644 dev/api/responses/comments-update.json diff --git a/app/Activity/CommentRepo.php b/app/Activity/CommentRepo.php index 13808903d..1802e3905 100644 --- a/app/Activity/CommentRepo.php +++ b/app/Activity/CommentRepo.php @@ -74,6 +74,7 @@ class CommentRepo ActivityService::add(ActivityType::COMMENT_CREATE, $comment); ActivityService::add(ActivityType::COMMENTED_ON, $entity); + $comment->refresh()->unsetRelations(); return $comment; } diff --git a/dev/api/requests/comments-create.json b/dev/api/requests/comments-create.json new file mode 100644 index 000000000..abc6832d8 --- /dev/null +++ b/dev/api/requests/comments-create.json @@ -0,0 +1,5 @@ +{ + "page_id": 2646, + "html": "

Can the title be updated?

", + "content_ref": "bkmrk-page-title:7341676876991010:3-14" +} \ No newline at end of file diff --git a/dev/api/requests/comments-update.json b/dev/api/requests/comments-update.json new file mode 100644 index 000000000..bbcf114f3 --- /dev/null +++ b/dev/api/requests/comments-update.json @@ -0,0 +1,4 @@ +{ + "html": "

Can this comment be updated??????

", + "archived": true +} \ No newline at end of file diff --git a/dev/api/responses/comments-create.json b/dev/api/responses/comments-create.json new file mode 100644 index 000000000..6fec5c101 --- /dev/null +++ b/dev/api/responses/comments-create.json @@ -0,0 +1,13 @@ +{ + "id": 167, + "commentable_id": 2646, + "commentable_type": "page", + "parent_id": null, + "local_id": 29, + "created_by": 1, + "updated_by": 1, + "created_at": "2025-10-24T14:05:41.000000Z", + "updated_at": "2025-10-24T14:05:41.000000Z", + "content_ref": "bkmrk-page-title:7341676876991010:3-14", + "archived": false +} \ No newline at end of file diff --git a/dev/api/responses/comments-list.json b/dev/api/responses/comments-list.json new file mode 100644 index 000000000..a0f638120 --- /dev/null +++ b/dev/api/responses/comments-list.json @@ -0,0 +1,29 @@ +{ + "data": [ + { + "id": 1, + "commentable_id": 2607, + "commentable_type": "page", + "parent_id": null, + "local_id": 1, + "content_ref": "", + "created_by": 1, + "updated_by": 1, + "created_at": "2022-04-20T08:43:27.000000Z", + "updated_at": "2022-04-20T08:43:27.000000Z" + }, + { + "id": 18, + "commentable_id": 2607, + "commentable_type": "page", + "parent_id": 1, + "local_id": 2, + "content_ref": "", + "created_by": 3, + "updated_by": 3, + "created_at": "2022-11-15T08:12:35.000000Z", + "updated_at": "2022-11-15T08:12:35.000000Z" + } + ], + "total": 88 +} \ No newline at end of file diff --git a/dev/api/responses/comments-read.json b/dev/api/responses/comments-read.json new file mode 100644 index 000000000..054b8ad19 --- /dev/null +++ b/dev/api/responses/comments-read.json @@ -0,0 +1,38 @@ +{ + "id": 22, + "commentable_id": 2646, + "commentable_type": "page", + "html": "

This page looks great!<\/p>\n", + "parent_id": null, + "local_id": 2, + "created_by": { + "id": 1, + "name": "Admin", + "slug": "admin" + }, + "updated_by": { + "id": 1, + "name": "Admin", + "slug": "admin" + }, + "created_at": "2023-06-07T07:50:56.000000Z", + "updated_at": "2023-06-07T07:50:56.000000Z", + "content_ref": "", + "archived": false, + "replies": [ + { + "id": 34, + "commentable_id": 2646, + "commentable_type": "page", + "html": "

Thanks for the comment!<\/p>\n", + "parent_id": 2, + "local_id": 10, + "created_by": 2, + "updated_by": 2, + "created_at": "2023-06-07T13:46:25.000000Z", + "updated_at": "2023-06-07T13:46:25.000000Z", + "content_ref": "", + "archived": false + } + ] +} \ No newline at end of file diff --git a/dev/api/responses/comments-update.json b/dev/api/responses/comments-update.json new file mode 100644 index 000000000..ce5ed2644 --- /dev/null +++ b/dev/api/responses/comments-update.json @@ -0,0 +1,13 @@ +{ + "id": 167, + "commentable_id": 2646, + "commentable_type": "page", + "parent_id": null, + "local_id": 29, + "created_by": 1, + "updated_by": 1, + "created_at": "2025-10-24T14:05:41.000000Z", + "updated_at": "2025-10-24T14:09:56.000000Z", + "content_ref": "bkmrk-page-title:7341676876991010:3-14", + "archived": true +} \ No newline at end of file diff --git a/dev/api/responses/pages-read.json b/dev/api/responses/pages-read.json index 22ff2de84..e38a9cf92 100644 --- a/dev/api/responses/pages-read.json +++ b/dev/api/responses/pages-read.json @@ -29,6 +29,79 @@ "revision_count": 5, "template": false, "editor": "wysiwyg", + "comments": { + "active": [ + { + "comment": { + "id": 22, + "commentable_id": 306, + "commentable_type": "page", + "html": "

Does this need revising?<\/p>\n", + "parent_id": null, + "local_id": 1, + "created_by": { + "id": 1, + "name": "Admin", + "slug": "admin" + }, + "updated_by": 1, + "created_at": "2023-06-07T07:50:56.000000Z", + "updated_at": "2023-06-07T07:50:56.000000Z", + "content_ref": "", + "archived": false + }, + "depth": 0, + "children": [ + { + "comment": { + "id": 34, + "commentable_id": 2646, + "commentable_type": "page", + "html": "

I think it's okay!<\/p>\n", + "parent_id": 1, + "local_id": 2, + "created_by": { + "id": 2, + "name": "Editor", + "slug": "editor" + }, + "updated_by": 1, + "created_at": "2023-06-07T13:46:25.000000Z", + "updated_at": "2023-06-07T13:46:25.000000Z", + "content_ref": "", + "archived": false + }, + "depth": 1, + "children": [] + } + ] + } + ], + "archived": [ + { + "comment": { + "id": 21, + "commentable_id": 2646, + "commentable_type": "page", + "html": "

The title needs to be fixed<\/p>\n", + "parent_id": null, + "local_id": 3, + "created_by": { + "id": 2, + "name": "Editor", + "slug": "editor" + }, + "updated_by": 1, + "created_at": "2023-06-07T07:50:49.000000Z", + "updated_at": "2025-10-24T08:37:22.000000Z", + "content_ref": "", + "archived": true + }, + "depth": 0, + "children": [] + } + ] + }, "tags": [ { "name": "Category",