diff --git a/api/v1/comments/UserCommentController.php b/api/v1/comments/UserCommentController.php
new file mode 100644
index 00000000000..b77e345c5df
--- /dev/null
+++ b/api/v1/comments/UserCommentController.php
@@ -0,0 +1,423 @@
+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)
+ ->withContextIds([$this->getRequest()->getContext()->getId()])
+ ->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. Used in UserCommentResource::toArray method.
+ */
+ 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)
+ ->withContextIds([$this->getRequest()->getContext()->getId()]);
+
+ 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. Used in UserCommentResource::toArray method.
+ */
+ 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(AddComment $illuminateRequest): JsonResponse
+ {
+ $request = $this->getRequest();
+ $context = $request->getContext();
+ $currentUser = $request->getUser();
+ $requestBody = $illuminateRequest->validated();
+
+ $publicationId = (int)$requestBody['publicationId'];
+ $commentText = $requestBody['commentText'];
+
+ $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 (!Repo::userComment()->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(AddReport $illuminateRequest): JsonResponse
+ {
+ $requestBody = $illuminateRequest->validated();
+ $note = $requestBody['note'];
+ $commentId = (int)$illuminateRequest->route('commentId');
+
+ $comment = UserComment::query()->find($commentId);
+ $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()->find($commentId);
+
+ if (!$comment) {
+ return response()->json([
+ 'error' => __('api.404.resourceNotFound'),
+ ], Response::HTTP_NOT_FOUND);
+ }
+
+ UserCommentReport::withCommentIds([$commentId])->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);
+ }
+}
diff --git a/api/v1/comments/formRequests/AddComment.php b/api/v1/comments/formRequests/AddComment.php
new file mode 100644
index 00000000000..00371065e59
--- /dev/null
+++ b/api/v1/comments/formRequests/AddComment.php
@@ -0,0 +1,100 @@
+ [
+ 'required',
+ 'integer',
+ Rule::exists('publications', 'publication_id'),
+ ],
+ 'commentText' => [
+ 'required',
+ 'string',
+ ],
+ ];
+ }
+
+
+ /**
+ * Get the validation rules that apply after the initial validation.
+ *
+ * This method is used to perform additional checks that depend on the
+ * results of the initial validation, such as checking if a publication
+ * is the current version of a submission.
+ */
+ public function after(): array
+ {
+ return [
+ function (Validator $validator) {
+ // Only run this validation if all initial checks in `rules` passed
+ if (!$validator->errors()->count()) {
+ $publicationId = (int)$this->input('publicationId');
+ $publication = Repo::publication()->get($publicationId);
+ $submissionId = $publication->getData('submissionId');
+ $submission = Repo::submission()->get((int)$submissionId);
+
+ if (!$submission || $submission->getCurrentPublication()->getId() !== $publicationId) {
+ $validator->errors()->add('publicationId', __('api.userComments.400.cannotCommentOnPublicationVersion'));
+ }
+ }
+ },
+ ];
+ }
+
+ /**
+ * Get the custom error messages for the validation rules.
+ */
+ public function messages(): array
+ {
+ return [
+ 'publicationId.required' => __('api.userComments.form.400.required.publicationId'),
+ 'publicationId.integer' => __('api.userComments.400.invalidPublicationId', ['publicationId' => $this->input('publicationId')]),
+ 'publicationId.exists' => __('api.404.resourceNotFound'),
+ 'commentText.required' => __('api.userComments.form.400.required.commentText'),
+ ];
+ }
+
+ /**
+ * Override the default `failedValidation` method to return a JSON response consistent with existing error responses in codebase.
+ */
+ public function failedValidation(Validator $validator)
+ {
+ throw new HttpResponseException(response()->json($validator->errors(), Response::HTTP_UNPROCESSABLE_ENTITY));
+ }
+
+ /** @inheritdoc */
+ public function validated($key = null, $default = null)
+ {
+ $request = $this->validator->validated();
+ $request['commentText'] = PKPString::stripUnsafeHtml($this->input('commentText'));
+ return $request;
+ }
+}
diff --git a/api/v1/comments/formRequests/AddReport.php b/api/v1/comments/formRequests/AddReport.php
new file mode 100644
index 00000000000..2130f733f02
--- /dev/null
+++ b/api/v1/comments/formRequests/AddReport.php
@@ -0,0 +1,89 @@
+ [
+ 'required',
+ 'string',
+ ],
+ ];
+ }
+
+ /**
+ * Get the custom error messages for the validation rules.
+ */
+ public function messages(): array
+ {
+ return [
+ 'note.required' => __('api.userComments.form.400.required.note'),
+ ];
+ }
+
+ /**
+ * Handle a passed validation attempt.
+ */
+ protected function passedValidation(): void
+ {
+ $commentId = $this->route('commentId');
+ $comment = UserComment::query()->find($commentId);
+
+ if (!$comment) {
+ throw new HttpResponseException(response()->json([
+ 'error' => __('api.404.resourceNotFound'),
+ ], Response::HTTP_NOT_FOUND));
+ }
+
+ $user = Application::get()->getRequest()->getUser();
+
+ if (!Repo::userComment()->isModerator($user) && !$comment->isApproved) {
+ throw new HttpResponseException(response()->json([
+ 'error' => __('api.403.unauthorized'),
+ ], Response::HTTP_FORBIDDEN));
+ }
+ }
+
+ /**
+ * Override the default `failedValidation` method to return a JSON response consistent with existing error responses in codebase.
+ */
+ public function failedValidation(Validator $validator)
+ {
+ throw new HttpResponseException(response()->json($validator->errors(), Response::HTTP_UNPROCESSABLE_ENTITY));
+ }
+
+ /** @inheritdoc */
+ public function validated($key = null, $default = null)
+ {
+ $request = $this->validator->validated();
+ $request['note'] = PKPString::stripUnsafeHtml($this->input('note'));
+ return $request;
+ }
+}
diff --git a/api/v1/comments/resources/UserCommentReportResource.php b/api/v1/comments/resources/UserCommentReportResource.php
new file mode 100644
index 00000000000..b4772e01272
--- /dev/null
+++ b/api/v1/comments/resources/UserCommentReportResource.php
@@ -0,0 +1,41 @@
+user;
+
+ return [
+ 'id' => $this->id,
+ 'userCommentId' => $this->userCommentId,
+ 'userId' => $user->getId(),
+ '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..b0f52404855
--- /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' => $user->getId(),
+ '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 782adc244b4..450e9281af3 100644
--- a/classes/core/PKPString.php
+++ b/classes/core/PKPString.php
@@ -333,24 +333,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/install/ReviewAssignmentSettings.php b/classes/migration/install/ReviewAssignmentSettingsMigration.php
similarity index 89%
rename from classes/migration/install/ReviewAssignmentSettings.php
rename to classes/migration/install/ReviewAssignmentSettingsMigration.php
index 15519fb254c..ca0a5c4c5f8 100644
--- a/classes/migration/install/ReviewAssignmentSettings.php
+++ b/classes/migration/install/ReviewAssignmentSettingsMigration.php
@@ -1,13 +1,13 @@
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')->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');
+
+ $table->index(['publication_id'], 'user_comments_publication_id');
+ $table->index(['publication_id', 'context_id'], 'user_comments_publication_context');
+ $table->index(['is_approved'], 'user_comments_is_approved');
+ });
+
+ 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');
+
+ $table->index(['user_comment_id'], 'user_comment_reports_user_comment_id');
+ });
+
+ 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');
+
+ $table->unique(['user_comment_id', 'locale', 'setting_name'], 'user_comment_settings_unique');
+ });
+ }
+
+ /**
+ * 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/migration/upgrade/v3_6_0/I11326_UserComments.php b/classes/migration/upgrade/v3_6_0/I11326_UserComments.php
new file mode 100755
index 00000000000..4d7bb2a0328
--- /dev/null
+++ b/classes/migration/upgrade/v3_6_0/I11326_UserComments.php
@@ -0,0 +1,96 @@
+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')->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');
+
+ $table->index(['publication_id'], 'user_comments_publication_id');
+ $table->index(['publication_id', 'context_id'], 'user_comments_publication_context');
+ $table->index(['is_approved'], 'user_comments_is_approved');
+ });
+
+ 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');
+
+ $table->index(['user_comment_id'], 'user_comment_reports_user_comment_id');
+ });
+
+ 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');
+
+ $table->unique(['user_comment_id', 'locale', 'setting_name'], 'user_comment_settings_unique');
+ });
+ }
+
+ /**
+ * 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..e8d49ce8ccc
--- /dev/null
+++ b/classes/userComment/Repository.php
@@ -0,0 +1,106 @@
+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;
+ }
+
+ /**
+ * Check if a user is a moderator (admin/manager).
+ */
+ public function isModerator(User $user, ?Context $context = null): bool
+ {
+ $context = $context ?: $this->request->getContext();
+ return $user->hasRole([Role::ROLE_ID_MANAGER], $context->getId()) || $user->hasRole([Role::ROLE_ID_SITE_ADMIN], PKPApplication::SITE_CONTEXT_ID);
+ }
+}
diff --git a/classes/userComment/UserComment.php b/classes/userComment/UserComment.php
new file mode 100644
index 00000000000..822b9d2c745
--- /dev/null
+++ b/classes/userComment/UserComment.php
@@ -0,0 +1,155 @@
+ 'int',
+ 'userId' => 'int',
+ 'contextId' => 'int',
+ 'publicationId' => 'int',
+ 'isApproved' => 'boolean',
+ 'createdAt' => 'datetime',
+ 'updatedAt' => 'datetime',
+ ];
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public static function getSchemaName(): ?string
+ {
+ return null;
+ }
+
+ /**
+ * @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);
+ }
+
+ /**
+ * Scope a query to only include comments with specific context IDs.
+ */
+ protected function scopeWithContextIds(EloquentBuilder $builder, array $contextIds): EloquentBuilder
+ {
+ return $builder->whereIn('context_id', $contextIds);
+ }
+}
diff --git a/classes/userComment/relationships/UserCommentReport.php b/classes/userComment/relationships/UserCommentReport.php
new file mode 100644
index 00000000000..e9240895693
--- /dev/null
+++ b/classes/userComment/relationships/UserCommentReport.php
@@ -0,0 +1,88 @@
+ $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}`."