diff --git a/.env.example b/.env.example index d08ef75a8..8d4fa1b52 100644 --- a/.env.example +++ b/.env.example @@ -154,6 +154,7 @@ MAIL_API_OAUTH2_CLIENT_SCOPES= CFP_APP_BASE_URL= CFP_SUPPORT_EMAIL= +CFP_SPEAKER_CHANGE_NOTIFICATION_EMAIL= CFP_OAUTH2_SCOPES= CFP_OAUTH2_CLIENT_ID= # ceiling and default for an admin-granted per-presentation submission reopen window, in hours diff --git a/app/Jobs/Emails/IMailTemplatesConstants.php b/app/Jobs/Emails/IMailTemplatesConstants.php index d6a59b4d6..e11aac0ef 100644 --- a/app/Jobs/Emails/IMailTemplatesConstants.php +++ b/app/Jobs/Emails/IMailTemplatesConstants.php @@ -20,6 +20,8 @@ interface IMailTemplatesConstants { const accepted_moderated_presentations = 'accepted_moderated_presentations'; const accepted_presentations = 'accepted_presentations'; + const activity_change_action = 'activity_change_action'; + const activity_change_role = 'activity_change_role'; const admin_ticket_edit_url = 'admin_ticket_edit_url'; const alternate_moderated_presentations = 'alternate_moderated_presentations'; const alternate_presentations = 'alternate_presentations'; diff --git a/app/Jobs/Emails/Schedule/PresentationActivitySpeakerChangeEmail.php b/app/Jobs/Emails/Schedule/PresentationActivitySpeakerChangeEmail.php new file mode 100644 index 000000000..943dcc487 --- /dev/null +++ b/app/Jobs/Emails/Schedule/PresentationActivitySpeakerChangeEmail.php @@ -0,0 +1,98 @@ +getSummit(); + + $payload = []; + $payload[IMailTemplatesConstants::speaker_full_name] = $speaker->getFullName(" "); + $payload[IMailTemplatesConstants::speaker_email] = $speaker->getEmail(); + $payload[IMailTemplatesConstants::presentation_title] = $presentation->getTitle(); + $payload[IMailTemplatesConstants::presentation_id] = $presentation->getId(); + $payload[IMailTemplatesConstants::presentation_edit_link] = $presentation->getEditLink(); + $payload[IMailTemplatesConstants::activity_change_role] = $role; + $payload[IMailTemplatesConstants::activity_change_action] = $action; + + $to_email = Config::get(self::RecipientConfigKey); + if (empty($to_email)) + throw new ValidationException(sprintf('%s is not configured.', self::RecipientConfigKey)); + + parent::__construct($summit, $payload, self::DEFAULT_TEMPLATE, $to_email); + } + + /** + * @return array + */ + public static function getEmailTemplateSchema(): array{ + + $payload = parent::getEmailTemplateSchema(); + + $payload[IMailTemplatesConstants::speaker_full_name]['type'] = 'string'; + $payload[IMailTemplatesConstants::speaker_email]['type'] = 'string'; + $payload[IMailTemplatesConstants::presentation_title]['type'] = 'string'; + $payload[IMailTemplatesConstants::presentation_id]['type'] = 'int'; + $payload[IMailTemplatesConstants::presentation_edit_link]['type'] = 'string'; + $payload[IMailTemplatesConstants::activity_change_role]['type'] = 'string'; + $payload[IMailTemplatesConstants::activity_change_action]['type'] = 'string'; + + return $payload; + } +} diff --git a/app/Services/Model/Imp/Notifications/SpeakerChangeNotifications.php b/app/Services/Model/Imp/Notifications/SpeakerChangeNotifications.php new file mode 100644 index 000000000..48c425604 --- /dev/null +++ b/app/Services/Model/Imp/Notifications/SpeakerChangeNotifications.php @@ -0,0 +1,99 @@ +transaction() returns. Methods that merely + * take a collector as a parameter only ever add to it, never dispatch it. That keeps a + * notification from outliving a save that ends up rolling back, without any caller having to + * describe its own transaction nesting. + * + * Class SpeakerChangeNotifications + * @package App\Services\Model\Imp\Notifications + */ +final class SpeakerChangeNotifications +{ + /** + * @var array + */ + private $pending = []; + + /** + * @param Presentation $presentation + * @param PresentationSpeaker $speaker + * @param string $role + * @param string $action + * @return void + */ + public function add(Presentation $presentation, PresentationSpeaker $speaker, string $role, string $action): void + { + $this->pending[] = [$presentation, $speaker, $role, $action]; + } + + /** + * @return bool + */ + public function isEmpty(): bool + { + return count($this->pending) === 0; + } + + /** + * Queues everything collected so far and empties the collector. + * + * By the time this runs the caller's write is already durable, so a notification failure + * must never propagate: it would surface as an HTTP error on a request that actually + * succeeded. The recipient is optional platform config, so an unconfigured deployment + * simply logs and sends nothing, and a single failing notification never cancels the rest. + * + * @return void + */ + public function dispatch(): void + { + $pending = $this->pending; + $this->pending = []; + + if (count($pending) === 0) return; + + if (empty(Config::get(PresentationActivitySpeakerChangeEmail::RecipientConfigKey))) { + Log::warning + ( + sprintf + ( + "SpeakerChangeNotifications::dispatch %s is not configured, skipping %s speaker change notification(s).", + PresentationActivitySpeakerChangeEmail::RecipientConfigKey, + count($pending) + ) + ); + return; + } + + foreach ($pending as $notification) { + try { + PresentationActivitySpeakerChangeEmail::dispatch(...$notification); + } catch (\Exception $ex) { + Log::warning("SpeakerChangeNotifications::dispatch failed to dispatch speaker change notification."); + Log::warning($ex); + } + } + } +} diff --git a/app/Services/Model/Imp/PresentationService.php b/app/Services/Model/Imp/PresentationService.php index 4df302eea..4d7dfb56f 100644 --- a/app/Services/Model/Imp/PresentationService.php +++ b/app/Services/Model/Imp/PresentationService.php @@ -17,6 +17,7 @@ use App\Http\Utils\FileUploadInfo; use App\Http\Utils\IFileUploader; use App\Jobs\Emails\PresentationSubmissions\PresentationCreatorNotificationEmail; +use App\Jobs\Emails\Schedule\PresentationActivitySpeakerChangeEmail; use App\Models\Exceptions\AuthzException; use App\Models\Foundation\Summit\Events\Presentations\TrackChairs\PresentationTrackChairScore; use App\Models\Foundation\Summit\Events\Presentations\TrackChairs\PresentationTrackChairScoreType; @@ -33,6 +34,7 @@ use App\Services\Filesystem\FileUploadStrategyFactory; use App\Services\Model\AbstractService; use App\Services\Model\IFolderService; +use App\Services\Model\Imp\Notifications\SpeakerChangeNotifications; use Illuminate\Http\Request as LaravelRequest; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Log; @@ -1725,7 +1727,9 @@ public function processMediaUpload(int $summit_id, int $media_upload_type_id, ?s * @throws \Exception */ public function upsertPresentationSpeaker(Summit $summit, int $presentation_id, int $speaker_id, array $data): Presentation { - return $this->tx_service->transaction(function () use ($summit, $presentation_id, $speaker_id, $data) { + $notifications = new SpeakerChangeNotifications(); + + $presentation = $this->tx_service->transaction(function () use ($summit, $presentation_id, $speaker_id, $data, $notifications) { $presentation = $summit->getEvent($presentation_id); if (!$presentation instanceof Presentation) @@ -1737,6 +1741,15 @@ public function upsertPresentationSpeaker(Summit $summit, int $presentation_id, if (!$presentation->isSpeaker($speaker)) { $presentation->addSpeaker($speaker); + if ($presentation->isPublished()) { + $notifications->add + ( + $presentation, + $speaker, + PresentationActivitySpeakerChangeEmail::Role_Speaker, + PresentationActivitySpeakerChangeEmail::Action_Added + ); + } } if (isset($data['order'])) { @@ -1747,6 +1760,11 @@ public function upsertPresentationSpeaker(Summit $summit, int $presentation_id, return $presentation; }); + + // we own the collector, so nothing goes out until OUR transaction has committed + $notifications->dispatch(); + + return $presentation; } /** @@ -1758,7 +1776,9 @@ public function upsertPresentationSpeaker(Summit $summit, int $presentation_id, */ public function removeSpeakerFromPresentation(Summit $summit, int $presentation_id, int $speaker_id): void { - $this->tx_service->transaction(function () use ($summit, $presentation_id, $speaker_id) { + $notifications = new SpeakerChangeNotifications(); + + $this->tx_service->transaction(function () use ($summit, $presentation_id, $speaker_id, $notifications) { $presentation = $summit->getEvent($presentation_id); if (!$presentation instanceof Presentation) @@ -1768,8 +1788,22 @@ public function removeSpeakerFromPresentation(Summit $summit, int $presentation_ if (is_null($speaker) || !($speaker instanceof PresentationSpeaker)) throw new EntityNotFoundException("Speaker {$speaker_id} not found."); - $presentation->removeSpeaker($speaker); + if ($presentation->isSpeaker($speaker)) { + $presentation->removeSpeaker($speaker); + if ($presentation->isPublished()) { + $notifications->add + ( + $presentation, + $speaker, + PresentationActivitySpeakerChangeEmail::Role_Speaker, + PresentationActivitySpeakerChangeEmail::Action_Removed + ); + } + } }); + + // we own the collector, so nothing goes out until OUR transaction has committed + $notifications->dispatch(); } } diff --git a/app/Services/Model/Imp/SummitService.php b/app/Services/Model/Imp/SummitService.php index 155427fcd..4848e60b0 100644 --- a/app/Services/Model/Imp/SummitService.php +++ b/app/Services/Model/Imp/SummitService.php @@ -24,6 +24,7 @@ use App\Jobs\Emails\PresentationSubmissions\ImportEventSpeakerEmail; use App\Jobs\Emails\PresentationSubmissions\PresentationModeratorNotificationEmail; use App\Jobs\Emails\PresentationSubmissions\PresentationSpeakerNotificationEmail; +use App\Jobs\Emails\Schedule\PresentationActivitySpeakerChangeEmail; use App\Jobs\Emails\Schedule\ShareEventEmail; use App\Jobs\EncryptAllSummitBadgeQRCodes; use App\Jobs\ProcessEventDataImport; @@ -52,6 +53,7 @@ use App\Services\FileSystem\IFileUploadStrategy; use App\Services\Model\AbstractPublishService; use App\Services\Model\IMemberService; +use App\Services\Model\Imp\Notifications\SpeakerChangeNotifications; use App\Services\Utils\Security\IEncryptionAES256KeysGenerator; use DateInterval; use DateTime; @@ -608,7 +610,11 @@ public function deleteMyEventFeedback(Member $member, Summit $summit, int $event */ public function addEvent(Summit $summit, array $data) { - return $this->saveOrUpdateEvent($summit, $data, null); + $notifications = new SpeakerChangeNotifications(); + $event = $this->saveOrUpdateEvent($summit, $data, $notifications, null); + // we own the collector, so our transaction() above was the outermost one + $notifications->dispatch(); + return $event; } /** @@ -619,7 +625,11 @@ public function addEvent(Summit $summit, array $data) */ public function updateEvent(Summit $summit, $event_id, array $data, bool $trigger_data_update = true, bool $saveAsIncomplete = false) { - return $this->saveOrUpdateEvent($summit, $data, $event_id, $trigger_data_update, $saveAsIncomplete); + $notifications = new SpeakerChangeNotifications(); + $event = $this->saveOrUpdateEvent($summit, $data, $notifications, $event_id, $trigger_data_update, $saveAsIncomplete); + // we own the collector, so our transaction() above was the outermost one + $notifications->dispatch(); + return $event; } /** @@ -660,9 +670,9 @@ private function canPerformEventTypeTransition(SummitEventType $old_event_type, * @return SummitEvent * @throws Exception */ - private function saveOrUpdateEvent(Summit $summit, array $data, $event_id = null, bool $trigger_data_update = true, bool $saveAsIncomplete = false) + private function saveOrUpdateEvent(Summit $summit, array $data, SpeakerChangeNotifications $notifications, $event_id = null, bool $trigger_data_update = true, bool $saveAsIncomplete = false) { - return $this->tx_service->transaction(function () use ($summit, $data, $event_id, $trigger_data_update, $saveAsIncomplete) { + return $this->tx_service->transaction(function () use ($summit, $data, $notifications, $event_id, $trigger_data_update, $saveAsIncomplete) { Log::debug ( @@ -833,7 +843,7 @@ private function saveOrUpdateEvent(Summit $summit, array $data, $event_id = null } } - $this->saveOrUpdatePresentationData($event, $event_type, $data, $saveAsIncomplete); + $this->saveOrUpdatePresentationData($event, $event_type, $data, $notifications, $saveAsIncomplete); $this->saveOrUpdateSummitGroupEventData($event, $event_type, $data); if (!$event_type->isAllowsLocation()) @@ -882,11 +892,14 @@ private function saveOrUpdateSummitGroupEventData(SummitEvent $event, SummitEven * @param SummitEvent $event * @param SummitEventType $event_type * @param array $data + * @param SpeakerChangeNotifications $notifications collector this method only ever ADDS to; + * dispatching it belongs to whoever constructed it, after its own transaction commits. * @param bool $saveAsIncomplete + * @return void * @throws EntityNotFoundException * @throws ValidationException */ - private function saveOrUpdatePresentationData(SummitEvent $event, SummitEventType $event_type, array $data, bool $saveAsIncomplete = false) + private function saveOrUpdatePresentationData(SummitEvent $event, SummitEventType $event_type, array $data, SpeakerChangeNotifications $notifications, bool $saveAsIncomplete = false): void { if (!$event instanceof Presentation) return; @@ -894,6 +907,13 @@ private function saveOrUpdatePresentationData(SummitEvent $event, SummitEventTyp if ($saveAsIncomplete && $event->isPublished()) throw new ValidationException('Cannot save a published event as incomplete.'); + // captured before any mutation below: a published presentation being edited is a + // "change", not a creation, so notifications are only considered when this was + // already true on entry + $was_published = $event->isPublished(); + $old_speaker_ids = array_map(fn(PresentationSpeaker $s) => $s->getId(), $event->getSpeakers()->toArray()); + $old_moderator = $event->hasModerator() ? $event->getModerator() : null; + if (!$saveAsIncomplete || $event->isNew()) { // if we are creating the presentation from admin, then // we should mark it as received and complete @@ -929,6 +949,28 @@ private function saveOrUpdatePresentationData(SummitEvent $event, SummitEventTyp $event->addSpeaker($speaker); } } + + if ($was_published) { + $new_speaker_ids = array_map(fn(PresentationSpeaker $s) => $s->getId(), $event->getSpeakers()->toArray()); + foreach (array_diff($new_speaker_ids, $old_speaker_ids) as $added_id) { + $notifications->add + ( + $event, + $this->speaker_repository->getById($added_id), + PresentationActivitySpeakerChangeEmail::Role_Speaker, + PresentationActivitySpeakerChangeEmail::Action_Added + ); + } + foreach (array_diff($old_speaker_ids, $new_speaker_ids) as $removed_id) { + $notifications->add + ( + $event, + $this->speaker_repository->getById($removed_id), + PresentationActivitySpeakerChangeEmail::Role_Speaker, + PresentationActivitySpeakerChangeEmail::Action_Removed + ); + } + } } // moderator @@ -952,6 +994,33 @@ private function saveOrUpdatePresentationData(SummitEvent $event, SummitEventTyp throw new EntityNotFoundException(sprintf('Moderator %s not found', $moderator_id)); $event->setModerator($moderator); } + + if ($was_published) { + $new_moderator = $event->hasModerator() ? $event->getModerator() : null; + $old_moderator_id = is_null($old_moderator) ? null : $old_moderator->getId(); + $new_moderator_id = is_null($new_moderator) ? null : $new_moderator->getId(); + + if ($old_moderator_id !== $new_moderator_id) { + if (!is_null($old_moderator)) { + $notifications->add + ( + $event, + $old_moderator, + PresentationActivitySpeakerChangeEmail::Role_Moderator, + PresentationActivitySpeakerChangeEmail::Action_Removed + ); + } + if (!is_null($new_moderator)) { + $notifications->add + ( + $event, + $new_moderator, + PresentationActivitySpeakerChangeEmail::Role_Moderator, + PresentationActivitySpeakerChangeEmail::Action_Added + ); + } + } + } } PresentationFactory::populate($event, $data, true); @@ -1487,17 +1556,27 @@ public function unPublishEvents(Summit $summit, array $data) */ public function updateAndPublishEvents(Summit $summit, array $data) { - return $this->tx_service->transaction(function () use ( + $notifications = new SpeakerChangeNotifications(); + + $result = $this->tx_service->transaction(function () use ( $summit, - $data + $data, + $notifications ) { foreach ($data['events'] as $event_data) { - $this->updateEvent($summit, intval($event_data['id']), $event_data); + // saveOrUpdateEvent directly, not updateEvent: updateEvent owns its own collector + // and would dispatch while this outer transaction is still open + $this->saveOrUpdateEvent($summit, $event_data, $notifications, intval($event_data['id'])); $this->publishEvent($summit, intval($event_data['id']), $event_data); } return true; }); + + // we own the collector, so nothing goes out until OUR transaction has committed + $notifications->dispatch(); + + return $result; } /** @@ -1510,17 +1589,27 @@ public function updateAndPublishEvents(Summit $summit, array $data) */ public function updateEvents(Summit $summit, array $data, bool $trigger_data_update = true) { - return $this->tx_service->transaction(function () use ( + $notifications = new SpeakerChangeNotifications(); + + $result = $this->tx_service->transaction(function () use ( $summit, $data, - $trigger_data_update + $trigger_data_update, + $notifications ) { foreach ($data['events'] as $event_data) { - $this->updateEvent($summit, intval($event_data['id']), $event_data, $trigger_data_update); + // saveOrUpdateEvent directly, not updateEvent: updateEvent owns its own collector + // and would dispatch while this outer transaction is still open + $this->saveOrUpdateEvent($summit, $event_data, $notifications, intval($event_data['id']), $trigger_data_update); } return true; }); + + // we own the collector, so nothing goes out until OUR transaction has committed + $notifications->dispatch(); + + return $result; } /** @@ -1732,7 +1821,9 @@ public function deleteSummit($summit_id) */ public function addSpeaker2Presentation(int $current_member_id, int $speaker_id, int $presentation_id): Presentation { - return $this->tx_service->transaction(function () use ($current_member_id, $speaker_id, $presentation_id) { + $notifications = new SpeakerChangeNotifications(); + + $presentation = $this->tx_service->transaction(function () use ($current_member_id, $speaker_id, $presentation_id, $notifications) { $current_member = $this->member_repository->getById($current_member_id); if (is_null($current_member) || !($current_member instanceof Member)) throw new EntityNotFoundException(sprintf("Member %s not found.", $current_member_id)); @@ -1760,7 +1851,18 @@ public function addSpeaker2Presentation(int $current_member_id, int $speaker_id, if (!$presentation->isCompleted()) $presentation->setProgress(Presentation::PHASE_SPEAKERS); - $presentation->addSpeaker($speaker); + if (!$presentation->isSpeaker($speaker)) { + $presentation->addSpeaker($speaker); + if ($presentation->isPublished()) { + $notifications->add + ( + $presentation, + $speaker, + PresentationActivitySpeakerChangeEmail::Role_Speaker, + PresentationActivitySpeakerChangeEmail::Action_Added + ); + } + } // check is selection plan is private, if so add moderator to allowed members @@ -1775,6 +1877,11 @@ public function addSpeaker2Presentation(int $current_member_id, int $speaker_id, return $presentation; }); + + // we own the collector, so nothing goes out until OUR transaction has committed + $notifications->dispatch(); + + return $presentation; } /** @@ -1787,7 +1894,9 @@ public function addSpeaker2Presentation(int $current_member_id, int $speaker_id, */ public function removeSpeakerFromPresentation(int $current_member_id, int $speaker_id, int $presentation_id): Presentation { - return $this->tx_service->transaction(function () use ($current_member_id, $speaker_id, $presentation_id) { + $notifications = new SpeakerChangeNotifications(); + + $presentation = $this->tx_service->transaction(function () use ($current_member_id, $speaker_id, $presentation_id, $notifications) { $current_member = $this->member_repository->getById($current_member_id); if (is_null($current_member) || !($current_member instanceof Member)) @@ -1817,10 +1926,26 @@ public function removeSpeakerFromPresentation(int $current_member_id, int $speak if (!$presentation->isCompleted()) $presentation->setProgress(Presentation::PHASE_SPEAKERS); - $presentation->removeSpeaker($speaker); + if ($presentation->isSpeaker($speaker)) { + $presentation->removeSpeaker($speaker); + if ($presentation->isPublished()) { + $notifications->add + ( + $presentation, + $speaker, + PresentationActivitySpeakerChangeEmail::Role_Speaker, + PresentationActivitySpeakerChangeEmail::Action_Removed + ); + } + } return $presentation; }); + + // we own the collector, so nothing goes out until OUR transaction has committed + $notifications->dispatch(); + + return $presentation; } /** @@ -1833,7 +1958,9 @@ public function removeSpeakerFromPresentation(int $current_member_id, int $speak */ public function addModerator2Presentation(int $current_member_id, int $speaker_id, int $presentation_id): Presentation { - return $this->tx_service->transaction(function () use ($current_member_id, $speaker_id, $presentation_id) { + $notifications = new SpeakerChangeNotifications(); + + $presentation = $this->tx_service->transaction(function () use ($current_member_id, $speaker_id, $presentation_id, $notifications) { $current_member = $this->member_repository->getById($current_member_id); if (is_null($current_member) || !($current_member instanceof Member)) throw new EntityNotFoundException(sprintf("Member %s not found.", $current_member_id)); @@ -1862,8 +1989,30 @@ public function addModerator2Presentation(int $current_member_id, int $speaker_i if (!$presentation->isCompleted()) $presentation->setProgress(Presentation::PHASE_SPEAKERS); + $previous_moderator = $presentation->hasModerator() ? $presentation->getModerator() : null; + $previous_moderator_id = is_null($previous_moderator) ? null : $previous_moderator->getId(); + $presentation->setModerator($speaker); + if ($presentation->isPublished() && $previous_moderator_id !== $speaker->getId()) { + if (!is_null($previous_moderator)) { + $notifications->add + ( + $presentation, + $previous_moderator, + PresentationActivitySpeakerChangeEmail::Role_Moderator, + PresentationActivitySpeakerChangeEmail::Action_Removed + ); + } + $notifications->add + ( + $presentation, + $speaker, + PresentationActivitySpeakerChangeEmail::Role_Moderator, + PresentationActivitySpeakerChangeEmail::Action_Added + ); + } + // check is selection plan is private, if so add moderator to allowed members $selection_plan = $presentation->getSelectionPlan(); @@ -1877,6 +2026,11 @@ public function addModerator2Presentation(int $current_member_id, int $speaker_i return $presentation; }); + + // we own the collector, so nothing goes out until OUR transaction has committed + $notifications->dispatch(); + + return $presentation; } /** @@ -1889,7 +2043,9 @@ public function addModerator2Presentation(int $current_member_id, int $speaker_i */ public function removeModeratorFromPresentation(int $current_member_id, int $speaker_id, int $presentation_id): Presentation { - return $this->tx_service->transaction(function () use ($current_member_id, $speaker_id, $presentation_id) { + $notifications = new SpeakerChangeNotifications(); + + $presentation = $this->tx_service->transaction(function () use ($current_member_id, $speaker_id, $presentation_id, $notifications) { $current_member = $this->member_repository->getById($current_member_id); if (is_null($current_member) || !($current_member instanceof Member)) @@ -1919,10 +2075,27 @@ public function removeModeratorFromPresentation(int $current_member_id, int $spe if (!$presentation->isCompleted()) $presentation->setProgress(Presentation::PHASE_SPEAKERS); + $previous_moderator = $presentation->hasModerator() ? $presentation->getModerator() : null; + $presentation->unsetModerator(); + if (!is_null($previous_moderator) && $presentation->isPublished()) { + $notifications->add + ( + $presentation, + $previous_moderator, + PresentationActivitySpeakerChangeEmail::Role_Moderator, + PresentationActivitySpeakerChangeEmail::Action_Removed + ); + } + return $presentation; }); + + // we own the collector, so nothing goes out until OUR transaction has committed + $notifications->dispatch(); + + return $presentation; } /** diff --git a/config/cfp.php b/config/cfp.php index 54b8dc9ea..9f866e840 100644 --- a/config/cfp.php +++ b/config/cfp.php @@ -15,6 +15,7 @@ return [ 'base_url' => env('CFP_APP_BASE_URL', null), 'support_email' => env('CFP_SUPPORT_EMAIL', null), + 'speaker_change_notification_email' => env('CFP_SPEAKER_CHANGE_NOTIFICATION_EMAIL', null), 'client_id' => env('CFP_OAUTH2_CLIENT_ID', null), 'scopes' => env('CFP_OAUTH2_SCOPES', null), diff --git a/tests/Unit/Jobs/PresentationActivitySpeakerChangeEmailTest.php b/tests/Unit/Jobs/PresentationActivitySpeakerChangeEmailTest.php new file mode 100644 index 000000000..15e438ccf --- /dev/null +++ b/tests/Unit/Jobs/PresentationActivitySpeakerChangeEmailTest.php @@ -0,0 +1,68 @@ +expectException(\InvalidArgumentException::class); + + new PresentationActivitySpeakerChangeEmail( + new Presentation(), + new PresentationSpeaker(), + 'NotARole', + 'Added' + ); + } + + public function testConstructorRejectsInvalidAction(): void + { + $this->expectException(\InvalidArgumentException::class); + + new PresentationActivitySpeakerChangeEmail( + new Presentation(), + new PresentationSpeaker(), + 'Speaker', + 'NotAnAction' + ); + } + + public function testConstructorThrowsWhenRecipientNotConfigured(): void + { + Config::set('cfp.speaker_change_notification_email', null); + + $this->expectException(ValidationException::class); + + $presentation = new Presentation(); + $presentation->setTitle('Test Presentation'); + + new PresentationActivitySpeakerChangeEmail( + $presentation, + new PresentationSpeaker(), + 'Speaker', + 'Added' + ); + } +} diff --git a/tests/oauth2/OAuth2PresentationApiTest.php b/tests/oauth2/OAuth2PresentationApiTest.php index cf1683360..1582dc6ec 100644 --- a/tests/oauth2/OAuth2PresentationApiTest.php +++ b/tests/oauth2/OAuth2PresentationApiTest.php @@ -12,9 +12,14 @@ * See the License for the specific language governing permissions and * limitations under the License. **/ +use App\Jobs\Emails\Schedule\PresentationActivitySpeakerChangeEmail; use App\Models\Foundation\Main\IGroup; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\Queue; +use LaravelDoctrine\ORM\Facades\Registry; +use models\summit\Presentation; +use models\utils\SilverstripeBaseModel; /** * Class OAuth2PresentationApiTest */ @@ -36,6 +41,7 @@ protected function setUp(): void self::$current_track_chair = self::$summit->addTrackChair(self::$member, [ self::$defaultTrack ] ); self::$em->persist(self::$summit); self::$em->flush(); + Config::set('cfp.speaker_change_notification_email', 'speaker-changes@test.com'); } protected function tearDown(): void @@ -1249,6 +1255,184 @@ public function testUpdateSpeakerInPresentation() $this->assertResponseStatus(201); } + public function testAddSpeaker2PresentationQueuesChangeNotificationOnPublishedPresentation() + { + $presentation = self::$default_selection_plan->getPresentations()[0]; + $this->assertTrue($presentation->isPublished()); + + $params = [ + 'id' => self::$summit->getId(), + 'presentation_id' => $presentation->getId(), + 'speaker_id' => self::$speaker->getId(), + ]; + + Queue::fake(); + + $response = $this->action( + "POST", + "OAuth2PresentationApiController@addSpeaker2Presentation", + $params, + [], + [], + [], + $this->getAuthHeaders(), + json_encode(['order' => 1]) + ); + + $this->assertResponseStatus(201); + Queue::assertPushed(PresentationActivitySpeakerChangeEmail::class, 1); + + // re-adding the same (already assigned) speaker is an order-only update: no additional email + $response = $this->action( + "POST", + "OAuth2PresentationApiController@addSpeaker2Presentation", + $params, + [], + [], + [], + $this->getAuthHeaders(), + json_encode(['order' => 1]) + ); + + $this->assertResponseStatus(201); + Queue::assertPushed(PresentationActivitySpeakerChangeEmail::class, 1); + } + + public function testAddSpeaker2PresentationSucceedsWhenNotificationRecipientNotConfigured() + { + // the recipient is optional platform config: an unconfigured deployment must still + // apply the speaker change, since the write has already committed by the time the + // notification is dispatched. + Config::set(PresentationActivitySpeakerChangeEmail::RecipientConfigKey, null); + + $presentation = self::$default_selection_plan->getPresentations()[0]; + $this->assertTrue($presentation->isPublished()); + + $params = [ + 'id' => self::$summit->getId(), + 'presentation_id' => $presentation->getId(), + 'speaker_id' => self::$speaker->getId(), + ]; + + Queue::fake(); + + $this->action( + "POST", + "OAuth2PresentationApiController@addSpeaker2Presentation", + $params, + [], + [], + [], + $this->getAuthHeaders(), + json_encode(['order' => 1]) + ); + + $this->assertResponseStatus(201); + Queue::assertNotPushed(PresentationActivitySpeakerChangeEmail::class); + + // the save must have really landed: re-read through a current entity manager, since a + // transaction-level reset during the request leaves the fixture's static one stale + $em = Registry::getManager(SilverstripeBaseModel::EntityManager); + $persisted = $em->find(Presentation::class, $presentation->getId()); + $this->assertTrue($persisted->isSpeaker(self::$speaker)); + } + + public function testRemoveSpeakerFromPresentationQueuesChangeNotificationOnPublishedPresentation() + { + $presentation = self::$default_selection_plan->getPresentations()[0]; + $this->assertTrue($presentation->isPublished()); + + $params = [ + 'id' => self::$summit->getId(), + 'presentation_id' => $presentation->getId(), + 'speaker_id' => self::$speaker->getId(), + ]; + + // ensure the speaker is actually assigned first (outside the fake queue window) + $response = $this->action( + "POST", + "OAuth2PresentationApiController@addSpeaker2Presentation", + $params, + [], + [], + [], + $this->getAuthHeaders(), + json_encode(['order' => 1]) + ); + $this->assertResponseStatus(201); + + Queue::fake(); + + $response = $this->action( + "DELETE", + "OAuth2PresentationApiController@removeSpeakerFromPresentation", + $params, + [], + [], + [], + $this->getAuthHeaders() + ); + + $this->assertResponseStatus(204); + Queue::assertPushed(PresentationActivitySpeakerChangeEmail::class, 1); + + // removing an already-removed (not assigned) speaker queues nothing additional + $response = $this->action( + "DELETE", + "OAuth2PresentationApiController@removeSpeakerFromPresentation", + $params, + [], + [], + [], + $this->getAuthHeaders() + ); + + $this->assertResponseStatus(204); + Queue::assertPushed(PresentationActivitySpeakerChangeEmail::class, 1); + } + + public function testSpeakerChangeEndpointsDoNotQueueChangeNotificationOnNonPublishedPresentation() + { + $presentation = self::$default_selection_plan->getPresentations()[1]; + $presentation->unPublish(); + self::$em->persist($presentation); + self::$em->flush(); + $this->assertFalse($presentation->isPublished()); + + $params = [ + 'id' => self::$summit->getId(), + 'presentation_id' => $presentation->getId(), + 'speaker_id' => self::$speaker->getId(), + ]; + + Queue::fake(); + + $response = $this->action( + "POST", + "OAuth2PresentationApiController@addSpeaker2Presentation", + $params, + [], + [], + [], + $this->getAuthHeaders(), + json_encode(['order' => 1]) + ); + $this->assertResponseStatus(201); + + $response = $this->action( + "DELETE", + "OAuth2PresentationApiController@removeSpeakerFromPresentation", + $params, + [], + [], + [], + $this->getAuthHeaders() + ); + $this->assertResponseStatus(204); + + Queue::assertNotPushed(PresentationActivitySpeakerChangeEmail::class); + } + // --- Comments --- public function testGetComment() diff --git a/tests/oauth2/OAuth2SummitEventsApiTest.php b/tests/oauth2/OAuth2SummitEventsApiTest.php index f00452cc2..79674c39d 100644 --- a/tests/oauth2/OAuth2SummitEventsApiTest.php +++ b/tests/oauth2/OAuth2SummitEventsApiTest.php @@ -11,10 +11,16 @@ * See the License for the specific language governing permissions and * limitations under the License. **/ +use App\Jobs\Emails\IMailTemplatesConstants; +use App\Jobs\Emails\Schedule\PresentationActivitySpeakerChangeEmail; use App\Models\Foundation\Main\IGroup; use App\Services\Model\ISummitService; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\App; +use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\Queue; +use models\summit\PresentationSpeaker; +use models\summit\PresentationType; use models\utils\SilverstripeBaseModel; use services\model\IPresentationService; use models\summit\Presentation; @@ -35,10 +41,29 @@ protected function setUp():void self::$defaultMember2 = self::$member2; self::insertSummitTestData(); self::InsertOrdersTestData(); + Config::set('cfp.speaker_change_notification_email', 'speaker-changes@test.com'); + } + + private function collectDispatchedChangeActions(): array + { + $actions = []; + Queue::assertPushed(PresentationActivitySpeakerChangeEmail::class, function ($job) use (&$actions) { + $ref = new \ReflectionClass($job); + $prop = $ref->getProperty('payload'); + $prop->setAccessible(true); + $payload = $prop->getValue($job); + $actions[] = [ + 'role' => $payload[IMailTemplatesConstants::activity_change_role], + 'action' => $payload[IMailTemplatesConstants::activity_change_action], + ]; + return true; + }); + return $actions; } public function tearDown():void { + \Mockery::close(); self::clearOrdersTestData(); self::clearSummitTestData(); parent::tearDown(); @@ -480,6 +505,316 @@ public function testUpdateEvent() } + public function testUpdateEventSpeakersQueuesChangeNotificationOnPublishedPresentation() + { + $presentation = self::$summit->getPresentations()[0]; + $this->assertTrue($presentation->isPublished()); + $old_speaker_id = self::$defaultSpeaker->getId(); + + $newSpeaker = new PresentationSpeaker(); + $newSpeaker->setFirstName('New'); + $newSpeaker->setLastName('Speaker'); + $newSpeaker->setBio('New speaker bio'); + self::$em->persist($newSpeaker); + self::$em->flush(); + + $params = [ + 'id' => self::$summit->getId(), + 'event_id' => $presentation->getId(), + ]; + + $data = [ + 'speakers' => [$newSpeaker->getId()], + ]; + + Queue::fake(); + + $response = $this->action( + "PUT", + "OAuth2SummitEventsApiController@updateEvent", + $params, + [], + [], + [], + $this->getAuthHeaders(), + json_encode($data) + ); + + $this->assertResponseStatus(200); + + $actions = $this->collectDispatchedChangeActions(); + $this->assertCount(2, $actions); + $this->assertContains(['role' => 'Speaker', 'action' => 'Added'], $actions); + $this->assertContains(['role' => 'Speaker', 'action' => 'Removed'], $actions); + + // re-saving the identical speakers array queues nothing additional + Queue::fake(); + + $response = $this->action( + "PUT", + "OAuth2SummitEventsApiController@updateEvent", + $params, + [], + [], + [], + $this->getAuthHeaders(), + json_encode($data) + ); + + $this->assertResponseStatus(200); + Queue::assertNotPushed(PresentationActivitySpeakerChangeEmail::class); + } + + public function testUpdateEventModeratorQueuesChangeNotificationOnPublishedPresentation() + { + $moderatorType = new PresentationType(); + $moderatorType->setType('TEST MODERATED PRESENTATION TYPE ' . str_random(8)); + $moderatorType->setMinSpeakers(1); + $moderatorType->setMaxSpeakers(3); + $moderatorType->setMinModerators(0); + $moderatorType->setMaxModerators(1); + $moderatorType->setUseSpeakers(true); + $moderatorType->setShouldBeAvailableOnCfp(true); + $moderatorType->setAreSpeakersMandatory(false); + $moderatorType->setUseModerator(true); + $moderatorType->setIsModeratorMandatory(false); + $moderatorType->setAllowsLocationTimeframeCollision(true); + $moderatorType->setAllowsSpeakerEventCollision(true); + $moderatorType->setBlackoutTimes('Final'); + self::$summit->addEventType($moderatorType); + self::$em->persist(self::$summit); + self::$em->flush(); + + $oldModerator = new PresentationSpeaker(); + $oldModerator->setFirstName('Old'); + $oldModerator->setLastName('Moderator'); + $oldModerator->setBio('Old moderator bio'); + self::$em->persist($oldModerator); + + $newModerator = new PresentationSpeaker(); + $newModerator->setFirstName('New'); + $newModerator->setLastName('Moderator'); + $newModerator->setBio('New moderator bio'); + self::$em->persist($newModerator); + self::$em->flush(); + + $start_date = new \DateTime('now', new \DateTimeZone('UTC')); + $end_date = (clone $start_date)->add(new \DateInterval('PT1H')); + + $presentation = new Presentation(); + self::$summit->addEvent($presentation); + $presentation->setTitle('Moderated Presentation ' . str_random(8)); + $presentation->setAbstract('Moderated presentation abstract'); + $presentation->setCategory(self::$defaultTrack); + $presentation->setType($moderatorType); + $presentation->setProgress(Presentation::PHASE_COMPLETE); + $presentation->setStatus(Presentation::STATUS_RECEIVED); + $presentation->setStartDate($start_date); + $presentation->setEndDate($end_date); + $presentation->addSpeaker(self::$defaultSpeaker); + $presentation->setModerator($oldModerator); + self::$em->persist($presentation); + self::$em->flush(); + $presentation->publish(); + self::$em->persist($presentation); + self::$em->flush(); + + $this->assertTrue($presentation->isPublished()); + + $params = [ + 'id' => self::$summit->getId(), + 'event_id' => $presentation->getId(), + ]; + + $data = [ + 'moderator_speaker_id' => $newModerator->getId(), + ]; + + Queue::fake(); + + $response = $this->action( + "PUT", + "OAuth2SummitEventsApiController@updateEvent", + $params, + [], + [], + [], + $this->getAuthHeaders(), + json_encode($data) + ); + + $this->assertResponseStatus(200); + + $actions = $this->collectDispatchedChangeActions(); + $this->assertCount(2, $actions); + $this->assertContains(['role' => 'Moderator', 'action' => 'Added'], $actions); + $this->assertContains(['role' => 'Moderator', 'action' => 'Removed'], $actions); + } + + public function testUpdateEventSpeakersDoNotQueueChangeNotificationOnNonPublishedPresentation() + { + $presentation = self::$summit->getPresentations()[2]; + $presentation->unPublish(); + self::$em->persist($presentation); + self::$em->flush(); + $this->assertFalse($presentation->isPublished()); + + $newSpeaker = new PresentationSpeaker(); + $newSpeaker->setFirstName('New'); + $newSpeaker->setLastName('SpeakerNotPublished'); + $newSpeaker->setBio('New speaker bio'); + self::$em->persist($newSpeaker); + self::$em->flush(); + + $params = [ + 'id' => self::$summit->getId(), + 'event_id' => $presentation->getId(), + ]; + + $data = [ + 'speakers' => [$newSpeaker->getId()], + ]; + + Queue::fake(); + + $response = $this->action( + "PUT", + "OAuth2SummitEventsApiController@updateEvent", + $params, + [], + [], + [], + $this->getAuthHeaders(), + json_encode($data) + ); + + $this->assertResponseStatus(200); + Queue::assertNotPushed(PresentationActivitySpeakerChangeEmail::class); + } + + public function testUpdateEventModeratorDoesNotQueueChangeNotificationOnNonPublishedPresentation() + { + $moderatorType = new PresentationType(); + $moderatorType->setType('TEST MODERATED PRESENTATION TYPE NOT PUBLISHED ' . str_random(8)); + $moderatorType->setMinSpeakers(1); + $moderatorType->setMaxSpeakers(3); + $moderatorType->setMinModerators(0); + $moderatorType->setMaxModerators(1); + $moderatorType->setUseSpeakers(true); + $moderatorType->setShouldBeAvailableOnCfp(true); + $moderatorType->setAreSpeakersMandatory(false); + $moderatorType->setUseModerator(true); + $moderatorType->setIsModeratorMandatory(false); + $moderatorType->setAllowsLocationTimeframeCollision(true); + $moderatorType->setAllowsSpeakerEventCollision(true); + $moderatorType->setBlackoutTimes('Final'); + self::$summit->addEventType($moderatorType); + self::$em->persist(self::$summit); + self::$em->flush(); + + $oldModerator = new PresentationSpeaker(); + $oldModerator->setFirstName('Old'); + $oldModerator->setLastName('ModeratorNotPublished'); + $oldModerator->setBio('Old moderator bio'); + self::$em->persist($oldModerator); + + $newModerator = new PresentationSpeaker(); + $newModerator->setFirstName('New'); + $newModerator->setLastName('ModeratorNotPublished'); + $newModerator->setBio('New moderator bio'); + self::$em->persist($newModerator); + self::$em->flush(); + + $presentation = new Presentation(); + self::$summit->addEvent($presentation); + $presentation->setTitle('Non-Published Moderated Presentation ' . str_random(8)); + $presentation->setAbstract('Non-published moderated presentation abstract'); + $presentation->setCategory(self::$defaultTrack); + $presentation->setType($moderatorType); + $presentation->setProgress(Presentation::PHASE_COMPLETE); + $presentation->setStatus(Presentation::STATUS_RECEIVED); + $presentation->addSpeaker(self::$defaultSpeaker); + $presentation->setModerator($oldModerator); + self::$em->persist($presentation); + self::$em->flush(); + + $this->assertFalse($presentation->isPublished()); + + $params = [ + 'id' => self::$summit->getId(), + 'event_id' => $presentation->getId(), + ]; + + $data = [ + 'moderator_speaker_id' => $newModerator->getId(), + ]; + + Queue::fake(); + + $response = $this->action( + "PUT", + "OAuth2SummitEventsApiController@updateEvent", + $params, + [], + [], + [], + $this->getAuthHeaders(), + json_encode($data) + ); + + $this->assertResponseStatus(200); + Queue::assertNotPushed(PresentationActivitySpeakerChangeEmail::class); + } + + public function testUpdateEventsRollsBackWithoutQueuingChangeNotificationsWhenABatchMemberFails() + { + $presentation = self::$summit->getPresentations()[3]; + $this->assertTrue($presentation->isPublished()); + + $newSpeaker = new PresentationSpeaker(); + $newSpeaker->setFirstName('New'); + $newSpeaker->setLastName('SpeakerBulkRollback'); + $newSpeaker->setBio('New speaker bio'); + self::$em->persist($newSpeaker); + self::$em->flush(); + + $otherPresentation = self::$summit->getPresentations()[4]; + + $data = [ + 'events' => [ + [ + 'id' => $presentation->getId(), + 'speakers' => [$newSpeaker->getId()], + ], + [ + // existing event, but a nonexistent track_id: SummitService::saveOrUpdateEvent + // throws a clean EntityNotFoundException here, forcing the whole bulk + // transaction to roll back + 'id' => $otherPresentation->getId(), + 'track_id' => 999999999, + ], + ], + ]; + + Queue::fake(); + + $mock_context = \Mockery::mock(\models\oauth2\IResourceServerContext::class); + $mock_context->shouldReceive('getCurrentUser')->andReturn(self::$defaultMember2); + $this->app->instance('resource_server_context', $mock_context); + + $service = App::make(\services\model\ISummitService::class); + + $threw = false; + try { + $service->updateEvents(self::$summit, $data, false); + } catch (\Exception $ex) { + $threw = true; + } + + $this->assertTrue($threw, 'updateEvents was expected to throw for the nonexistent track_id in the batch.'); + Queue::assertNotPushed(PresentationActivitySpeakerChangeEmail::class); + } + public function testUpdateDraftEventDoesNotCompleteIncompletePresentation() { $presentation = new Presentation(); diff --git a/tests/oauth2/OAuth2SummitSpeakersApiTest.php b/tests/oauth2/OAuth2SummitSpeakersApiTest.php index 5f0469753..606a22a69 100644 --- a/tests/oauth2/OAuth2SummitSpeakersApiTest.php +++ b/tests/oauth2/OAuth2SummitSpeakersApiTest.php @@ -11,11 +11,14 @@ * See the License for the specific language governing permissions and * limitations under the License. **/ +use App\Jobs\Emails\IMailTemplatesConstants; +use App\Jobs\Emails\Schedule\PresentationActivitySpeakerChangeEmail; use App\Models\Foundation\Main\IGroup; use App\Models\Foundation\Summit\Speakers\SpeakerEditPermissionRequest; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Date; +use Illuminate\Support\Facades\Queue; use LaravelDoctrine\ORM\Facades\EntityManager; use models\summit\Presentation; use models\summit\PresentationSpeaker; @@ -35,6 +38,24 @@ protected function setUp(): void self::insertSummitTestData(); // Clean up stale edit permission requests from previous test runs/methods self::$em->getConnection()->executeStatement('DELETE FROM SpeakerEditPermissionRequest'); + Config::set('cfp.speaker_change_notification_email', 'speaker-changes@test.com'); + } + + private function collectDispatchedChangeActions(): array + { + $actions = []; + Queue::assertPushed(PresentationActivitySpeakerChangeEmail::class, function ($job) use (&$actions) { + $ref = new \ReflectionClass($job); + $prop = $ref->getProperty('payload'); + $prop->setAccessible(true); + $payload = $prop->getValue($job); + $actions[] = [ + 'role' => $payload[IMailTemplatesConstants::activity_change_role], + 'action' => $payload[IMailTemplatesConstants::activity_change_action], + ]; + return true; + }); + return $actions; } protected function tearDown(): void @@ -1764,6 +1785,106 @@ public function testRemoveSpeakerFromMyPresentation() $this->assertResponseStatus(204); } + public function testAddSpeakerToMyPresentationQueuesChangeNotificationOnPublishedPresentation() + { + $presentation = self::$presentations[0]; + $this->assertTrue($presentation->isPublished()); + + $new_speaker = new PresentationSpeaker(); + $new_speaker->setFirstName('New'); + $new_speaker->setLastName('SpeakerSelfService'); + $new_speaker->setBio('New speaker bio'); + self::$em->persist($new_speaker); + self::$em->flush(); + + $presentation_id = $presentation->getId(); + $speaker_id = $new_speaker->getId(); + self::$em->clear(); + + $headers = [ + "HTTP_Authorization" => " Bearer " . $this->access_token, + "CONTENT_TYPE" => "application/json" + ]; + + $params = [ + 'presentation_id' => $presentation_id, + 'speaker_id' => $speaker_id, + ]; + + Queue::fake(); + + $response = $this->action( + "PUT", + "OAuth2SummitSpeakersApiController@addSpeakerToMyPresentation", + $params, + [], + [], + [], + $headers + ); + + $this->assertResponseStatus(201); + Queue::assertPushed(PresentationActivitySpeakerChangeEmail::class, 1); + + // re-adding the same (already assigned) speaker queues nothing additional + $response = $this->action( + "PUT", + "OAuth2SummitSpeakersApiController@addSpeakerToMyPresentation", + $params, + [], + [], + [], + $headers + ); + + $this->assertResponseStatus(201); + Queue::assertPushed(PresentationActivitySpeakerChangeEmail::class, 1); + } + + public function testRemoveSpeakerFromMyPresentationQueuesChangeNotificationOnPublishedPresentation() + { + $ids = $this->testAddSpeakerToMyPresentation(); + + $headers = [ + "HTTP_Authorization" => " Bearer " . $this->access_token, + "CONTENT_TYPE" => "application/json" + ]; + + $params = [ + 'presentation_id' => $ids['presentation_id'], + 'speaker_id' => $ids['speaker_id'], + ]; + + Queue::fake(); + + $response = $this->action( + "DELETE", + "OAuth2SummitSpeakersApiController@removeSpeakerFromMyPresentation", + $params, + [], + [], + [], + $headers + ); + + $this->assertResponseStatus(204); + Queue::assertPushed(PresentationActivitySpeakerChangeEmail::class, 1); + + // removing an already-removed speaker queues nothing additional + $response = $this->action( + "DELETE", + "OAuth2SummitSpeakersApiController@removeSpeakerFromMyPresentation", + $params, + [], + [], + [], + $headers + ); + + $this->assertResponseStatus(204); + Queue::assertPushed(PresentationActivitySpeakerChangeEmail::class, 1); + } + public function testAddModeratorToMyPresentation() { // Create a new speaker to be moderator @@ -1832,6 +1953,289 @@ public function testRemoveModeratorFromMyPresentation() $this->assertResponseStatus(204); } + public function testAddModeratorToMyPresentationQueuesChangeNotificationOnPublishedPresentation() + { + $presentation = self::$presentations[0]; + $this->assertTrue($presentation->isPublished()); + + $moderator = new PresentationSpeaker(); + $moderator->setFirstName('Moderator'); + $moderator->setLastName('Notify'); + $moderator->setBio('Moderator bio'); + self::$em->persist($moderator); + self::$em->flush(); + + $presentation_id = $presentation->getId(); + $moderator_id = $moderator->getId(); + self::$em->clear(); + + $headers = [ + "HTTP_Authorization" => " Bearer " . $this->access_token, + "CONTENT_TYPE" => "application/json" + ]; + + $params = [ + 'presentation_id' => $presentation_id, + 'speaker_id' => $moderator_id, + ]; + + Queue::fake(); + + $response = $this->action( + "PUT", + "OAuth2SummitSpeakersApiController@addModeratorToMyPresentation", + $params, + [], + [], + [], + $headers + ); + + $this->assertResponseStatus(201); + Queue::assertPushed(PresentationActivitySpeakerChangeEmail::class, 1); + } + + public function testAddModeratorToMyPresentationReassigningSameModeratorQueuesNothing() + { + $presentation = self::$presentations[0]; + $this->assertTrue($presentation->isPublished()); + + $moderator = new PresentationSpeaker(); + $moderator->setFirstName('Moderator'); + $moderator->setLastName('Unchanged'); + $moderator->setBio('Moderator bio'); + self::$em->persist($moderator); + self::$em->flush(); + + $presentation_id = $presentation->getId(); + $moderator_id = $moderator->getId(); + self::$em->clear(); + + $headers = [ + "HTTP_Authorization" => " Bearer " . $this->access_token, + "CONTENT_TYPE" => "application/json" + ]; + + $params = [ + 'presentation_id' => $presentation_id, + 'speaker_id' => $moderator_id, + ]; + + // first assignment: sets the moderator (not asserted here, exercised elsewhere) + $this->action( + "PUT", + "OAuth2SummitSpeakersApiController@addModeratorToMyPresentation", + $params, + [], + [], + [], + $headers + ); + + Queue::fake(); + + // re-assigning the SAME moderator must not queue a notification + $response = $this->action( + "PUT", + "OAuth2SummitSpeakersApiController@addModeratorToMyPresentation", + $params, + [], + [], + [], + $headers + ); + + $this->assertResponseStatus(201); + Queue::assertNotPushed(PresentationActivitySpeakerChangeEmail::class); + } + + public function testAddModeratorToMyPresentationReplacingModeratorQueuesRemovedAndAdded() + { + $presentation = self::$presentations[0]; + $this->assertTrue($presentation->isPublished()); + + $old_moderator = new PresentationSpeaker(); + $old_moderator->setFirstName('Old'); + $old_moderator->setLastName('ModeratorReplaced'); + $old_moderator->setBio('Old moderator bio'); + self::$em->persist($old_moderator); + + $new_moderator = new PresentationSpeaker(); + $new_moderator->setFirstName('New'); + $new_moderator->setLastName('ModeratorReplacing'); + $new_moderator->setBio('New moderator bio'); + self::$em->persist($new_moderator); + self::$em->flush(); + + $presentation_id = $presentation->getId(); + $old_moderator_id = $old_moderator->getId(); + $new_moderator_id = $new_moderator->getId(); + self::$em->clear(); + + $headers = [ + "HTTP_Authorization" => " Bearer " . $this->access_token, + "CONTENT_TYPE" => "application/json" + ]; + + // assign the old moderator first (not asserted here) + $this->action( + "PUT", + "OAuth2SummitSpeakersApiController@addModeratorToMyPresentation", + [ + 'presentation_id' => $presentation_id, + 'speaker_id' => $old_moderator_id, + ], + [], + [], + [], + $headers + ); + + Queue::fake(); + + // replace with the new moderator: must queue Removed (old) + Added (new) + $response = $this->action( + "PUT", + "OAuth2SummitSpeakersApiController@addModeratorToMyPresentation", + [ + 'presentation_id' => $presentation_id, + 'speaker_id' => $new_moderator_id, + ], + [], + [], + [], + $headers + ); + + $this->assertResponseStatus(201); + + $actions = $this->collectDispatchedChangeActions(); + $this->assertCount(2, $actions); + $this->assertContains(['role' => 'Moderator', 'action' => 'Added'], $actions); + $this->assertContains(['role' => 'Moderator', 'action' => 'Removed'], $actions); + } + + public function testRemoveModeratorFromMyPresentationQueuesChangeNotificationOnPublishedPresentation() + { + $ids = $this->testAddModeratorToMyPresentation(); + + $presentation = self::$summit->getEvent($ids['presentation_id']); + $this->assertTrue($presentation->isPublished()); + + $headers = [ + "HTTP_Authorization" => " Bearer " . $this->access_token, + "CONTENT_TYPE" => "application/json" + ]; + + $params = [ + 'presentation_id' => $ids['presentation_id'], + 'speaker_id' => $ids['speaker_id'], + ]; + + Queue::fake(); + + $response = $this->action( + "DELETE", + "OAuth2SummitSpeakersApiController@removeModeratorFromMyPresentation", + $params, + [], + [], + [], + $headers + ); + + $this->assertResponseStatus(204); + Queue::assertPushed(PresentationActivitySpeakerChangeEmail::class, 1); + } + + public function testModeratorEndpointsDoNotQueueChangeNotificationOnNonPublishedPresentation() + { + $presentation = self::$presentations[0]; + $presentation->unPublish(); + self::$em->persist($presentation); + self::$em->flush(); + $this->assertFalse($presentation->isPublished()); + + $moderator = new PresentationSpeaker(); + $moderator->setFirstName('Moderator'); + $moderator->setLastName('NotPublished'); + $moderator->setBio('Moderator bio'); + self::$em->persist($moderator); + self::$em->flush(); + + $presentation_id = $presentation->getId(); + $moderator_id = $moderator->getId(); + self::$em->clear(); + + $headers = [ + "HTTP_Authorization" => " Bearer " . $this->access_token, + "CONTENT_TYPE" => "application/json" + ]; + + $params = [ + 'presentation_id' => $presentation_id, + 'speaker_id' => $moderator_id, + ]; + + Queue::fake(); + + $response = $this->action( + "PUT", + "OAuth2SummitSpeakersApiController@addModeratorToMyPresentation", + $params, + [], + [], + [], + $headers + ); + $this->assertResponseStatus(201); + + $response = $this->action( + "DELETE", + "OAuth2SummitSpeakersApiController@removeModeratorFromMyPresentation", + $params, + [], + [], + [], + $headers + ); + $this->assertResponseStatus(204); + + Queue::assertNotPushed(PresentationActivitySpeakerChangeEmail::class); + } + + public function testRemoveModeratorFromMyPresentationWithNoModeratorSetQueuesNothing() + { + $presentation = self::$presentations[0]; + $this->assertTrue($presentation->isPublished()); + $this->assertFalse($presentation->hasModerator()); + + $headers = [ + "HTTP_Authorization" => " Bearer " . $this->access_token, + "CONTENT_TYPE" => "application/json" + ]; + + $params = [ + 'presentation_id' => $presentation->getId(), + 'speaker_id' => self::$defaultSpeaker->getId(), + ]; + + Queue::fake(); + + $response = $this->action( + "DELETE", + "OAuth2SummitSpeakersApiController@removeModeratorFromMyPresentation", + $params, + [], + [], + [], + $headers + ); + + $this->assertResponseStatus(204); + Queue::assertNotPushed(PresentationActivitySpeakerChangeEmail::class); + } + // --- Approve/Decline Speaker Edit Permission --- public function testApproveSpeakerEditPermission()