From d08e2a2acd9313ba3270c81b17dd5b8f048f9c03 Mon Sep 17 00:00:00 2001 From: taslangraham Date: Wed, 21 May 2025 20:38:15 -0500 Subject: [PATCH] pkp/pkp-lib#11326 Implement backend infrastructure for managing and moderating public comments --- api/v1/comments/UserCommentController.php | 491 ++++++++++++++++++ .../resources/UserCommentReportResource.php | 41 ++ .../resources/UserCommentResource.php | 50 ++ classes/core/PKPBaseController.php | 26 +- classes/core/PKPString.php | 34 +- classes/facades/Repo.php | 8 +- .../upgrade/v3_6_0/I11326_UserComments.php | 88 ++++ classes/userComment/Repository.php | 94 ++++ classes/userComment/UserComment.php | 139 +++++ .../relationships/UserCommentReport.php | 84 +++ locale/en/api.po | 27 + 11 files changed, 1071 insertions(+), 11 deletions(-) create mode 100644 api/v1/comments/UserCommentController.php create mode 100644 api/v1/comments/resources/UserCommentReportResource.php create mode 100644 api/v1/comments/resources/UserCommentResource.php create mode 100755 classes/migration/upgrade/v3_6_0/I11326_UserComments.php create mode 100644 classes/userComment/Repository.php create mode 100644 classes/userComment/UserComment.php create mode 100644 classes/userComment/relationships/UserCommentReport.php diff --git a/api/v1/comments/UserCommentController.php b/api/v1/comments/UserCommentController.php new file mode 100644 index 00000000000..9c16501a136 --- /dev/null +++ b/api/v1/comments/UserCommentController.php @@ -0,0 +1,491 @@ +getManyPublicComments(...)) + ->name('comment.getManyPublic'); + + // Routes accessible only to authenticated users + Route::middleware([ + 'has.user', + ])->group(function () { + Route::post('', $this->submit(...)) + ->name('comment.submit'); + + Route::delete('{commentId}', $this->delete(...)) + ->name('comment.delete') + ->whereNumber('commentId'); + + Route::post('{commentId}/reports', $this->submitReport(...)) + ->name('comment.submitReport') + ->whereNumber('commentId'); + + // Moderator routes + Route::middleware([ + self::roleAuthorizer([ + Role::ROLE_ID_SITE_ADMIN, + Role::ROLE_ID_MANAGER + ]), + ])->group(function () { + Route::get('', $this->getMany(...)) + ->name('comment.getMany'); + + Route::get('{commentId}', $this->get(...)) + ->name('comment.getComment') + ->whereNumber('commentId'); + + Route::put('{commentId}/setApproval', $this->setApproval(...)) + ->name('comment.setApproval') + ->whereNumber('commentId'); + + Route::delete('{commentId}/reports/{reportId}', $this->deleteReport(...)) + ->name('comment.deleteReport') + ->whereNumber('commentId') + ->whereNumber('reportId'); + + Route::get('{commentId}/reports/{reportId}', $this->getReport(...)) + ->name('comment.getReport') + ->whereNumber('commentId') + ->whereNumber('reportId'); + + Route::get('{commentId}/reports', $this->getReports(...)) + ->name('comment.getReports') + ->whereNumber('commentId'); + + Route::delete('{commentId}/reports', $this->deleteReports(...)) + ->name('comment.deleteReports') + ->whereNumber('commentId'); + }); + }); + } + + /** + * Gets the publicly accessible comments for a publication. + * Accepts the following query parameters: + * publicationIds(required, array) publication IDs to fetch comments for. + * page(integer) - The pagination page to retrieve records from. + */ + public function getManyPublicComments(Request $illuminateRequest): JsonResponse + { + $publicationIdsRaw = paramToArray($illuminateRequest->query('publicationIds') ?? []); + + if (empty($publicationIdsRaw)) { + return response()->json(['error' => __('api.userComments.400.missingPublicationParam')], Response::HTTP_BAD_REQUEST); + } + + $publicationIds = []; + foreach ($publicationIdsRaw as $id) { + if (!filter_var($id, FILTER_VALIDATE_INT)) { + return response()->json([ + 'error' => __('api.userComments.400.invalidPublicationId', ['publicationId' => $id]) + ], Response::HTTP_BAD_REQUEST); + } + + $publicationIds[] = (int)$id; + } + + $query = UserComment::withPublicationIds($publicationIds)->withIsApproved(true); + + $paginatedInfo = Repo::userComment() + ->setPage($illuminateRequest->query('page') ?? 1) + ->getPaginatedData($query); + + return response()->json($this->formatPaginatedResponseData($paginatedInfo), Response::HTTP_OK); + } + + /** + * Gets a list of comments. Accessible only to moderators(admins/managers). + * Filters available via query params: + * ``` + * publicationIds(required, array) - publication IDs to retrieve comments for. + * userIds(array) - Include this to filter by user IDs + * isReported(boolean) - Include this to filter comment based on if they were reported or not. + * isApproved(boolean) - Include this to filter comments by approval status. + * page(integer) - The pagination page to retrieve records from. + * ``` + * Use the 'includeReports' query parameter to include associated reports. + */ + public function getMany(Request $illuminateRequest): JsonResponse + { + $queryParams = $illuminateRequest->query(); + $publicationIdsRaw = paramToArray($queryParams['publicationIds'] ?? []); + + if (empty($publicationIdsRaw)) { + return response()->json(['error' => __('api.userComments.400.missingPublicationParam')], Response::HTTP_BAD_REQUEST); + } + + $publicationIds = []; + foreach ($publicationIdsRaw as $id) { + if (!filter_var($id, FILTER_VALIDATE_INT)) { + return response()->json([ + 'error' => __('api.userComments.400.invalidPublicationId', ['publicationId' => $id]) + ], Response::HTTP_BAD_REQUEST); + } + + $publicationIds[] = (int)$id; + } + + $query = UserComment::withPublicationIds($publicationIds); + foreach ($queryParams as $param => $value) { + switch ($param) { + case 'userIds': + $userIdsRaw = paramToArray($value ?? []); + $userIds = []; + + foreach ($userIdsRaw as $userId) { + if (!filter_var($userId, FILTER_VALIDATE_INT)) { + return response()->json([ + 'error' => __('api.userComments.400.invalidUserId', ['userId' => $userId]) + ], Response::HTTP_BAD_REQUEST); + } + + $userIds[] = (int)$userId; + } + + $query->withUserIds($userIds); + break; + case 'isApproved': + $isApproved = PKPString::strictConvertToBoolean($value); + if ($isApproved === null) { + return response()->json([ + 'error' => __('api.userComments.400.invalidIsApproved', ['isApproved' => $value]) + ], Response::HTTP_BAD_REQUEST); + } + $query->withIsApproved($isApproved); + break; + case 'isReported': + $isReported = PKPString::strictConvertToBoolean($value); + if ($isReported === null) { + return response()->json([ + 'error' => __('api.userComments.400.invalidIsReported', ['$isReported' => $value]) + ], Response::HTTP_BAD_REQUEST); + } + $query->withIsReported($isReported); + break; + } + } + + $paginatedInfo = Repo::userComment() + ->setPage($queryParams['page'] ?? 1) + ->getPaginatedData($query); + + return response()->json($this->formatPaginatedResponseData($paginatedInfo), Response::HTTP_OK); + } + + /** + * Get a single comment by ID + * Use the 'includeReports' query parameter to include associated reports. + */ + public function get(Request $illuminateRequest): JsonResponse + { + $commentId = (int)$illuminateRequest->route('commentId'); + $comment = UserComment::query()->find($commentId); + + if (!$comment) { + return response()->json(['error' => __('api.404.resourceNotFound')], Response::HTTP_NOT_FOUND); + } + + return response()->json(new UserCommentResource($comment), Response::HTTP_OK); + } + + /** Add a new comment */ + public function submit(Request $illuminateRequest): JsonResponse + { + $request = $this->getRequest(); + $context = $request->getContext(); + $currentUser = $request->getUser(); + $requestBody = $illuminateRequest->input(); + + $publicationId = (int)$requestBody['publicationId']; + $commentText = $requestBody['commentText']; + + if (!$publicationId) { + return response()->json(['error' => __('api.userComments.form.400.required.publicationId')], Response::HTTP_BAD_REQUEST); + } + + if (empty($commentText)) { + return response()->json(['error' => __('api.userComments.form.400.required.commentText')], Response::HTTP_BAD_REQUEST); + } + + $commentText = PKPString::stripUnsafeHtml($commentText); + $publication = Repo::publication()->get($publicationId); + + if (!$publication) { + return response()->json(['error' => __('api.404.resourceNotFound')], Response::HTTP_NOT_FOUND); + } + + $submissionId = $publication->getData('submissionId'); + $submission = Repo::submission()->get((int)$submissionId); + + if (!$submission) { + return response()->json(['error' => __('api.404.resourceNotFound')], Response::HTTP_NOT_FOUND); + } + + if ($submission->getCurrentPublication()->getId() !== $publicationId) { + return response()->json([ + 'error' => __('api.userComments.400.cannotCommentOnPublicationVersion'), + ], Response::HTTP_BAD_REQUEST); + } + + $createdComment = UserComment::query()->create( + [ + 'userId' => $currentUser->getId(), + 'contextId' => $context->getId(), + 'publicationId' => $publicationId, + 'commentText' => $commentText, + 'isApproved' => false, + ] + ); + + return response()->json(new UserCommentResource($createdComment), Response::HTTP_OK); + } + + /** Delete a comment by ID*/ + public function delete(Request $illuminateRequest): JsonResponse + { + $commentId = (int)$illuminateRequest->route('commentId'); + $request = $this->getRequest(); + $user = $request->getUser(); + $comment = UserComment::query()->find($commentId); + + if (!$comment) { + return response()->json([ + 'error' => __('api.404.resourceNotFound'), + ], Response::HTTP_NOT_FOUND); + } + + $isCommentOwner = $comment->userId === $user->getId(); + + if (!$this->isModerator($user) && !$isCommentOwner) { + return response()->json([ + 'error' => __('api.403.unauthorized'), + ], Response::HTTP_FORBIDDEN); + } + + $comment->delete(); + + return response()->json([], Response::HTTP_OK); + } + + /** + * Set the approval status of a comment. + */ + public function setApproval(Request $illuminateRequest): JsonResponse + { + $isApproved = PKPString::strictConvertToBoolean($illuminateRequest->input('approved') ?? ''); // Process the boolean value in body, returning null for invalid values. + + if ($isApproved === null) { + return response()->json([ + 'error' => __('api.userComments.400.invalidIsApproved', ['isApproved' => $illuminateRequest->input('approved')]) + ], Response::HTTP_BAD_REQUEST); + } + + $commentId = (int)$illuminateRequest->route('commentId'); + $comment = UserComment::query()->find($commentId); + + if (!$comment) { + return response()->json([ + 'error' => __('api.404.resourceNotFound'), + ], Response::HTTP_NOT_FOUND); + } + + $comment->isApproved = $isApproved; + $comment->save(); + + return response()->json(new UserCommentResource($comment), Response::HTTP_OK); + } + + /** Report a comment */ + public function submitReport(Request $illuminateRequest): JsonResponse + { + $requestBody = $illuminateRequest->input(); + $note = $requestBody['note']; + + if (!$note) { + return response()->json( + [ + 'error' => __('api.userComments.form.400.required.note') + ], + Response::HTTP_BAD_REQUEST + ); + } + + $commentId = (int)$illuminateRequest->route('commentId'); + $comment = UserComment::query()->find($commentId); + if (!$comment) { + return response()->json( + [ + 'error' => __('api.404.resourceNotFound') + ], + Response::HTTP_NOT_FOUND + ); + } + + $request = $this->getRequest(); + $user = $request->getUser(); + + // Non-moderator users can only report comments that are approved, thus visible to them. + if (!$this->isModerator($user) && !$comment->isApproved) { + return response()->json( + [ + 'error' => __('api.403.unauthorized') + ], + Response::HTTP_FORBIDDEN + ); + } + + $note = PKPString::stripUnsafeHtml($note); + $reportId = Repo::userComment()->addReport($comment, $this->getRequest()->getUser(), $note); + $report = UserCommentReport::query()->find($reportId); + return response()->json(new UserCommentReportResource($report), Response::HTTP_OK); + } + + /** Delete a report by ID */ + public function deleteReport(Request $illuminateRequest): JsonResponse + { + $reportId = (int)$illuminateRequest->route('reportId'); + $commentId = (int)$illuminateRequest->route('commentId'); + + $report = UserCommentReport::withCommentIds([$commentId]) + ->withReportIds([$reportId]) + ->first(); + + if (!$report) { + return response()->json([ + 'error' => __('api.404.resourceNotFound'), + ], Response::HTTP_NOT_FOUND); + } + + $report->delete(); + + return response()->json([], Response::HTTP_OK); + } + + /** + * Delete all reports made against a comment. + */ + public function deleteReports(Request $illuminateRequest): JsonResponse + { + $commentId = (int)$illuminateRequest->route('commentId'); + $comment = UserComment::query()->with(['reports'])->find($commentId); + + if (!$comment) { + return response()->json([ + 'error' => __('api.404.resourceNotFound'), + ], Response::HTTP_NOT_FOUND); + } + + $reportIds = $comment->reports->pluck('userCommentReportId')->all(); + + UserCommentReport::query() + ->whereIn('user_comment_report_id', $reportIds) + ->delete(); + + return response()->json([], Response::HTTP_OK); + } + + /** Get a single report by ID */ + public function getReport(Request $illuminateRequest): JsonResponse + { + $reportId = (int)$illuminateRequest->route('reportId'); + $commentId = (int)$illuminateRequest->route('commentId'); + + $report = UserCommentReport::query() + ->withCommentIds([$commentId]) + ->withReportIds([$reportId]) + ->first(); + + if (!$report) { + return response()->json([ + 'error' => __('api.404.resourceNotFound'), + ], Response::HTTP_NOT_FOUND); + } + + return response()->json(new UserCommentReportResource($report), Response::HTTP_OK); + } + + /** + * Get all reports for a comment + * Accepts the following query parameters: + * ``` + * page(optional, integer) - The pagination page to retrieve records from. + * ``` + */ + public function getReports(Request $illuminateRequest): JsonResponse + { + $commentId = (int)$illuminateRequest->route('commentId'); + $comment = UserComment::query()->find($commentId); + + if (!$comment) { + return response()->json([ + 'error' => __('api.404.resourceNotFound'), + ], Response::HTTP_NOT_FOUND); + } + + $query = UserCommentReport::query()->where('user_comment_id', $commentId); + $paginatedInfo = Repo::userComment() + ->setPage($illuminateRequest->query('page') ?? 1) + ->getPaginatedData($query); + + return response()->json($this->formatPaginatedResponseData($paginatedInfo), Response::HTTP_OK); + } + + /** + * Check if a user is a moderator (admin/manager). + */ + private function isModerator(User $user): bool + { + $context = $this->getRequest()->getContext(); + return $user->hasRole([Role::ROLE_ID_MANAGER], $context->getId()) || $user->hasRole([Role::ROLE_ID_SITE_ADMIN], PKPApplication::SITE_CONTEXT_ID); + } +} diff --git a/api/v1/comments/resources/UserCommentReportResource.php b/api/v1/comments/resources/UserCommentReportResource.php new file mode 100644 index 00000000000..5c3e7a2da86 --- /dev/null +++ b/api/v1/comments/resources/UserCommentReportResource.php @@ -0,0 +1,41 @@ +user; + + return [ + 'id' => $this->id, + 'userCommentId' => $this->userCommentId, + 'userId' => $this->userId, + 'userName' => $user->getFullName(), + 'userOrcidDisplayValue' => $user->getOrcidDisplayValue(), + 'note' => $this->note, + 'createdAt' => $this->createdAt, + 'updatedAt' => $this->updatedAt, + ]; + } +} diff --git a/api/v1/comments/resources/UserCommentResource.php b/api/v1/comments/resources/UserCommentResource.php new file mode 100644 index 00000000000..a6279544568 --- /dev/null +++ b/api/v1/comments/resources/UserCommentResource.php @@ -0,0 +1,50 @@ +user; /** @var User $user */ + $requestQueryParams = $request->query(); + + $results = [ + 'id' => $this->id, + 'contextId' => $this->contextId, + 'commentText' => $this->commentText, + 'createdAt' => $this->createdAt, + 'updatedAt' => $this->updatedAt, + 'isApproved' => $this->isApproved, + 'isReported' => $this->reports->isNotEmpty(), + 'publicationId' => $this->publicationId, + 'userId' => $this->userId, + 'userName' => $user->getFullName(), + 'userOrcidDisplayValue' => $user->getOrcidDisplayValue(), + ]; + + if (key_exists('includeReports', $requestQueryParams)) { + $results['reports'] = UserCommentReportResource::collection($this->reports); + } + + return $results; + } +} diff --git a/classes/core/PKPBaseController.php b/classes/core/PKPBaseController.php index 844d825aa61..2e8aa0d1713 100644 --- a/classes/core/PKPBaseController.php +++ b/classes/core/PKPBaseController.php @@ -21,6 +21,8 @@ use Illuminate\Foundation\Bus\DispatchesJobs; use Illuminate\Foundation\Validation\ValidatesRequests; use Illuminate\Http\Request; +use Illuminate\Http\Response; +use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Routing\Controller; use Illuminate\Routing\Route; use PKP\security\authorization\AllowedHostsPolicy; @@ -110,12 +112,12 @@ public static function getRouteController(?Request $request = null): ?static if (!$requestedRoute = static::getRequestedRoute($request)) { return null; } - + $calledRouteController = (new ReflectionFunction($requestedRoute->action['uses']))->getClosureThis(); - // When the routes are added to router as a closure/callable from other section like from a + // When the routes are added to router as a closure/callable from other section like from a // plugin through the hook, the resolved called route class may not be an instance of - // `PKPBaseController` and we need to resolve the current controller instance from + // `PKPBaseController` and we need to resolve the current controller instance from // `APIHandler::getApiController` method if ($calledRouteController instanceof self) { return $calledRouteController; @@ -582,4 +584,22 @@ protected function _validateStatDates(array $params, string $dateStartParam = 'd return true; } + + /** + * Format and returns paginated response data. + * + * @param LengthAwarePaginator $pagination - The object with paginated data and metadata. + * + */ + public function formatPaginatedResponseData(LengthAwarePaginator $pagination): array + { + return [ + 'data' => $pagination->values(), + 'total' => $pagination->total(), + 'pagination' => [ + 'lastPage' => $pagination->lastPage(), + 'currentPage' => $pagination->currentPage(), + ] + ]; + } } diff --git a/classes/core/PKPString.php b/classes/core/PKPString.php index 106008290f3..7cd061f5bb0 100644 --- a/classes/core/PKPString.php +++ b/classes/core/PKPString.php @@ -332,24 +332,44 @@ public static function getWordCount(string $str): int /** * Map the specific HTML tags in title/ sub title for JATS schema compability + * * @see https://jats.nlm.nih.gov/publishing/0.4/xsd/JATS-journalpublishing0.xsd * * @param string $htmlTitle The submission title/sub title as in HTML - * @return string */ public static function mapTitleHtmlTagsToXml(string $htmlTitle): string { $mappings = [ - '' => '', - '' => '', - '' => '', - '' => '', - '' => '', - '' => '', + '' => '', + '' => '', + '' => '', + '' => '', + '' => '', + '' => '', ]; return str_replace(array_keys($mappings), array_values($mappings), $htmlTitle); } + + /** + * Does a strict conversion of a string to a boolean value. + * + * @param string $value The value to convert. + * + * @return bool|null Returns true if the string is "true", false if it is "false", and null otherwise. + */ + public static function strictConvertToBoolean(string $value): ?bool + { + $lower = strtolower($value); + if ($lower === 'true') { + return true; + } + if ($lower === 'false') { + return false; + } + + return null; + } } if (!PKP_STRICT_MODE) { diff --git a/classes/facades/Repo.php b/classes/facades/Repo.php index 8a16be7a08e..a175246c0ee 100644 --- a/classes/facades/Repo.php +++ b/classes/facades/Repo.php @@ -44,10 +44,11 @@ use PKP\query\Repository as QueryRepository; use PKP\ror\Repository as RorRepository; use PKP\stageAssignment\Repository as StageAssignmentRepository; +use PKP\submission\reviewer\recommendation\Repository as ReviewerRecommendationRepository; use PKP\submissionFile\Repository as SubmissionFileRepository; use PKP\user\interest\Repository as UserInterestRepository; +use PKP\userComment\Repository as UserCommentRepository; use PKP\userGroup\Repository as UserGroupRepository; -use PKP\submission\reviewer\recommendation\Repository as ReviewerRecommendationRepository; class Repo { @@ -170,4 +171,9 @@ public static function reviewerRecommendation(): ReviewerRecommendationRepositor { return app(ReviewerRecommendationRepository::class); } + + public static function userComment(): UserCommentRepository + { + return app(UserCommentRepository::class); + } } diff --git a/classes/migration/upgrade/v3_6_0/I11326_UserComments.php b/classes/migration/upgrade/v3_6_0/I11326_UserComments.php new file mode 100755 index 00000000000..4db9f44b766 --- /dev/null +++ b/classes/migration/upgrade/v3_6_0/I11326_UserComments.php @@ -0,0 +1,88 @@ +bigInteger('user_comment_id')->autoIncrement()->comment('Primary key.'); + $table->bigInteger('user_id')->comment('ID of the user that made the comment.'); + $table->bigInteger('context_id')->comment('ID of the context (e.g., journal) the comment belongs to.'); + $table->bigInteger('publication_id')->nullable()->comment('ID of the publication that the comment belongs to.'); + $table->text('comment_text')->comment('The comment text.'); + $table->boolean('is_approved')->default(false)->comment('Boolean indicating if the comment is approved.'); + $table->timestamps(); + + $table->foreign('user_id') + ->references('user_id') + ->on('users') + ->onDelete('cascade') + ->onUpdate('cascade'); + }); + + Schema::create('user_comment_reports', function (Blueprint $table) { + $table->bigInteger('user_comment_report_id')->autoIncrement()->comment('Primary key.'); + $table->bigInteger('user_comment_id')->comment('ID of the user comment that the reported was created for.'); + $table->bigInteger('user_id')->comment('ID of the user that made the report.'); + $table->text('note')->comment('Reason for the report.'); + $table->timestamps(); + + $table->foreign('user_comment_id') + ->references('user_comment_id') + ->on('user_comments') + ->onDelete('cascade') + ->onUpdate('cascade'); + + $table->foreign('user_id') + ->references('user_id') + ->on('users') + ->onDelete('cascade') + ->onUpdate('cascade'); + }); + + Schema::create('user_comment_settings', function (Blueprint $table) { + $table->bigInteger('user_comment_setting_id')->autoIncrement()->comment('Primary key.'); + $table->bigInteger('user_comment_id')->comment('ID of the user comment that the setting belongs to.'); + $table->string('locale', 14)->default(''); + $table->string('setting_name', 255); + $table->longText('setting_value')->nullable(); + + $table->foreign('user_comment_id') + ->references('user_comment_id') + ->on('user_comments') + ->onDelete('cascade') + ->onUpdate('cascade'); + }); + } + + /** + * Reverse the migration. + */ + public function down(): void + { + Schema::drop('user_comment_reports'); + Schema::drop('user_comment_settings'); + Schema::drop('user_comments'); + } +} diff --git a/classes/userComment/Repository.php b/classes/userComment/Repository.php new file mode 100644 index 00000000000..50d3b12824c --- /dev/null +++ b/classes/userComment/Repository.php @@ -0,0 +1,94 @@ +request = $request; + $context = $request->getContext(); + $this->perPage = $context->getData('itemsPerPage'); + } + + /** + * @param UserComment $userComment - The user comment to add the report to + * @param User $user - The user reporting the comment + * @param string $note - The note to attach to the report + * + * @return int - The ID of the newly created report + */ + public function addReport(UserComment $userComment, User $user, string $note): int + { + $report = UserCommentReport::query()->create([ + 'userCommentId' => $userComment->id, + 'userId' => $user->getId(), + 'note' => $note, + ]); + + return $report->id; + } + + /** + * Accepts a model query builder and returns a paginated collection of the data. + * + * @param SettingsBuilder|Builder $query - The query builder used to get the paginated records. The underlying model of the builder must be either UserComment or UserCommentReport. + */ + public function getPaginatedData(SettingsBuilder|Builder $query): LengthAwarePaginator + { + $resourceClass = match (true) { + $query->getModel() instanceof UserComment => UserCommentResource::class, + $query->getModel() instanceof UserCommentReport => UserCommentReportResource::class, + default => throw new \InvalidArgumentException('Unsupported model type: ' . get_class($query->getModel())), + }; + + $currentPage = LengthAwarePaginator::resolveCurrentPage(); + $sanitizedPage = $currentPage - 1; + $offsetRows = $this->perPage * $sanitizedPage; + $total = $query->count(); + + $data = $query + ->orderBy('created_at', 'DESC') + ->skip($offsetRows) + ->take($this->perPage) + ->get(); + + return new LengthAwarePaginator( + $resourceClass::collection($data), + $total, + $this->perPage + ); + } + + public function setPage(int $page): static + { + LengthAwarePaginator::currentPageResolver(fn () => $page); + return $this; + } +} diff --git a/classes/userComment/UserComment.php b/classes/userComment/UserComment.php new file mode 100644 index 00000000000..9495a1bfb75 --- /dev/null +++ b/classes/userComment/UserComment.php @@ -0,0 +1,139 @@ + 'int', + 'userId' => 'int', + 'contextId' => 'int', + 'publicationId' => 'int', + 'isApproved' => 'boolean', + 'createdAt' => 'datetime', + 'updatedAt' => 'datetime', + ]; + } + + /** + * @inheritDoc + */ + public static function getSchemaName(): ?string + { + return ''; + } + + /** + * @inheritDoc + */ + public function getSettingsTable(): string + { + return $this->settingsTable; + } + + /** + * Get the primary key of the model as 'id' property. + */ + protected function id(): Attribute + { + return Attribute::make( + get: fn ($value, $attributes) => $attributes[$this->primaryKey] ?? null, + ); + } + + /** + * Accessor for user. Can be replaced with relationship once User is converted to an Eloquent Model. + */ + protected function user(): Attribute + { + return Attribute::make( + get: fn () => Repo::user()->get($this->userId, true), + )->shouldCache(); + } + + /** + * An on-to-many relationship with user_comment_report table => UserCommentReport Eloquent model. + * To eagerly fill the reports collection, the calling code should add `UserComment::with(['reports'])` + */ + public function reports(): HasMany + { + return $this->hasMany(UserCommentReport::class, 'user_comment_id', 'user_comment_id'); + } + + // Scopes + + /** + * Scope a query to only include comments with specific publication IDs. + */ + protected function scopeWithPublicationIds(EloquentBuilder $builder, array $publicationIds): EloquentBuilder + { + return $builder->whereIn('publication_id', $publicationIds); + } + + /** + * Scope a query to only include comments with specific user IDs. + */ + protected function scopeWithUserIds(EloquentBuilder $builder, array $userIds): EloquentBuilder + { + return $builder->whereIn('user_id', $userIds); + } + + /** + * Scope a query to only include comments with specific IDs. + */ + protected function scopeWithUserCommentIds(EloquentBuilder $builder, array $userCommentIds): EloquentBuilder + { + return $builder->whereIn('user_comment_id', $userCommentIds); + } + + /** + * Scope a query to include comments based on if they are reported or not. + */ + protected function scopeWithIsReported(EloquentBuilder $builder, $isReported): EloquentBuilder + { + return $isReported + ? $builder->whereHas('reports') + : $builder->whereDoesntHave('reports'); + } + + /** + * Scope a query to only include comments with a specific approval status. + */ + protected function scopeWithIsApproved(EloquentBuilder $builder, bool $isApproved): EloquentBuilder + { + return $builder->where('is_approved', $isApproved); + } +} diff --git a/classes/userComment/relationships/UserCommentReport.php b/classes/userComment/relationships/UserCommentReport.php new file mode 100644 index 00000000000..29e6a718c88 --- /dev/null +++ b/classes/userComment/relationships/UserCommentReport.php @@ -0,0 +1,84 @@ + $attributes[$this->primaryKey] ?? null, + ); + } + + /** + * Get the comment that the report belongs to. + */ + public function comment(): BelongsTo + { + return $this->belongsTo(UserComment::class, 'user_comment_id', 'user_comment_id'); + } + + /** + * Get the user that created the report. + */ + protected function user(): Attribute + { + return Attribute::make( + get: fn () => Repo::user()->get($this->userId, true), + )->shouldCache(); + } + + // Scopes + + /** + * Scope a query to only include reports with specific comment IDs. + */ + protected function scopeWithCommentIds(EloquentBuilder $builder, array $commentIds): EloquentBuilder + { + return $builder->whereIn('user_comment_id', $commentIds); + } + + /** + * Scope a query to only include reports with specific report IDs. + */ + protected function scopeWithReportIds(EloquentBuilder $builder, array $reportIds): EloquentBuilder + { + return $builder->whereIn('user_comment_report_id', $reportIds); + } +} diff --git a/locale/en/api.po b/locale/en/api.po index 0c5694688d6..6f0413ad5f4 100644 --- a/locale/en/api.po +++ b/locale/en/api.po @@ -376,3 +376,30 @@ msgstr "A category cannot be its own parent or create a circular reference with msgid "api.categories.409.circularReference" msgstr "A circular reference was detected while building the category tree." + +msgid "api.userComments.400.cannotCommentOnPublicationVersion" +msgstr "You are only allowed to comment on the most recently published publication." + +msgid "api.userComments.400.missingPublicationParam" +msgstr "The request is missing the required `publicationIds` query parameter." + +msgid "api.userComments.400.invalidPublicationId" +msgstr "An invalid publication ID was provided: `{$publicationId}`." + +msgid "api.userComments.400.invalidUserId" +msgstr "An invalid user ID was provided: `{$userId}`." + +msgid "api.userComments.form.400.required.commentText" +msgstr "The `commentText` field is required." + +msgid "api.userComments.form.400.required.publicationId" +msgstr "The `publicationId` field is required." + +msgid "api.userComments.form.400.required.note" +msgstr "The `note` field is required." + +msgid "api.userComments.400.invalidIsApproved" +msgstr "An invalid value was provided for `isApproved`: `{$isApproved}`." + +msgid "api.userComments.400.invalidIsReported" +msgstr "An invalid value was provided for `isReported`: `{$isReported}`."