diff --git a/backend/app/Events/InlineCommentAdded.php b/backend/app/Events/InlineCommentAdded.php new file mode 100644 index 000000000..79df5bc23 --- /dev/null +++ b/backend/app/Events/InlineCommentAdded.php @@ -0,0 +1,42 @@ +inline_comment = $inline_comment; + } + + /** + * Get the channels the event should broadcast on. + * + * @return array + */ + public function broadcastOn(): array + { + return [ + new PrivateChannel('channel-name'), + ]; + } +} diff --git a/backend/app/Events/InlineCommentReplyAdded.php b/backend/app/Events/InlineCommentReplyAdded.php new file mode 100644 index 000000000..207c7acc4 --- /dev/null +++ b/backend/app/Events/InlineCommentReplyAdded.php @@ -0,0 +1,42 @@ +inline_comment = $inline_comment; + } + + /** + * Get the channels the event should broadcast on. + * + * @return array + */ + public function broadcastOn(): array + { + return [ + new PrivateChannel('channel-name'), + ]; + } +} diff --git a/backend/app/Events/OverallCommentAdded.php b/backend/app/Events/OverallCommentAdded.php new file mode 100644 index 000000000..8817657c1 --- /dev/null +++ b/backend/app/Events/OverallCommentAdded.php @@ -0,0 +1,42 @@ +overall_comment = $overall_comment; + } + + /** + * Get the channels the event should broadcast on. + * + * @return array + */ + public function broadcastOn(): array + { + return [ + new PrivateChannel('channel-name'), + ]; + } +} diff --git a/backend/app/Events/OverallCommentReplyAdded.php b/backend/app/Events/OverallCommentReplyAdded.php new file mode 100644 index 000000000..fb95e4bf3 --- /dev/null +++ b/backend/app/Events/OverallCommentReplyAdded.php @@ -0,0 +1,42 @@ +overall_comment = $overall_comment; + } + + /** + * Get the channels the event should broadcast on. + * + * @return array + */ + public function broadcastOn(): array + { + return [ + new PrivateChannel('channel-name'), + ]; + } +} diff --git a/backend/app/Listeners/NotifyUsersAboutInlineComment.php b/backend/app/Listeners/NotifyUsersAboutInlineComment.php new file mode 100644 index 000000000..f9908fbfc --- /dev/null +++ b/backend/app/Listeners/NotifyUsersAboutInlineComment.php @@ -0,0 +1,46 @@ +inline_comment->submission; + $submitters = $submission->submitters()->get(); + $review_coordinators = $submission->reviewCoordinators()->get(); + $notification_data = [ + 'submission' => [ + 'id' => $submission->id, + 'title' => $submission->title, + ], + 'commentor' => [ + 'display_label' => $event->inline_comment->createdBy->displayLabel, + ], + 'type' => 'submission.inline_comment.added', + ]; + $recipients = $submitters + ->merge($review_coordinators) + ->unique() + ->filter(function ($user) use ($event) { + return $user->id !== $event->inline_comment->createdBy->id; + }); + + Notification::send($recipients, new InlineCommentAdded($notification_data)); + } +} diff --git a/backend/app/Listeners/NotifyUsersAboutInlineCommentReply.php b/backend/app/Listeners/NotifyUsersAboutInlineCommentReply.php new file mode 100644 index 000000000..e0c461e36 --- /dev/null +++ b/backend/app/Listeners/NotifyUsersAboutInlineCommentReply.php @@ -0,0 +1,50 @@ +inline_comment->submission; + $submitters = $submission->submitters()->get(); + $parent_commentor = $event->inline_comment->parent->createdBy()->get(); + $commentors = $event->inline_comment->parent->commentors()->get(); + $review_coordinators = $submission->reviewCoordinators()->get(); + $notification_data = [ + 'submission' => [ + 'id' => $submission->id, + 'title' => $submission->title, + ], + 'commentor' => [ + 'display_label' => $event->inline_comment->createdBy->displayLabel, + ], + 'type' => 'submission.inline_comment_reply.added', + ]; + $recipients = $submitters + ->merge($commentors) + ->merge($parent_commentor) + ->merge($review_coordinators) + ->unique() + ->filter(function ($user) use ($event) { + return $user->id !== $event->inline_comment->createdBy->id; + }); + + Notification::send($recipients, new InlineCommentReplyAdded($notification_data)); + } +} diff --git a/backend/app/Listeners/NotifyUsersAboutOverallComment.php b/backend/app/Listeners/NotifyUsersAboutOverallComment.php new file mode 100644 index 000000000..560c59670 --- /dev/null +++ b/backend/app/Listeners/NotifyUsersAboutOverallComment.php @@ -0,0 +1,46 @@ +overall_comment->submission; + $submitters = $submission->submitters()->get(); + $review_coordinators = $submission->reviewCoordinators()->get(); + $notification_data = [ + 'submission' => [ + 'id' => $submission->id, + 'title' => $submission->title, + ], + 'commentor' => [ + 'display_label' => $event->overall_comment->createdBy->displayLabel, + ], + 'type' => 'submission.overall_comment.added', + ]; + $recipients = $submitters + ->merge($review_coordinators) + ->unique() + ->filter(function ($user) use ($event) { + return $user->id !== $event->overall_comment->createdBy->id; + }); + + Notification::send($recipients, new OverallCommentAdded($notification_data)); + } +} diff --git a/backend/app/Listeners/NotifyUsersAboutOverallCommentReply.php b/backend/app/Listeners/NotifyUsersAboutOverallCommentReply.php new file mode 100644 index 000000000..f07a64c9a --- /dev/null +++ b/backend/app/Listeners/NotifyUsersAboutOverallCommentReply.php @@ -0,0 +1,50 @@ +overall_comment->submission; + $submitters = $submission->submitters()->get(); + $parent_commentor = $event->overall_comment->parent->createdBy()->get(); + $commentors = $event->overall_comment->parent->commentors()->get(); + $review_coordinators = $submission->reviewCoordinators()->get(); + $notification_data = [ + 'submission' => [ + 'id' => $submission->id, + 'title' => $submission->title, + ], + 'commentor' => [ + 'display_label' => $event->overall_comment->createdBy->displayLabel, + ], + 'type' => 'submission.overall_comment_reply.added', + ]; + $recipients = $submitters + ->merge($commentors) + ->merge($parent_commentor) + ->merge($review_coordinators) + ->unique() + ->filter(function ($user) use ($event) { + return $user->id !== $event->overall_comment->createdBy->id; + }); + + Notification::send($recipients, new OverallCommentReplyAdded($notification_data)); + } +} diff --git a/backend/app/Models/InlineComment.php b/backend/app/Models/InlineComment.php index b6560c7a4..6b4ea393d 100644 --- a/backend/app/Models/InlineComment.php +++ b/backend/app/Models/InlineComment.php @@ -3,12 +3,15 @@ namespace App\Models; +use App\Events\InlineCommentAdded; +use App\Events\InlineCommentReplyAdded; use App\Http\Traits\CreatedUpdatedBy; use App\Models\Traits\ReadStatus; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\SoftDeletes; class InlineComment extends BaseModel @@ -38,6 +41,21 @@ class InlineComment extends BaseModel 'to', ]; + /** + * @return void + */ + protected static function boot() + { + parent::boot(); + static::created(function ($inlineComment) { + if ($inlineComment->parent_id) { + InlineCommentReplyAdded::dispatch($inlineComment); + } else { + InlineCommentAdded::dispatch($inlineComment); + } + }); + } + /** * The submission that owns the inline comment * @@ -63,6 +81,28 @@ public function replies(): HasMany } } + /** + * All commentors involved in the inline comment thread: + * - anyone who has replied to the parent inline comment or its replies + * - does not include the creator of the parent inline comment + * + * @return \Illuminate\Database\Eloquent\Relations\HasManyThrough + */ + public function commentors(): HasManyThrough + { + $parentComment = $this->parent_id ? $this->parent : $this; + + // Get all users who have replied to the parent inline comment + return $this->hasManyThrough( + User::class, + InlineComment::class, + 'parent_id', // Foreign key on InlineComment table + 'id', // Foreign key on User table + 'id', // Local key on this table + 'created_by' // Local key on InlineComment table + )->where('parent_id', $parentComment->id); + } + /** * The creator of the inline comment * @@ -83,6 +123,16 @@ public function updatedBy(): BelongsTo return $this->belongsTo(User::class, 'updated_by'); } + /** + * The parent inline comment of this inline comment reply + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function parent(): BelongsTo + { + return $this->belongsTo(InlineComment::class, 'parent_id'); + } + /** * @return \Illuminate\Database\Eloquent\Casts\Attribute */ diff --git a/backend/app/Models/OverallComment.php b/backend/app/Models/OverallComment.php index a696e08c0..c858ea1d1 100644 --- a/backend/app/Models/OverallComment.php +++ b/backend/app/Models/OverallComment.php @@ -3,12 +3,15 @@ namespace App\Models; +use App\Events\OverallCommentAdded; +use App\Events\OverallCommentReplyAdded; use App\Http\Traits\CreatedUpdatedBy; use App\Models\Traits\ReadStatus; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\SoftDeletes; class OverallComment extends BaseModel @@ -30,6 +33,21 @@ class OverallComment extends BaseModel 'parent_id', ]; + /** + * @return void + */ + protected static function boot() + { + parent::boot(); + static::created(function ($overallComment) { + if ($overallComment->parent_id) { + OverallCommentReplyAdded::dispatch($overallComment); + } else { + OverallCommentAdded::dispatch($overallComment); + } + }); + } + /** * The submission that owns the overall comment * @@ -55,6 +73,28 @@ public function replies(): HasMany } } + /** + * All commentors involved in the overall comment thread: + * - anyone who has replied to the parent overall comment or its replies + * - does not include the creator of the parent overall comment + * + * @return \Illuminate\Database\Eloquent\Relations\HasManyThrough + */ + public function commentors(): HasManyThrough + { + $parentComment = $this->parent_id ? $this->parent : $this; + + // Get all users who have replied to the parent overall comment + return $this->hasManyThrough( + User::class, + OverallComment::class, + 'parent_id', // Foreign key on OverallComment table + 'id', // Foreign key on User table + 'id', // Local key on this table + 'created_by' // Local key on OverallComment table + )->where('parent_id', $parentComment->id); + } + /** * The creator of the overall comment * @@ -75,6 +115,16 @@ public function updatedBy(): BelongsTo return $this->belongsTo(User::class, 'updated_by'); } + /** + * The parent inline comment of this inline comment reply + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function parent(): BelongsTo + { + return $this->belongsTo(OverallComment::class, 'parent_id'); + } + /** * @return \Illuminate\Database\Eloquent\Casts\Attribute */ diff --git a/backend/app/Notifications/InlineCommentAdded.php b/backend/app/Notifications/InlineCommentAdded.php new file mode 100644 index 000000000..58d153f9a --- /dev/null +++ b/backend/app/Notifications/InlineCommentAdded.php @@ -0,0 +1,59 @@ +data = $inline_comment; + $this->after_commit = true; + } + + /** + * Get the notification's delivery channels. + * + * @return array + */ + public function via(): array + { + return ['database']; + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(): array + { + return [ + 'submission' => [ + 'id' => $this->data['submission']['id'], + 'title' => $this->data['submission']['title'], + ], + 'commentor' => [ + 'display_label' => $this->data['commentor']['display_label'], + ], + 'type' => $this->data['type'], + ]; + } +} diff --git a/backend/app/Notifications/InlineCommentReplyAdded.php b/backend/app/Notifications/InlineCommentReplyAdded.php new file mode 100644 index 000000000..80acb818f --- /dev/null +++ b/backend/app/Notifications/InlineCommentReplyAdded.php @@ -0,0 +1,58 @@ +data = $inline_comment; + $this->after_commit = true; + } + + /** + * Get the notification's delivery channels. + * + * @return array + */ + public function via(): array + { + return ['database']; + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(): array + { + return [ + 'submission' => [ + 'id' => $this->data['submission']['id'], + 'title' => $this->data['submission']['title'], + ], + 'commentor' => [ + 'display_label' => $this->data['commentor']['display_label'], + ], + 'type' => $this->data['type'], + ]; + } +} diff --git a/backend/app/Notifications/OverallCommentAdded.php b/backend/app/Notifications/OverallCommentAdded.php new file mode 100644 index 000000000..e3668cc58 --- /dev/null +++ b/backend/app/Notifications/OverallCommentAdded.php @@ -0,0 +1,59 @@ +data = $overall_comment; + $this->after_commit = true; + } + + /** + * Get the notification's delivery channels. + * + * @return array + */ + public function via(): array + { + return ['database']; + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(): array + { + return [ + 'submission' => [ + 'id' => $this->data['submission']['id'], + 'title' => $this->data['submission']['title'], + ], + 'commentor' => [ + 'display_label' => $this->data['commentor']['display_label'], + ], + 'type' => $this->data['type'], + ]; + } +} diff --git a/backend/app/Notifications/OverallCommentReplyAdded.php b/backend/app/Notifications/OverallCommentReplyAdded.php new file mode 100644 index 000000000..64bc9bf14 --- /dev/null +++ b/backend/app/Notifications/OverallCommentReplyAdded.php @@ -0,0 +1,58 @@ +data = $overall_comment; + $this->after_commit = true; + } + + /** + * Get the notification's delivery channels. + * + * @return array + */ + public function via(): array + { + return ['database']; + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(): array + { + return [ + 'submission' => [ + 'id' => $this->data['submission']['id'], + 'title' => $this->data['submission']['title'], + ], + 'commentor' => [ + 'display_label' => $this->data['commentor']['display_label'], + ], + 'type' => $this->data['type'], + ]; + } +} diff --git a/backend/app/Providers/EventServiceProvider.php b/backend/app/Providers/EventServiceProvider.php index ec6235eae..476337a92 100644 --- a/backend/app/Providers/EventServiceProvider.php +++ b/backend/app/Providers/EventServiceProvider.php @@ -2,6 +2,10 @@ namespace App\Providers; +use App\Events\OverallCommentAdded; +use App\Events\OverallCommentReplyAdded; +use App\Events\InlineCommentAdded; +use App\Events\InlineCommentReplyAdded; use App\Events\ReviewCoordinatorInvitationAccepted; use App\Events\ReviewCoordinatorInvited; use App\Events\ReviewerInvited; @@ -11,6 +15,10 @@ use App\Listeners\NotifyReviewerAboutInvitation; use App\Listeners\NotifyUsersAboutAcceptedReviewCoordinatorInvitation; use App\Listeners\NotifyUsersAboutAcceptedReviewerInvitation; +use App\Listeners\NotifyUsersAboutInlineComment; +use App\Listeners\NotifyUsersAboutInlineCommentReply; +use App\Listeners\NotifyUsersAboutOverallComment; +use App\Listeners\NotifyUsersAboutOverallCommentReply; use App\Listeners\NotifyUsersAboutUpdatedSubmissionStatus; use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Listeners\SendEmailVerificationNotification; @@ -47,6 +55,18 @@ class EventServiceProvider extends ServiceProvider 'SocialiteProviders\Google\GoogleExtendSocialite@handle', 'SocialiteProviders\Orcid\OrcidExtendSocialite@handle' ], + InlineCommentAdded::class => [ + NotifyUsersAboutInlineComment::class, + ], + InlineCommentReplyAdded::class => [ + NotifyUsersAboutInlineCommentReply::class, + ], + OverallCommentAdded::class => [ + NotifyUsersAboutOverallComment::class, + ], + OverallCommentReplyAdded::class => [ + NotifyUsersAboutOverallCommentReply::class, + ], ]; /** diff --git a/backend/graphql/notification.graphql b/backend/graphql/notification.graphql index 38a2c7f31..7dcc8e0d9 100644 --- a/backend/graphql/notification.graphql +++ b/backend/graphql/notification.graphql @@ -27,6 +27,7 @@ type NotificationData { body: String action: String url: String + commentor: User invitee: User inviter: User submission: Submission diff --git a/backend/phpunit.xml b/backend/phpunit.xml index 588813d64..f7b05a3f3 100644 --- a/backend/phpunit.xml +++ b/backend/phpunit.xml @@ -6,9 +6,11 @@ ./tests/Feature + ./tests/Feature/Notifications ./tests/Api + ./tests/Api/Notifications diff --git a/backend/tests/Api/NotificationTest.php b/backend/tests/Api/Notifications/SubmissionStatusUpdatesTest.php similarity index 99% rename from backend/tests/Api/NotificationTest.php rename to backend/tests/Api/Notifications/SubmissionStatusUpdatesTest.php index d3ced68bd..30f5ee4a0 100644 --- a/backend/tests/Api/NotificationTest.php +++ b/backend/tests/Api/Notifications/SubmissionStatusUpdatesTest.php @@ -1,7 +1,7 @@ create(); - - return Submission::factory() - ->hasAttached($user, [], 'submitters') - ->create(['status' => $status]); - } - - /** - * @param int $id - * @return StyleCriteria - */ - private function createStyleCriteria($id) - { - $criteria = StyleCriteria::factory() - ->create([ - 'name' => 'PHPUnit Criteria', - 'publication_id' => $id, - 'description' => 'This is a test style criteria created by PHPUnit', - 'icon' => 'php', - ]); - - return $criteria; - } - - /** - * @param int $count - * @param User|null $user (optional) - * @return Submission - */ - private function createSubmissionWithInlineComment($count = 1, $user = null) - { - if ($user === null) { - $user = User::factory()->create(); - } - $submission = $this->createSubmission(); - $style_criteria = $this->createStyleCriteria($submission->publication->id); - InlineComment::factory()->count($count)->create([ - 'submission_id' => $submission->id, - 'content' => 'This is some content for an inline comment created by PHPUnit.', - 'created_by' => $user->id, - 'updated_by' => $user->id, - 'style_criteria' => [$style_criteria], - ]); - - return $submission; - } - - /** - * @param int $count - * @param User|null $user (optional) - * @return Submission - */ - private function createSubmissionWithOverallComment($count = 1, $user = null) - { - if ($user === null) { - $user = User::factory()->create(); - } - $submission = $this->createSubmission(); - OverallComment::factory()->count($count)->create([ - 'submission_id' => $submission->id, - 'content' => 'This is some content for an overall comment created by PHPUnit.', - 'created_by' => $user->id, - 'updated_by' => $user->id, - ]); - - return $submission; - } + use TestFactory; public function testRetrieveInlineComments() { diff --git a/backend/tests/Feature/Notifications/InlineCommentsTest.php b/backend/tests/Feature/Notifications/InlineCommentsTest.php new file mode 100644 index 000000000..3dcfc9382 --- /dev/null +++ b/backend/tests/Feature/Notifications/InlineCommentsTest.php @@ -0,0 +1,159 @@ +createSubmissionWithAllRoles(); + + // Create comment participants + $commentor = User::factory()->create(); + $commentor_reply = User::factory()->create(); + $commentor_reply_to_reply = User::factory()->create(); + $commentor_elsewhere = User::factory()->create(); + + // Assign comment participants as submission reviewers + $submission->reviewers()->attach([ + $commentor->id, + $commentor_reply->id, + $commentor_reply_to_reply->id, + $commentor_elsewhere->id, + ]); + + // Make comments + InlineComment::factory()->create([ + 'submission_id' => $submission->id, + 'content' => 'This is some content for an inline comment created by PHPUnit.', + 'created_by' => $commentor_elsewhere->id, + 'updated_by' => $commentor_elsewhere->id, + 'style_criteria' => [], + 'parent_id' => null, + 'reply_to_id' => null, + ]); + $comment_parent = InlineComment::factory()->create([ + 'submission_id' => $submission->id, + 'content' => 'This is some content for an inline comment created by PHPUnit.', + 'created_by' => $commentor->id, + 'updated_by' => $commentor->id, + 'style_criteria' => [], + 'parent_id' => null, + 'reply_to_id' => null, + ]); + $comment_reply = InlineComment::factory()->create([ + 'submission_id' => $submission->id, + 'content' => 'This is some content for an inline comment reply created by PHPUnit.', + 'created_by' => $commentor_reply->id, + 'updated_by' => $commentor_reply->id, + 'style_criteria' => [], + 'parent_id' => $comment_parent->id, + 'reply_to_id' => $comment_parent->id, + ]); + InlineComment::factory()->create([ + 'submission_id' => $submission->id, + 'content' => 'This is some content for an inline comment reply to a reply created by PHPUnit.', + 'created_by' => $commentor_reply_to_reply->id, + 'updated_by' => $commentor_reply_to_reply->id, + 'style_criteria' => [], + 'parent_id' => $comment_parent->id, + 'reply_to_id' => $comment_reply->id, + ]); + + return $submission; + } + + /** + * @return void + */ + public function testUsersReceiveNotificationsForNewInlineComments() + { + $submission = $this->createSubmissionWithInlineCommentThread(); + $comments = $submission->inlineCommentsWithReplies(); + $this->assertEquals(4, $comments->count()); + + // Submitter + // Gets all 4 notifications + $submitter = $submission->submitters()->first(); + $this->assertEquals(2, $this->getInlineCommentNotificationCount($submitter)); + $this->assertEquals(2, $this->getInlineCommentReplyNotificationCount($submitter)); + + // Uninvolved First Reviewer + // Gets 0 notifications + $reviewer1 = $submission->reviewers()->first(); + $this->assertEquals(0, $this->getInlineCommentNotificationCount($reviewer1)); + $this->assertEquals(0, $this->getInlineCommentReplyNotificationCount($reviewer1)); + + // Inline Commentor + // Gets 2 notifications for all replies + $reviewer2 = $submission->reviewers()->get()->slice(1, 1)->first(); + $this->assertEquals(0, $this->getInlineCommentNotificationCount($reviewer2)); + $this->assertEquals(2, $this->getInlineCommentReplyNotificationCount($reviewer2)); + + // Inline Comment Replier + // Gets 1 notification for reply to reply + $reviewer3 = $submission->reviewers()->get()->slice(2, 1)->first(); + $this->assertEquals(0, $this->getInlineCommentNotificationCount($reviewer3)); + $this->assertEquals(1, $this->getInlineCommentReplyNotificationCount($reviewer3)); + + // Inline Comment Reply Replier + // Gets 0 notifications + $reviewer4 = $submission->reviewers()->get()->slice(3, 1)->first(); + $this->assertEquals(0, $this->getInlineCommentNotificationCount($reviewer4)); + $this->assertEquals(0, $this->getInlineCommentReplyNotificationCount($reviewer4)); + + // Separate Inline Commentor + // Gets 0 notifications + $reviewer5 = $submission->reviewers()->first(); + $this->assertEquals(0, $this->getInlineCommentNotificationCount($reviewer5)); + $this->assertEquals(0, $this->getInlineCommentReplyNotificationCount($reviewer5)); + + // Coordinator + // Gets all 4 notifications + $coordinator = $submission->reviewCoordinators()->first(); + $this->assertEquals(2, $this->getInlineCommentNotificationCount($coordinator)); + $this->assertEquals(2, $this->getInlineCommentReplyNotificationCount($coordinator)); + + // Editor + // Gets 0 notifications + $editor = $submission->publication->editors()->first(); + $this->assertEquals(0, $this->getInlineCommentNotificationCount($editor)); + $this->assertEquals(0, $this->getInlineCommentReplyNotificationCount($editor)); + + // Publication Admins + // Gets 0 notifications + $admin = $submission->publication->publicationAdmins()->first(); + $this->assertEquals(0, $this->getInlineCommentNotificationCount($admin)); + $this->assertEquals(0, $this->getInlineCommentReplyNotificationCount($admin)); + } + + /** + * @param User $user + * @return int + */ + private function getInlineCommentNotificationCount($user) + { + $type = 'App\Notifications\InlineCommentAdded'; + + return $user->notifications()->where('type', $type)->get()->count(); + } + + /** + * @param User $user + * @return int + */ + private function getInlineCommentReplyNotificationCount($user) + { + $type = 'App\Notifications\InlineCommentReplyAdded'; + + return $user->notifications()->where('type', $type)->get()->count(); + } +} diff --git a/backend/tests/Feature/Notifications/InvitationsTest.php b/backend/tests/Feature/Notifications/InvitationsTest.php new file mode 100644 index 000000000..6ceb51a40 --- /dev/null +++ b/backend/tests/Feature/Notifications/InvitationsTest.php @@ -0,0 +1,78 @@ +beAppAdmin(); + $submitter = User::factory()->create(); + $reviewer = User::factory()->create(); + $review_coordinator = User::factory()->create(); + $submission = Submission::factory() + ->hasAttached($submitter, [], 'submitters') + ->hasAttached($reviewer, [], 'reviewers') + ->hasAttached($review_coordinator, [], 'reviewCoordinators') + ->create(); + $invite = SubmissionInvitation::create([ + 'submission_id' => $submission->id, + 'role_id' => Role::REVIEWER_ROLE_ID, + 'email' => 'bob1@msu.edu', + ]); + $invite->inviteReviewer(); + $details = [ + 'name' => '', + 'username' => 'bob1', + 'password' => 'rLT2ovkZkMby5UpwiQkFBeS9', + ]; + $invite->acceptInvite($details); + $this->assertEquals(1, $submitter->notifications->count()); + $this->assertEquals(1, $reviewer->notifications->count()); + $this->assertEquals(1, $review_coordinator->notifications->count()); + } + + /** + * @return void + */ + public function testSubmissionUsersReceiveNotificationsUponAcceptedReviewCoordinatorInvitations() + { + $this->beAppAdmin(); + $submitter = User::factory()->create(); + $reviewer = User::factory()->create(); + $review_coordinator = User::factory()->create(); + $submission = Submission::factory() + ->hasAttached($submitter, [], 'submitters') + ->hasAttached($reviewer, [], 'reviewers') + ->hasAttached($review_coordinator, [], 'reviewCoordinators') + ->create(); + $invite = SubmissionInvitation::create([ + 'submission_id' => $submission->id, + 'role_id' => Role::REVIEW_COORDINATOR_ROLE_ID, + 'email' => 'bob2@msu.edu', + ]); + $invite->inviteReviewCoordinator(); + $details = [ + 'name' => '', + 'username' => 'bob2', + 'password' => 'aYUB1IYUadd38fl9mxAVv2', + ]; + $invite->acceptInvite($details); + $this->assertEquals(1, $submitter->notifications->count()); + $this->assertEquals(1, $reviewer->notifications->count()); + $this->assertEquals(1, $review_coordinator->notifications->count()); + } +} diff --git a/backend/tests/Feature/Notifications/OverallCommentsTest.php b/backend/tests/Feature/Notifications/OverallCommentsTest.php new file mode 100644 index 000000000..6e62e4f1f --- /dev/null +++ b/backend/tests/Feature/Notifications/OverallCommentsTest.php @@ -0,0 +1,155 @@ +createSubmissionWithAllRoles(); + + // Create comment participants + $commentor = User::factory()->create(); + $commentor_reply = User::factory()->create(); + $commentor_reply_to_reply = User::factory()->create(); + $commentor_elsewhere = User::factory()->create(); + + // Assign comment participants as submission reviewers + $submission->reviewers()->attach([ + $commentor->id, + $commentor_reply->id, + $commentor_reply_to_reply->id, + $commentor_elsewhere->id, + ]); + + // Make comments + OverallComment::factory()->create([ + 'submission_id' => $submission->id, + 'content' => 'This is some content for an overall comment created by PHPUnit.', + 'created_by' => $commentor_elsewhere->id, + 'updated_by' => $commentor_elsewhere->id, + 'parent_id' => null, + 'reply_to_id' => null, + ]); + $comment_parent = OverallComment::factory()->create([ + 'submission_id' => $submission->id, + 'content' => 'This is some content for an overall comment created by PHPUnit.', + 'created_by' => $commentor->id, + 'updated_by' => $commentor->id, + 'parent_id' => null, + 'reply_to_id' => null, + ]); + $comment_reply = OverallComment::factory()->create([ + 'submission_id' => $submission->id, + 'content' => 'This is some content for an overall comment reply created by PHPUnit.', + 'created_by' => $commentor_reply->id, + 'updated_by' => $commentor_reply->id, + 'parent_id' => $comment_parent->id, + 'reply_to_id' => $comment_parent->id, + ]); + OverallComment::factory()->create([ + 'submission_id' => $submission->id, + 'content' => 'This is some content for an overall comment reply to a reply created by PHPUnit.', + 'created_by' => $commentor_reply_to_reply->id, + 'updated_by' => $commentor_reply_to_reply->id, + 'parent_id' => $comment_parent->id, + 'reply_to_id' => $comment_reply->id, + ]); + + return $submission; + } + + /** + * @return void + */ + public function testUsersReceiveNotificationsForNewOverallComments() + { + $submission = $this->createSubmissionWithOverallCommentThread(); + $comments = $submission->overallCommentsWithReplies(); + $this->assertEquals(4, $comments->count()); + + // Submitter + // Gets all 4 notifications + $submitter = $submission->submitters()->first(); + $this->assertEquals(2, $this->getOverallCommentNotificationCount($submitter)); + $this->assertEquals(2, $this->getOverallCommentReplyNotificationCount($submitter)); + + // Uninvolved First Reviewer + // Gets 0 notifications + $reviewer1 = $submission->reviewers()->first(); + $this->assertEquals(0, $this->getOverallCommentNotificationCount($reviewer1)); + $this->assertEquals(0, $this->getOverallCommentReplyNotificationCount($reviewer1)); + + // Overall Commentor + // Gets 2 notifications for all replies + $reviewer2 = $submission->reviewers()->get()->slice(1, 1)->first(); + $this->assertEquals(0, $this->getOverallCommentNotificationCount($reviewer2)); + $this->assertEquals(2, $this->getOverallCommentReplyNotificationCount($reviewer2)); + + // Overall Comment Replier + // Gets 1 notification for reply to reply + $reviewer3 = $submission->reviewers()->get()->slice(2, 1)->first(); + $this->assertEquals(0, $this->getOverallCommentNotificationCount($reviewer3)); + $this->assertEquals(1, $this->getOverallCommentReplyNotificationCount($reviewer3)); + + // Overall Comment Reply Replier + // Gets 0 notifications + $reviewer4 = $submission->reviewers()->get()->slice(3, 1)->first(); + $this->assertEquals(0, $this->getOverallCommentNotificationCount($reviewer4)); + $this->assertEquals(0, $this->getOverallCommentReplyNotificationCount($reviewer4)); + + // Separate overall Commentor + // Gets 0 notifications + $reviewer5 = $submission->reviewers()->first(); + $this->assertEquals(0, $this->getOverallCommentNotificationCount($reviewer5)); + $this->assertEquals(0, $this->getOverallCommentReplyNotificationCount($reviewer5)); + + // Coordinator + // Gets all 4 notifications + $coordinator = $submission->reviewCoordinators()->first(); + $this->assertEquals(2, $this->getOverallCommentNotificationCount($coordinator)); + $this->assertEquals(2, $this->getOverallCommentReplyNotificationCount($coordinator)); + + // Editor + // Gets 0 notifications + $editor = $submission->publication->editors()->first(); + $this->assertEquals(0, $this->getOverallCommentNotificationCount($editor)); + $this->assertEquals(0, $this->getOverallCommentReplyNotificationCount($editor)); + + // Publication Admins + // Gets 0 notifications + $admin = $submission->publication->publicationAdmins()->first(); + $this->assertEquals(0, $this->getOverallCommentNotificationCount($admin)); + $this->assertEquals(0, $this->getOverallCommentReplyNotificationCount($admin)); + } + + /** + * @param User $user + * @return int + */ + private function getOverallCommentNotificationCount($user) + { + $type = 'App\Notifications\OverallCommentAdded'; + + return $user->notifications()->where('type', $type)->get()->count(); + } + + /** + * @param User $user + * @return int + */ + private function getOverallCommentReplyNotificationCount($user) + { + $type = 'App\Notifications\OverallCommentReplyAdded'; + + return $user->notifications()->where('type', $type)->get()->count(); + } +} diff --git a/backend/tests/Feature/NotificationTest.php b/backend/tests/Feature/Notifications/SubmissionStatusUpdatesTest.php similarity index 73% rename from backend/tests/Feature/NotificationTest.php rename to backend/tests/Feature/Notifications/SubmissionStatusUpdatesTest.php index 6467e93da..7778fb5b2 100644 --- a/backend/tests/Feature/NotificationTest.php +++ b/backend/tests/Feature/Notifications/SubmissionStatusUpdatesTest.php @@ -1,20 +1,20 @@ assertEquals(0, $user->unreadNotifications->count()); }); } - - /** - * @return void - */ - public function testSubmissionUsersReceiveNotificationsUponAcceptedReviewerInvitations() - { - $this->beAppAdmin(); - $submitter = User::factory()->create(); - $reviewer = User::factory()->create(); - $review_coordinator = User::factory()->create(); - $submission = Submission::factory() - ->hasAttached($submitter, [], 'submitters') - ->hasAttached($reviewer, [], 'reviewers') - ->hasAttached($review_coordinator, [], 'reviewCoordinators') - ->create(); - $invite = SubmissionInvitation::create([ - 'submission_id' => $submission->id, - 'role_id' => Role::REVIEWER_ROLE_ID, - 'email' => 'bob@msu.edu', - ]); - $invite->inviteReviewer(); - $details = [ - 'name' => '', - 'username' => 'bob', - 'password' => 'rLT2ovkZkMby5UpwiQkFBeS9', - ]; - $invite->acceptInvite($details); - $this->assertEquals(1, $submitter->notifications->count()); - $this->assertEquals(1, $reviewer->notifications->count()); - $this->assertEquals(1, $review_coordinator->notifications->count()); - } - - /** - * @return void - */ - public function testSubmissionUsersReceiveNotificationsUponAcceptedReviewCoordinatorInvitations() - { - $this->beAppAdmin(); - $submitter = User::factory()->create(); - $reviewer = User::factory()->create(); - $review_coordinator = User::factory()->create(); - $submission = Submission::factory() - ->hasAttached($submitter, [], 'submitters') - ->hasAttached($reviewer, [], 'reviewers') - ->hasAttached($review_coordinator, [], 'reviewCoordinators') - ->create(); - $invite = SubmissionInvitation::create([ - 'submission_id' => $submission->id, - 'role_id' => Role::REVIEW_COORDINATOR_ROLE_ID, - 'email' => 'bob@msu.edu', - ]); - $invite->inviteReviewCoordinator(); - $details = [ - 'name' => '', - 'username' => 'bob', - 'password' => 'aYUB1IYUadd38fl9mxAVv2', - ]; - $invite->acceptInvite($details); - $this->assertEquals(1, $submitter->notifications->count()); - $this->assertEquals(1, $reviewer->notifications->count()); - $this->assertEquals(1, $review_coordinator->notifications->count()); - } } diff --git a/backend/tests/Feature/SubmissionCommentTest.php b/backend/tests/Feature/SubmissionCommentTest.php index 571d54625..f09fcdfc1 100644 --- a/backend/tests/Feature/SubmissionCommentTest.php +++ b/backend/tests/Feature/SubmissionCommentTest.php @@ -3,85 +3,16 @@ namespace Tests\Feature; -use App\Models\InlineComment; -use App\Models\OverallComment; -use App\Models\StyleCriteria; -use App\Models\Submission; use App\Models\User; use Carbon\Carbon; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; +use Tests\TestFactory; class SubmissionCommentTest extends TestCase { use RefreshDatabase; - - /** - * @return Submission - */ - private function createSubmission() - { - $user = User::factory()->create(); - - return Submission::factory() - ->hasAttached($user, [], 'submitters') - ->create(); - } - - /** - * @param int $id - * @return StyleCriteria - */ - private function createStyleCriteria($id) - { - $criteria = StyleCriteria::factory() - ->create([ - 'name' => 'PHPUnit Criteria', - 'publication_id' => $id, - 'description' => 'This is a test style criteria created by PHPUnit', - 'icon' => 'php', - ]); - - return $criteria; - } - - /** - * @param int $count - * @return Submission - */ - private function createSubmissionWithInlineComment($count = 1) - { - $user = User::factory()->create(); - $submission = $this->createSubmission(); - $style_criteria = $this->createStyleCriteria($submission->publication->id); - InlineComment::factory()->count($count)->create([ - 'submission_id' => $submission->id, - 'content' => 'This is some content for an inline comment created by PHPUnit.', - 'created_by' => $user->id, - 'updated_by' => $user->id, - 'style_criteria' => [$style_criteria->toArray()], - ]); - - return $submission; - } - - /** - * @param int $count - * @return Submission - */ - private function createSubmissionWithOverallComment($count = 1) - { - $user = User::factory()->create(); - $submission = $this->createSubmission(); - OverallComment::factory()->count($count)->create([ - 'submission_id' => $submission->id, - 'content' => 'This is some content for an overall comment created by PHPUnit.', - 'created_by' => $user->id, - 'updated_by' => $user->id, - ]); - - return $submission; - } + use TestFactory; public function testInlineCommentsAreNotRetrievedForASubmissionThatHasNone() { diff --git a/backend/tests/TestFactory.php b/backend/tests/TestFactory.php new file mode 100644 index 000000000..0583804a4 --- /dev/null +++ b/backend/tests/TestFactory.php @@ -0,0 +1,118 @@ +create(); + + return Submission::factory() + ->hasAttached($user, [], 'submitters') + ->create(['status' => $status]); + } + + /** + * @param int $id + * @return StyleCriteria + */ + protected function createStyleCriteria($id) + { + $criteria = StyleCriteria::factory() + ->create([ + 'name' => 'PHPUnit Criteria', + 'publication_id' => $id, + 'description' => 'This is a test style criteria created by PHPUnit', + 'icon' => 'php', + ]); + + return $criteria; + } + + /** + * @param int $count + * @param User|null $user (optional) + * @return Submission + */ + protected function createSubmissionWithInlineComment($count = 1, $user = null) + { + if ($user === null) { + $user = User::factory()->create(); + } + $submission = $this->createSubmission(); + $style_criteria = $this->createStyleCriteria($submission->publication->id); + InlineComment::factory()->count($count)->create([ + 'submission_id' => $submission->id, + 'content' => 'This is some content for an inline comment created by PHPUnit.', + 'created_by' => $user->id, + 'updated_by' => $user->id, + 'style_criteria' => [$style_criteria], + ]); + + return $submission; + } + + /** + * @param int $count + * @param User|null $user (optional) + * @return Submission + */ + protected function createSubmissionWithOverallComment($count = 1, $user = null) + { + if ($user === null) { + $user = User::factory()->create(); + } + $submission = $this->createSubmission(); + OverallComment::factory()->count($count)->create([ + 'submission_id' => $submission->id, + 'content' => 'This is some content for an overall comment created by PHPUnit.', + 'created_by' => $user->id, + 'updated_by' => $user->id, + ]); + + return $submission; + } + + /** + * Create a submission with users that have all submission-relative and publication-relative roles. + * Does not include Application Administrator role. + * + * @return Submission + */ + protected function createSubmissionWithAllRoles(): Submission + { + $submission = $this->createSubmission(); + $submission->submitters->first(); + + $users = collect(); + for ($i = 0; $i < 4; $i++) { + $users->push( + User::factory()->create([ + 'username' => $this->faker->unique()->userName, + 'email' => $this->faker->unique()->safeEmail, + ]) + ); + } + $submission->reviewers()->attach([$users->slice(0, 1)->first()->id]); + $submission->reviewCoordinators()->attach([$users->slice(1, 1)->first()->id]); + $submission->publication->editors()->attach([$users->slice(2, 1)->first()->id]); + $submission->publication->publicationAdmins()->attach([$users->slice(3, 1)->first()->id]); + + return $submission; + } +} diff --git a/client/src/graphql/queries.js b/client/src/graphql/queries.js index 2f1fc8812..559343eb5 100644 --- a/client/src/graphql/queries.js +++ b/client/src/graphql/queries.js @@ -48,6 +48,9 @@ export const CURRENT_USER_NOTIFICATIONS = gql` submission { title } + commentor { + display_label + } invitee { display_label } diff --git a/client/src/i18n/en-US.json b/client/src/i18n/en-US.json index 062aa8b25..73e2a037c 100644 --- a/client/src/i18n/en-US.json +++ b/client/src/i18n/en-US.json @@ -1028,6 +1028,26 @@ "filter": "Filter" }, "submission": { + "overall_comment": { + "added": { + "short": "{data_commentor_display_label} added a new overall comment to {data_submission_title}" + } + }, + "overall_comment_reply": { + "added": { + "short": "{data_commentor_display_label} replied to an overall comment for {data_submission_title}" + } + }, + "inline_comment": { + "added": { + "short": "{data_commentor_display_label} added a new inline comment to {data_submission_title}" + } + }, + "inline_comment_reply": { + "added": { + "short": "{data_commentor_display_label} replied to an inline comment for {data_submission_title}" + } + }, "invitation": { "review_coordinator": { "accepted": { diff --git a/client/src/mappers/notification_icons.js b/client/src/mappers/notification_icons.js index bb4a93fb4..79a3bf98b 100644 --- a/client/src/mappers/notification_icons.js +++ b/client/src/mappers/notification_icons.js @@ -14,6 +14,18 @@ const icons = flatten({ revision_requested: "undo", archived: "inventory_2", deleted: "delete", + inline_comment: { + added: "chat_bubble", + }, + inline_comment_reply: { + added: "chat_bubble", + }, + overall_comment: { + added: "chat", + }, + overall_comment_reply: { + added: "chat", + }, invitation: { review_coordinator: { accepted: "emoji_people",