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
1 change: 1 addition & 0 deletions classes/editorialTask/EditorialTask.php
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ protected function fillHeadnote(array $attributes): array
'userId' => $attributes['createdBy'] ?? $this->createdBy,
'contents' => $attributes[self::ATTRIBUTE_HEADNOTE],
'isHeadnote' => true,
'messageId' => Note::generateMessageId(),
]);

unset($attributes[self::ATTRIBUTE_HEADNOTE]);
Expand Down
203 changes: 193 additions & 10 deletions classes/editorialTask/Repository.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@
use PKP\security\Role;
use PKP\stageAssignment\StageAssignment;
use PKP\user\User;
use PKP\mail\mailables\DiscussionSubmission;
use PKP\mail\mailables\DiscussionReview;
use PKP\mail\mailables\DiscussionCopyediting;
use PKP\mail\mailables\DiscussionProduction;


class Repository
{
Expand All @@ -46,7 +51,7 @@ public function countOpenPerStage(int $submissionId, ?array $participantIds = nu
{
$counts = EditorialTask::withAssoc(Application::ASSOC_TYPE_SUBMISSION, $submissionId)
->when($participantIds !== null, function ($q) use ($participantIds) {
$q->withUserIds($participantIds);
$q->withParticipantIds($participantIds);
})
->withClosed(false)
->selectRaw('stage_id, COUNT(stage_id) as count')
Expand Down Expand Up @@ -96,15 +101,21 @@ public function addQuery(int $submissionId, int $stageId, string $title, string
'seq' => $maxSeq + 1,
'createdBy' => $fromUser->getId(),
'type' => EditorialTaskType::DISCUSSION->value,
EditorialTask::ATTRIBUTE_PARTICIPANTS => array_map(fn (int $participantId) => ['userId' => $participantId], array_unique($participantUserIds)),
EditorialTask::ATTRIBUTE_PARTICIPANTS => array_map(
fn (int $participantId) => ['userId' => $participantId],
array_unique($participantUserIds)
),
'title' => $title,
]);

Note::create([
// Head note for this discussion, with a capturable messageId
$headNote = Note::create([
'assocType' => Application::ASSOC_TYPE_QUERY,
'assocId' => $task->id,
'contents' => $content,
'userId' => $fromUser->getId(),
'isHeadnote' => true,
'messageId' => Note::generateMessageId(),
]);

// Add task for assigned participants
Expand All @@ -113,8 +124,24 @@ public function addQuery(int $submissionId, int $stageId, string $title, string
/** @var NotificationSubscriptionSettingsDAO $notificationSubscriptionSettingsDao */
$notificationSubscriptionSettingsDao = DAORegistry::getDAO('NotificationSubscriptionSettingsDAO');

// need submission + context + stage mailables to send capturable email
$submission = Repo::submission()->get($submissionId);
$application = Application::get();
$request = $application->getRequest();
$context = $request?->getContext();

$mailableMap = [
WORKFLOW_STAGE_ID_SUBMISSION => DiscussionSubmission::class,
WORKFLOW_STAGE_ID_INTERNAL_REVIEW => DiscussionReview::class,
WORKFLOW_STAGE_ID_EXTERNAL_REVIEW => DiscussionReview::class,
WORKFLOW_STAGE_ID_EDITING => DiscussionCopyediting::class,
WORKFLOW_STAGE_ID_PRODUCTION => DiscussionProduction::class,
];

$mailableClass = $mailableMap[$stageId] ?? null;

foreach ($task->participants()->get() as $participant) {
$notificationMgr->createNotification(
$notification = $notificationMgr->createNotification(
$participant->userId,
Notification::NOTIFICATION_TYPE_NEW_QUERY,
$contextId,
Expand All @@ -123,7 +150,8 @@ public function addQuery(int $submissionId, int $stageId, string $title, string
Notification::NOTIFICATION_LEVEL_TASK
);

if (!$sendEmail) {
if (
!$sendEmail|| !$notification || !$mailableClass || !$submission || !$context) {
continue;
}

Expand All @@ -138,11 +166,20 @@ public function addQuery(int $submissionId, int $stageId, string $title, string
}

$recipient = $participant->user;
$mailable = new Mailable();
$mailable->to($recipient->getEmail(), $recipient->getFullName());
$mailable->from($fromUser->getEmail(), $fromUser->getFullName());
$mailable->subject($title);
$mailable->body($content);
if (!$recipient) {
continue;
}

/** @var \PKP\mail\Mailable $mailable */
$mailable = new $mailableClass($context, $submission);

$mailable
->sender($fromUser)
->recipients([$recipient])
->subject($title)
->body($content)
->allowUnsubscribe($notification)
->allowCapturableReply($headNote->messageId);

Mail::send($mailable);
}
Expand Down Expand Up @@ -250,6 +287,152 @@ public function deleteBySubmissionId(int $submissionId): void
}
}

public function notifyParticipantsOnNote(Note $note): void
{
// Only discussion notes
if ($note->assocType !== PKPApplication::ASSOC_TYPE_QUERY) {
return;
}

// skip headnote initial email handled when query is created
if ($note->isHeadnote ?? false) {
return;
}

$task = EditorialTask::find($note->assocId);
if (!$task) {
return;
}

$submission = Repo::submission()->get($task->assocId);
if (!$submission) {
return;
}

$application = Application::get();
$request = $application->getRequest();
$context = $request->getContext();
if (!$context) {
return;
}

$sender = Repo::user()->get($note->userId ?? null);
if (!$sender) {
return;
}

$headNote = Repo::note()->getHeadNote($task->id);
$threadAnchorMessageId = $headNote?->messageId;
$title = $headNote?->title ?: $task->title;
$subject = $title
? __('common.re') . ' ' . $title
: __('common.re');


$participantIds = $task->participants()
->pluck('user_id')
->all();

if (empty($participantIds)) {
return;
}

/** @var NotificationSubscriptionSettingsDAO $notificationSubscriptionSettingsDao */
$notificationSubscriptionSettingsDao = DAORegistry::getDAO('NotificationSubscriptionSettingsDAO');

$notificationManager = new NotificationManager();

// attachments for this note (if any)
$submissionFiles = Repo::submissionFile()
->getCollector()
->filterByAssoc(PKPApplication::ASSOC_TYPE_NOTE, [$note->id])
->filterBySubmissionIds([$submission->getId()])
->getMany();

// Stage -> mailable map (same as StageMailable)
$mailableMap = [
WORKFLOW_STAGE_ID_SUBMISSION => DiscussionSubmission::class,
WORKFLOW_STAGE_ID_INTERNAL_REVIEW => DiscussionReview::class,
WORKFLOW_STAGE_ID_EXTERNAL_REVIEW => DiscussionReview::class,
WORKFLOW_STAGE_ID_EDITING => DiscussionCopyediting::class,
WORKFLOW_STAGE_ID_PRODUCTION => DiscussionProduction::class,
];

if (!isset($mailableMap[$task->stageId])) {
return;
}

$mailableClass = $mailableMap[$task->stageId];

foreach ($participantIds as $userId) {
if ($userId === $sender->getId()) {
continue;
}

// clear previous "query activity" notifications for this user/query
Notification::withAssoc(PKPApplication::ASSOC_TYPE_QUERY, $task->id)
->withUserId($userId)
->withType(Notification::NOTIFICATION_TYPE_QUERY_ACTIVITY)
->withContextId((int) $context->getId())
->delete();

$recipient = Repo::user()->get($userId);
if (!$recipient) {
continue;
}

// create notification
$notification = $notificationManager->createNotification(
$userId,
Notification::NOTIFICATION_TYPE_QUERY_ACTIVITY,
(int) $context->getId(),
PKPApplication::ASSOC_TYPE_QUERY,
$task->id,
Notification::NOTIFICATION_LEVEL_TASK
);

if (!$notification) {
continue;
}

// respect email notification settings
$blocked = $notificationSubscriptionSettingsDao->getNotificationSubscriptionSettings(
NotificationSubscriptionSettingsDAO::BLOCKED_EMAIL_NOTIFICATION_KEY,
$userId,
(int) $context->getId()
);

if (in_array(Notification::NOTIFICATION_TYPE_QUERY_ACTIVITY, $blocked)) {
continue;
}

/** @var \PKP\mail\Mailable $mailable */
$mailable = new $mailableClass($context, $submission);

$mailable
->sender($sender)
->recipients([$recipient])
->subject($subject)
->body($note->contents)
->allowUnsubscribe($notification)
->allowCapturableReply(
$note->messageId,
$threadAnchorMessageId && $threadAnchorMessageId !== $note->messageId ? $threadAnchorMessageId : null,
$threadAnchorMessageId ? [$threadAnchorMessageId] : []
);

$submissionFiles->each(
fn ($item) => $mailable->attachSubmissionFile(
$item->getId(),
$item->getData('name')
)
);

Mail::send($mailable);
}
}


public function removeParticipantFromSubmissionTasks(int $submissionId, int $userId, int $contextId): void
{
$user = Repo::user()->get($userId);
Expand Down
64 changes: 64 additions & 0 deletions classes/mail/traits/CapturableReply.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

/**
* @file classes/mail/traits/CapturableReply.php
*
* Copyright (c) 2024 Simon Fraser University
* Copyright (c) 2024 John Willinsky
* Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
*
* @class CapturableReply
*
* @ingroup mail_traits
*
* @brief Mailable trait to add support for capturable replies
*/

namespace PKP\mail\traits;

use Illuminate\Mail\Mailables\Headers;
use PKP\config\Config;

trait CapturableReply
{
protected ?string $messageId = null;
protected ?string $inReplyTo = null;
protected array $references = [];

/**
* Adds headers that can be used to determine what a reply is in response to
*/
public function headers(): Headers
{
$textHeaders = [];

if ($this->inReplyTo) {
$textHeaders['In-Reply-To'] = $this->inReplyTo;
}

return new Headers(
messageId: $this->messageId ?: null,
references: $this->references,
text: $textHeaders,
);
}

public function setupCapturableReply()
{
if ($replyTo = Config::getVar('email', 'reply_to_address')) {
$this->replyTo($replyTo);
}
}

public function allowCapturableReply(
?string $messageId,
?string $inReplyTo = null,
array $references = []
): static {
$this->messageId = $messageId;
$this->inReplyTo = $inReplyTo;
$this->references = $references;

return $this;
}
}
27 changes: 25 additions & 2 deletions classes/mail/traits/Discussion.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,40 @@

namespace PKP\mail\traits;

use Illuminate\Mail\Mailables\Headers;
use PKP\mail\variables\SubmissionEmailVariable;

trait Discussion
{
use Unsubscribe {
getRequiredVariables as getTraitRequiredVariables;
use Unsubscribe, CapturableReply {
Unsubscribe::getRequiredVariables as getTraitRequiredVariables;
Unsubscribe::headers as unsubscribeHeaders;
CapturableReply::headers as capturableReplyHeaders;
}

/**
* Merge the headers from the competing traits together.
*/
public function headers(): Headers
{
$unsubscribeHeaders = $this->unsubscribeHeaders();
$capturableReplyHeaders = $this->capturableReplyHeaders();

if ($unsubscribeHeaders->messageId !== null) {
throw new \Exception('Unable to merge message IDs!');
}

return new Headers(
$capturableReplyHeaders->messageId,
array_merge($unsubscribeHeaders->references, $capturableReplyHeaders->references),
array_merge($unsubscribeHeaders->text, $capturableReplyHeaders->text)
);
}

protected function addFooter(string $locale): self
{
$this->setupUnsubscribeFooter($locale, $this->context);
$this->setupCapturableReply();
return $this;
}

Expand Down
2 changes: 2 additions & 0 deletions classes/migration/install/NotesMigration.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,12 @@ public function up(): void

$table->datetime('date_created');
$table->datetime('date_modified')->nullable();
$table->string('message_id', 255)->nullable();
$table->text('contents')->nullable();
$table->boolean('is_headnote')->default(false);

$table->index(['assoc_type', 'assoc_id'], 'notes_assoc');
$table->index(['message_id'], 'notes_message_id');
});
}

Expand Down
Loading