diff --git a/api/v1/reviewerSuggestions/ReviewerSuggestionController.php b/api/v1/reviewerSuggestions/ReviewerSuggestionController.php new file mode 100644 index 00000000000..d9f6573d23d --- /dev/null +++ b/api/v1/reviewerSuggestions/ReviewerSuggestionController.php @@ -0,0 +1,169 @@ +get(...)) + ->name('reviewer.suggestions.get') + ->whereNumber('suggestionId'); + + Route::get('submission/{submissionId}', $this->getMany(...)) + ->name('reviewer.suggestions.getMany') + ->whereNumber('submissionId'); + + Route::post('submission/{submissionId}', $this->add(...)) + ->name('reviewer.suggestions.add'); + + Route::put('{suggestionId}', $this->edit(...)) + ->name('reviewer.suggestions.edit') + ->whereNumber('suggestionId'); + + Route::delete('{suggestionId}', $this->delete(...)) + ->name('reviewer.suggestions.delete') + ->whereNumber('suggestionId'); + + Route::post('{suggestionId}', $this->approve(...)) + ->name('reviewer.suggestions.approve') + ->whereNumber('suggestionId'); + } + + /** + * @copydoc \PKP\core\PKPBaseController::authorize() + */ + public function authorize(PKPRequest $request, array &$args, array $roleAssignments): bool + { + $illuminateRequest = $args[0]; /** @var \Illuminate\Http\Request $illuminateRequest */ + $actionName = static::getRouteActionName($illuminateRequest); + + $this->addPolicy(new UserRolesRequiredPolicy($request), true); + $this->addPolicy(new ContextAccessPolicy($request, $roleAssignments)); + + if (in_array($actionName, ['add', 'edit', 'delete'])) { + $this->addPolicy(new SubmissionIncompletePolicy($request, $args)); + } + + return parent::authorize($request, $args, $roleAssignments); + } + + public function get(Request $illuminateRequest): JsonResponse + { + $request = $this->getRequest(); + $context = $request->getContext(); + $contextId = $context->getId(); + + return response()->json([], Response::HTTP_OK); + } + + public function getMany(Request $illuminateRequest): JsonResponse + { + $request = $this->getRequest(); + $context = $request->getContext(); + $contextId = $context->getId(); + + return response()->json([], Response::HTTP_OK); + } + + public function add(AddReviewerSuggestion $illuminateRequest): JsonResponse + { + $validateds = $illuminateRequest->validated(); + $request = $this->getRequest(); + $context = $request->getContext(); + $contextId = $context->getId(); + + return response()->json([], Response::HTTP_OK); + } + + public function edit(Request $illuminateRequest): JsonResponse + { + $request = $this->getRequest(); + $context = $request->getContext(); + $contextId = $context->getId(); + + return response()->json([], Response::HTTP_OK); + } + + public function delete(Request $illuminateRequest): JsonResponse + { + $request = $this->getRequest(); + $context = $request->getContext(); + $contextId = $context->getId(); + + return response()->json([], Response::HTTP_OK); + } + + public function approve(Request $illuminateRequest): JsonResponse + { + $request = $this->getRequest(); + $context = $request->getContext(); + $contextId = $context->getId(); + + return response()->json([], Response::HTTP_OK); + } +} diff --git a/api/v1/reviewerSuggestions/formRequests/AddReviewerSuggestion.php b/api/v1/reviewerSuggestions/formRequests/AddReviewerSuggestion.php new file mode 100644 index 00000000000..f319734225d --- /dev/null +++ b/api/v1/reviewerSuggestions/formRequests/AddReviewerSuggestion.php @@ -0,0 +1,65 @@ + [ + 'required', + 'string', + 'max:255', + ], + 'givenName' => [ + 'required', + 'string', + 'max:255', + ], + 'email' => [ + 'required', + 'email', + ], + ]; + } + + /** + * Get the error messages for the defined validation rules. + * + * @return array + */ + public function messages(): array + { + return [ + 'familyName.required' => 'family name is required', + ]; + } + + /** + * Get custom attributes for validator errors. + * + * @return array + */ + public function attributes(): array + { + return [ + 'familyName' => __('user.familyName'), + 'givenName' => __('user.givenName'), + 'email' => __('user.email'), + ]; + } +} diff --git a/classes/components/forms/context/PKPReviewSetupForm.php b/classes/components/forms/context/PKPReviewSetupForm.php index ab3b70019f0..44637f721ee 100644 --- a/classes/components/forms/context/PKPReviewSetupForm.php +++ b/classes/components/forms/context/PKPReviewSetupForm.php @@ -22,6 +22,7 @@ use PKP\components\forms\FieldSlider; use PKP\components\forms\FieldText; use PKP\components\forms\FormComponent; +use PKP\services\PKPSchemaService; use PKP\submission\reviewAssignment\ReviewAssignment; class PKPReviewSetupForm extends FormComponent @@ -50,7 +51,8 @@ public function __construct($action, $locales, $context) $this ->addDefaultFields($context) - ->addReminderFields($context); + ->addReminderFields($context) + ->addReviewSuggestionControl($context); } /** @@ -174,4 +176,32 @@ protected function addReminderFields(Context $context): static return $this; } + + /** + * Add review suggestion control field + */ + protected function addReviewSuggestionControl(Context $context): static + { + $schema = app()->get('schema'); /** @var \PKP\services\PKPSchemaService $schema */ + + if (!collect($schema->get("context")->properties)->has("reviewerSuggestionEnabled")) { + return $this; + } + + $this->addField( + new FieldOptions('reviewerSuggestionEnabled', [ + 'label' => __('manager.setup.reviewOptions.reviewerSuggestionEnabled'), + 'description' => __('manager.setup.reviewOptions.reviewerSuggestionEnabled.description'), + 'type' => 'checkbox', + 'value' => $context->getData('reviewerSuggestionEnabled'), + 'options' => [ + ['value' => true, 'label' => __('manager.setup.reviewOptions.reviewerSuggestionEnabled.label')], + ], + 'groupId' => self::REVIEW_SETTINGS_GROUP, + ]), + [FIELD_POSITION_AFTER, 'reviewerAccessKeysEnabled'] + ); + + return $this; + } } diff --git a/classes/core/PKPContainer.php b/classes/core/PKPContainer.php index 26f798f3a98..53751c5fa74 100644 --- a/classes/core/PKPContainer.php +++ b/classes/core/PKPContainer.php @@ -100,6 +100,14 @@ public function render($request, Throwable $exception) $pkpRouter = Application::get()->getRequest()->getRouter(); if($pkpRouter instanceof APIRouter && app('router')->getRoutes()->count()) { + + if ($exception instanceof \Illuminate\Validation\ValidationException) { + + return response() + ->json($exception->errors(), $exception->status) + ->send(); + } + return response()->json( [ 'error' => $exception->getMessage() @@ -179,6 +187,8 @@ public function registerConfiguredProviders(): void $this->register(new InvitationServiceProvider($this)); $this->register(new ScheduleServiceProvider($this)); $this->register(new ConsoleCommandServiceProvider($this)); + $this->register(new \Illuminate\Validation\ValidationServiceProvider($this)); + $this->register(new \Illuminate\Foundation\Providers\FormRequestServiceProvider($this)); } /** diff --git a/classes/migration/install/ReviewerSuggestionsMigration.php b/classes/migration/install/ReviewerSuggestionsMigration.php new file mode 100644 index 00000000000..d22c60d6d69 --- /dev/null +++ b/classes/migration/install/ReviewerSuggestionsMigration.php @@ -0,0 +1,118 @@ +comment('Author suggested reviewers at the submission time'); + $table->bigInteger('reviewer_suggestion_id')->autoIncrement(); + + $table + ->bigInteger('user_id') + ->nullable() + ->comment('The user/author who has made the suggestion'); + $table + ->foreign('user_id') + ->references('user_id') + ->on('users') + ->onDelete('set null'); + $table->index(['user_id'], 'reviewer_suggestions_user_id'); + + $table->bigInteger('submission_id')->comment('Submission at which the suggestion was made'); + $table->foreign('submission_id')->references('submission_id')->on('submissions')->onDelete('cascade'); + $table->index(['submission_id'], 'reviewer_suggestions_submission_id'); + + $table->string('email', 255)->comment('Suggested reviewer email address'); + $table->string('orcid_id', 255)->nullable()->comment('Suggested reviewer optional Orcid Id'); + + $table + ->timestamp('approved_at') + ->nullable() + ->comment('If and when the suggestion approved to add/invite suggested_reviewer'); + + $table + ->bigInteger('stage_id') + ->nullable() + ->comment('The stage at whihc suggestion approved'); + + $table + ->bigInteger('approver_id') + ->nullable() + ->comment('The user who has approved the suggestion'); + $table + ->foreign('approver_id') + ->references('user_id') + ->on('users') + ->onDelete('set null'); + + $table + ->bigInteger('reviewer_id') + ->nullable() + ->comment('The reviewer who has been added/invited through this suggestion'); + $table + ->foreign('reviewer_id') + ->references('user_id') + ->on('users') + ->onDelete('set null'); + + $table->timestamps(); + + }); + + Schema::create('reviewer_suggestion_settings', function (Blueprint $table) { + $table->comment('Reviewer suggestion settings table to contain multilingual or extra information'); + + $table + ->bigInteger('reviewer_suggestion_id') + ->comment('The foreign key mapping of this setting to reviewer_suggestions table'); + + $table + ->foreign('reviewer_suggestion_id') + ->references('reviewer_suggestion_id') + ->on('reviewer_suggestions') + ->onDelete('cascade'); + + $table->index(['reviewer_suggestion_id'], 'reviewer_suggestion_settings_reviewer_suggestion_id'); + $table->string('locale', 28)->default(''); + + $table->string('setting_name', 255); + $table->mediumText('setting_value')->nullable(); + + $table->unique(['reviewer_suggestion_id', 'locale', 'setting_name'], 'reviewer_suggestion_settings_unique'); + $table->index(['setting_name', 'locale'], 'reviewer_suggestion_settings_locale_setting_name_index'); + }); + + } + + /** + * Reverse the migration. + */ + public function down(): void + { + Schema::disableForeignKeyConstraints(); + Schema::drop('reviewer_suggestions'); + Schema::drop('reviewer_suggestion_settings'); + Schema::enableForeignKeyConstraints(); + } +} diff --git a/classes/security/authorization/internal/SubmissionIncompletePolicy.php b/classes/security/authorization/internal/SubmissionIncompletePolicy.php new file mode 100644 index 00000000000..32805b1c285 --- /dev/null +++ b/classes/security/authorization/internal/SubmissionIncompletePolicy.php @@ -0,0 +1,64 @@ +getDataObjectId(); + + $submission = Repo::submission()->get((int) $submissionId); + + if ($submission->getData('submissionProgress') > 0) { + return AuthorizationPolicy::AUTHORIZATION_PERMIT; + } + + return AuthorizationPolicy::AUTHORIZATION_DENY; + } +} diff --git a/classes/submission/reviewer/suggestion/ReviewerSuggestion.php b/classes/submission/reviewer/suggestion/ReviewerSuggestion.php new file mode 100644 index 00000000000..090dbecd087 --- /dev/null +++ b/classes/submission/reviewer/suggestion/ReviewerSuggestion.php @@ -0,0 +1,133 @@ + 'int', + 'submissionId' => 'int', + 'email' => 'string', + 'orcidId' => 'string', + 'approvedAt' => 'datetime', + 'stageId' => 'int', + 'approverId' => 'int', + 'reviewerId' => 'int', + ]; + } + + public function hasApproved(): ?Carbon + { + return $this->approvedAt; + } + + /** + * Accessor and Mutator for primary key => id + */ + protected function id(): Attribute + { + return Attribute::make( + get: fn($value, $attributes) => $attributes[$this->primaryKey] ?? null, + set: fn($value) => [$this->primaryKey => $value], + ); + } + + /** + * Accessor for submission. + * Should replace with relationship once Submission is converted to an Eloquent Model. + */ + protected function submission(): Attribute + { + return Attribute::make( + get: fn () => Repo::submission()->get($this->submissionId, true), + ); + } + + /** + * Accessor for user. + * Should replace 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), + ); + } + + /** + * Accessor for user. + * Should replace with relationship once User is converted to an Eloquent Model. + */ + protected function approver(): ?Attribute + { + return Attribute::make( + get: fn () => $this->approverId ? Repo::user()->get($this->approverId, true) : null, + ); + } + + /** + * Accessor for user. + * Should replace with relationship once User is converted to an Eloquent Model. + */ + protected function reviewer(): ?Attribute + { + return Attribute::make( + get: fn () => $this->reviewerId ? Repo::user()->get($this->reviewerId, true) : null, + ); + } + + public function scopeWithContextId(Builder $query, int $contextId): Builder + { + return $query->whereIn("submission_id", fn (Builder $query) => $query + ->select("submission_id") + ->from("submissions") + ->where("context_id", $contextId) + ); + } + + public function scopeWithSubmissionId(Builder $query, int $submissionId): Builder + { + return $query->where("submission_id", $submissionId); + } + + public function scopeWithUserId(Builder $query, int $userId): Builder + { + return $query->where("user_id", $userId); + } + + public function scopeWithStageId(Builder $query, int $stageId): Builder + { + return $query->where("stage_id", $stageId); + } +} diff --git a/locale/en/user.po b/locale/en/user.po index 29721f1288b..d3b9f46b17e 100644 --- a/locale/en/user.po +++ b/locale/en/user.po @@ -69,6 +69,9 @@ msgstr "A workflow stage was not specified." msgid "user.authorization.submission.incomplete.workflowAccessRestrict" msgstr "Workflow access for incomplete submission is restricted." +msgid "user.authorization.submission.complete.reviewerSuggestionRestrict" +msgstr "Add, update or delete of reviewer suggestion for completed submission is restricted." + msgid "user.authorization.pluginRequired" msgstr "A plugin was not specified and is required."