Skip to content
Merged
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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions app/Jobs/Emails/IMailTemplatesConstants.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php namespace App\Jobs\Emails\Schedule;
/**
* Copyright 2026 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/
use App\Jobs\Emails\AbstractSummitEmailJob;
use App\Jobs\Emails\IMailTemplatesConstants;
use Illuminate\Support\Facades\Config;
use models\exceptions\ValidationException;
use models\summit\Presentation;
use models\summit\PresentationSpeaker;
/**
* Class PresentationActivitySpeakerChangeEmail
* @package App\Jobs\Emails\Schedule
*/
class PresentationActivitySpeakerChangeEmail extends AbstractSummitEmailJob
{
const Role_Speaker = 'Speaker';
const Role_Moderator = 'Moderator';

const AllowedRoles = [self::Role_Speaker, self::Role_Moderator];

const Action_Added = 'Added';
const Action_Removed = 'Removed';

const AllowedActions = [self::Action_Added, self::Action_Removed];

const RecipientConfigKey = 'cfp.speaker_change_notification_email';

protected function getEmailEventSlug(): string
{
return self::EVENT_SLUG;
}

// metadata
const EVENT_SLUG = 'SUMMIT_PRESENTATION_ACTIVITY_SPEAKER_CHANGE';
const EVENT_NAME = 'SUMMIT_PRESENTATION_ACTIVITY_SPEAKER_CHANGE';
const DEFAULT_TEMPLATE = 'SUMMIT_PRESENTATION_ACTIVITY_SPEAKER_CHANGE';

/**
* PresentationActivitySpeakerChangeEmail constructor.
* @param Presentation $presentation
* @param PresentationSpeaker $speaker
* @param string $role
* @param string $action
*/
public function __construct(Presentation $presentation, PresentationSpeaker $speaker, string $role, string $action)
{
if (!in_array($role, self::AllowedRoles))
throw new \InvalidArgumentException(sprintf('role %s is not a valid role.', $role));

if (!in_array($action, self::AllowedActions))
throw new \InvalidArgumentException(sprintf('action %s is not a valid action.', $action));

$summit = $presentation->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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php namespace App\Services\Model\Imp\Notifications;
/**
* Copyright 2026 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/
use App\Jobs\Emails\Schedule\PresentationActivitySpeakerChangeEmail;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Log;
use models\summit\Presentation;
use models\summit\PresentationSpeaker;

/**
* Collects speaker/moderator changes made to published presentations while a transaction is
* still open, and turns them into queued notifications once that transaction has committed.
*
* The contract is deliberately structural: whoever CONSTRUCTS a collector is the one that
* dispatches it, right after its own tx_service->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);
}
}
}
}
40 changes: 37 additions & 3 deletions app/Services/Model/Imp/PresentationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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)
Expand All @@ -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'])) {
Expand All @@ -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;
}

/**
Expand All @@ -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)
Expand All @@ -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();
}
}
Loading
Loading