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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
262 changes: 262 additions & 0 deletions api/v1/peerReviews/peerReviewController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
<?php

/**
* @file api/v1/peerReviews/peerReviewController.php
*
* Copyright (c) 2025 Simon Fraser University
* Copyright (c) 2025 John Willinsky
* Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
*
* @class peerReviewController
*
* @ingroup api_v1_peerReviews
*
* @brief Handle API requests for public peer reviews.
*
*/

namespace PKP\API\v1\peerReviews;

use APP\core\Application;
use APP\facades\Repo;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Route;
use PKP\API\v1\peerReviews\resources\PublicationPeerReviewSummaryResource;
use PKP\API\v1\peerReviews\resources\SubmissionPeerReviewSummaryResource;
use PKP\core\PKPBaseController;
use PKP\core\PKPRequest;
use PKP\security\authorization\PublicReviewsEnabledPolicy;

class peerReviewController extends PKPBaseController
{
/**
* @copyDoc
*/
public function authorize(PKPRequest $request, array &$args, array $roleAssignments): bool
{
$this->addPolicy(new PublicReviewsEnabledPolicy($request->getContext()));
return parent::authorize($request, $args, $roleAssignments);
}

/**
* @inheritdoc
*/
public function getHandlerPath(): string
{
return 'peerReviews';
}

/**
* @inheritdoc
*/
public function getRouteGroupMiddleware(): array
{
return [
'has.context',
];
}

/**
* @inheritdoc
*/
public function getGroupRoutes(): void
{
Route::prefix('open/publications')->group(function () {
Route::get('/', $this->getManyOpenReviews(...))
->name('peerReviews.getManyOpenReviews');

Route::get('summary', $this->getManyPublicationReviewSummaries(...))
->name('peerReviews.publication.summary.getMany');

Route::get('{publicationId}', $this->getOpenReview(...))
->name('peerReviews.get')
->whereNumber('publicationId');

Route::get('{publicationId}/summary', $this->getPublicationReviewSummary(...))
->name('peerReviews.publication.summary.get')
->whereNumber('publicationId');
});

Route::prefix('open/submissions')->group(function () {
Route::get('{submissionId}/summary', $this->getSubmissionPeerReviewSummary(...))
->name('peerReviews.open.submissions.summary.get')
->whereNumber('submissionId');

Route::get('summary', $this->getManySubmissionPeerReviewSummary(...))
->name('eerReviews.open.submissions.summary.getMany');
});
}
/**
* Get peer review for a list of publications
* Filters available via query params:
* ```
* publicationIds(array, required) - publication IDs to retrieve peer review data for.
* ```
*/
public function getManyOpenReviews(Request $illuminateRequest): JsonResponse
{
$publicationIdsRaw = paramToArray($illuminateRequest->query('publicationIds', []));
$publicationIds = [];

foreach ($publicationIdsRaw as $id) {
if (!filter_var($id, FILTER_VALIDATE_INT)) {
return response()->json([
'error' => __('api.publication.400.invalidPublicationId', ['publicationId' => $id])
], Response::HTTP_BAD_REQUEST);
}

$publicationIds[] = (int)$id;
}

$publications = Repo::publication()->getCollector()
->filterByPublicationIds($publicationIds)
->getMany();

if ($publications->count() != count($publicationIds)) {
return response()->json([
'error' => __('api.404.resourceNotFound'),
], Response::HTTP_NOT_FOUND);
}

return response()->json(
Repo::publication()->getPeerReviews($publications->all()),
Response::HTTP_OK
);
}

/**
* Get peer review for a publication by ID
*/
public function getOpenReview(Request $illuminateRequest): JsonResponse
{
$publicationId = (int)$illuminateRequest->route('publicationId');
$publication = Repo::publication()->get($publicationId);

if (!$publication) {
return response()->json([
'error' => __('api.404.resourceNotFound'),
], Response::HTTP_NOT_FOUND);
}

return response()->json(
Repo::publication()->getPeerReviews([$publication])->first(),
Response::HTTP_OK
);
}

/**
* Get peer review summary by publication ID
*/
public function getPublicationReviewSummary(Request $illuminateRequest): JsonResponse
{
$publicationId = (int)$illuminateRequest->route('publicationId');
$publication = Repo::publication()->get($publicationId);

if (!$publication) {
return response()->json([
'error' => __('api.404.resourceNotFound'),
], Response::HTTP_NOT_FOUND);
}

return response()->json(
new PublicationPeerReviewSummaryResource($publication),
Response::HTTP_OK
);
}

/**
* Get peer review summaries for a list of publication IDs
*/
public function getManyPublicationReviewSummaries(Request $illuminateRequest): JsonResponse
{
$publicationIdsRaw = paramToArray($illuminateRequest->query('publicationIds', []));
$publicationIds = [];

foreach ($publicationIdsRaw as $id) {
if (!filter_var($id, FILTER_VALIDATE_INT)) {
return response()->json([
'error' => __('api.publication.400.invalidPublicationId', ['publicationId' => $id])
], Response::HTTP_BAD_REQUEST);
}

$publicationIds[] = (int)$id;
}

$publications = Repo::publication()->getCollector()
->filterByPublicationIds($publicationIds)
->getMany();

if ($publications->count() != count($publicationIds)) {
return response()->json([
'error' => __('api.404.resourceNotFound'),
], Response::HTTP_NOT_FOUND);
}

return response()->json(
PublicationPeerReviewSummaryResource::collection($publications),
Response::HTTP_OK
);
}

/**
* Get peer review summary by submission ID.
*/
public function getSubmissionPeerReviewSummary(Request $illuminateRequest): JsonResponse
{
$request = Application::get()->getRequest();
$context = $request->getContext();

$submissionId = (int)$illuminateRequest->route('submissionId');
$submission = Repo::submission()->get($submissionId, $context->getId());

if (!$submission) {
return response()->json([
'error' => __('api.404.resourceNotFound'),
], Response::HTTP_NOT_FOUND);
}

return response()->json(
new SubmissionPeerReviewSummaryResource($submission),
Response::HTTP_OK
);
}

/**
* Get peer review summaries for a list of submission IDs
*/
public function getManySubmissionPeerReviewSummary(Request $illuminateRequest): JsonResponse
{
$submissionIdsRaw = paramToArray($illuminateRequest->query('submissionIds', []));
$submissionIds = [];

$request = Application::get()->getRequest();
$context = $request->getContext();

foreach ($submissionIdsRaw as $id) {
if (!filter_var($id, FILTER_VALIDATE_INT)) {
return response()->json([
'error' => __('api.submission.400.invalidSubmissionId', ['submissionId' => $id])
], Response::HTTP_BAD_REQUEST);
}

$submissionIds[] = (int)$id;
}

$submissions = Repo::submission()->getCollector()
->filterByContextIds([$context->getId()])
->filterBySubmissionIds($submissionIds)->getMany();

if ($submissions->count() != count($submissionIds)) {
return response()->json([
'error' => __('api.404.resourceNotFound'),
], Response::HTTP_NOT_FOUND);
}

return response()->json(
SubmissionPeerReviewSummaryResource::collection($submissions),
Response::HTTP_OK
);
}
}
91 changes: 91 additions & 0 deletions api/v1/peerReviews/resources/BasePeerReviewResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

/**
* @file api/v1/peerReviews/resources/BasePeerReviewResource.php
*
* Copyright (c) 2025 Simon Fraser University
* Copyright (c) 2025 John Willinsky
* Distributed under the GNU GPL v3. For full terms see the file docs/COPYING
*
* @class BasePeerReviewResource
*
* @ingroup api_v1_peerReviews
*
* @brief A base class for API resource classes related to public peer reviews
*/

namespace PKP\API\v1\peerReviews\resources;

use APP\facades\Repo;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Enumerable;
use PKP\context\Context;
use PKP\submission\reviewAssignment\ReviewAssignment;
use PKP\submission\reviewer\recommendation\ReviewerRecommendation;

class BasePeerReviewResource extends JsonResource
{
/**
* Aggregates reviewer recommendations into summary counts.
* - If a reviewer participates in multiple rounds, only their latest completed review counts
* - Incomplete reviews (no completion date) are excluded
*
* @param Enumerable $reviewAssignments The Review Assignments to create summary from.
*/
public function getReviewerRecommendationsSummary(Enumerable $reviewAssignments, Context $context): array
{
$reviewAssignmentsGroupedByRoundId = $reviewAssignments
->groupBy(fn (ReviewAssignment $ra) => $ra->getReviewRoundId())
->map(
fn ($assignments) =>
$assignments->filter(fn (ReviewAssignment $ra) => !!$ra->getDateCompleted())
)
->sortKeys();

return $this->getSummaryCountForReviewerRecommendation($reviewAssignmentsGroupedByRoundId, $context);
}

/**
* Get the summary count for reviews.
*/
private function getSummaryCountForReviewerRecommendation(Enumerable $reviewAssignmentsGroupedByRoundId, Context $context): array
{
$responses = collect();

$availableRecommendationsGroupedByType = ReviewerRecommendation::withContextId($context->getId())->get();

foreach ($reviewAssignmentsGroupedByRoundId as $reviews) {
/** @var ReviewAssignment $review */
foreach ($reviews as $review) {
// For each review in each round, record the reviewer's decision, overriding any decision from previous rounds, keeping their latest recommendation
$responses->put(
$review->getReviewerId(),
$availableRecommendationsGroupedByType->get($review->getReviewerRecommendationId())->type,
);
}
}

return $this->buildSummaryCount($responses->countBy(), $context);
}

/**
* Tally review recommendations for each Recommendation type
*/
private function buildSummaryCount(Enumerable $reviewerResponseCount, Context $context): array
{
$summary = [];

$recTypes = ReviewerRecommendation::withContextId($context->getId())
->get()
->groupBy('type');
$recommendationTypeLabels = Repo::reviewerRecommendation()->getRecommendationTypeLabels();
foreach ($recTypes as $typeId => $recommendation) {
$summary[] = [
'recommendationTypeId' => $typeId,
'recommendationTypeLabel' => $recommendationTypeLabels[$typeId],
'count' => $reviewerResponseCount->get($typeId, 0),
];
}
return $summary;
}
}
Loading