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
2 changes: 1 addition & 1 deletion .github/workflows/push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ jobs:
- { name: "CacheOptimizations", filter: "--filter '(PresentationSpeakerCacheTest|ResourceServerContextTest)'" }
# Named by path because no job in this matrix runs the tests/ root, only its
# subdirectories - a file added there runs nowhere unless it is listed here.
- { name: "PresentationMediaUploads", filter: "tests/PresentationMediaUploadsTest.php tests/PresentationMediaUploadsVisibilityTest.php tests/PresentationSerializerCacheKeyTest.php tests/PresentationReopenModelTest.php tests/PresentationReopenApiTest.php tests/PresentationReopenAuthzTest.php" }
- { name: "PresentationMediaUploads", filter: "tests/PresentationMediaUploadsTest.php tests/PresentationMediaUploadsVisibilityTest.php tests/PresentationSerializerCacheKeyTest.php tests/PresentationReopenModelTest.php tests/PresentationReopenApiTest.php tests/PresentationReopenAuthzTest.php tests/PresentationSubmissionReopenedEmailTest.php" }
env:
OTEL_SERVICE_ENABLED: false
APP_ENV: testing
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,75 @@ public function closeSubmissionPeriod($summit_id, $presentation_id)
});
}

#[OA\Put(
path: "/api/v1/summits/{id}/presentations/{presentation_id}/submission-period/reopen/notify",
summary: "Admin-only: notify selected recipients (submitter/speakers/moderator) that the submission period has been reopened",
operationId: "notifySubmissionReopened",
security: [['summit_presentations_auth' => [SummitScopes::WriteSummitData, SummitScopes::WriteEventData, SummitScopes::WritePresentationData]]],
tags: ['Presentations'],
parameters: [
new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
new OA\Parameter(name: 'presentation_id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
],
requestBody: new OA\RequestBody(
required: false,
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'speaker_ids', type: 'array', items: new OA\Items(type: 'integer')),
new OA\Property(property: 'include_submitter', type: 'boolean'),
]
)
),
responses: [
new OA\Response(
response: Response::HTTP_OK,
description: "OK",
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'recipients', type: 'integer'),
new OA\Property(property: 'skipped', type: 'integer'),
]
)
),
new OA\Response(response: Response::HTTP_UNAUTHORIZED, description: "Unauthorized"),
new OA\Response(response: Response::HTTP_FORBIDDEN, description: "Forbidden"),
new OA\Response(response: Response::HTTP_NOT_FOUND, description: "Not Found"),
new OA\Response(response: Response::HTTP_PRECONDITION_FAILED, description: "Validation Error"),
new OA\Response(response: Response::HTTP_INTERNAL_SERVER_ERROR, description: "Server Error"),
]
)]
public function notifySubmissionReopened($summit_id, $presentation_id)
{
return $this->processRequest(function () use ($summit_id, $presentation_id) {

$summit = SummitFinderStrategyFactory::build($this->summit_repository, $this->resource_server_context)->find($summit_id);
if (is_null($summit)) return $this->error404();

$current_member = $this->resource_server_context->getCurrentUser();
if (is_null($current_member)) return $this->error403();

$isAdmin = $current_member->isAdmin()
|| $current_member->hasPermissionForOnGroup($summit, IGroup::SummitAdministrators);
if (!$isAdmin) return $this->error403();

$payload = $this->getJsonPayload([
'speaker_ids' => 'sometimes|array',
'speaker_ids.*' => 'integer',
'include_submitter' => 'sometimes|boolean',
]);

$result = $this->presentation_submission_reopen_service->notify(
$summit,
intval($presentation_id),
$payload['speaker_ids'] ?? [],
boolval($payload['include_submitter'] ?? false),
$current_member
);

return $this->ok(['recipients' => $result['queued'], 'skipped' => $result['skipped']]);
});
}

#[OA\Put(
path: "/api/v1/summits/{id}/presentations/{presentation_id}/completed",
summary: "Mark a presentation submission as completed",
Expand Down
2 changes: 2 additions & 0 deletions app/Jobs/Emails/EmailTemplatesSchemaSerializerRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
use App\Jobs\Emails\PresentationSubmissions\PresentationCreatorNotificationEmail;
use App\Jobs\Emails\PresentationSubmissions\PresentationModeratorNotificationEmail;
use App\Jobs\Emails\PresentationSubmissions\PresentationSpeakerNotificationEmail;
use App\Jobs\Emails\PresentationSubmissions\PresentationSubmissionReopenedEmail;
use App\Jobs\Emails\PresentationSubmissions\SelectionProcess\PresentationSpeakerSelectionProcessAcceptedAlternateEmail;
use App\Jobs\Emails\PresentationSubmissions\SelectionProcess\PresentationSpeakerSelectionProcessAcceptedOnlyEmail;
use App\Jobs\Emails\PresentationSubmissions\SelectionProcess\PresentationSpeakerSelectionProcessAcceptedRejectedEmail;
Expand Down Expand Up @@ -146,6 +147,7 @@ private function __construct()
$this->registry[PresentationCreatorNotificationEmail::EVENT_SLUG] = PresentationCreatorNotificationEmail::class;
$this->registry[PresentationModeratorNotificationEmail::EVENT_SLUG] = PresentationModeratorNotificationEmail::class;
$this->registry[PresentationSpeakerNotificationEmail::EVENT_SLUG] = PresentationSpeakerNotificationEmail::class;
$this->registry[PresentationSubmissionReopenedEmail::EVENT_SLUG] = PresentationSubmissionReopenedEmail::class;
$this->registry[SpeakerCreationEmail::EVENT_SLUG] = SpeakerCreationEmail::class;
$this->registry[SpeakerEditPermissionApprovedEmail::EVENT_SLUG] = SpeakerEditPermissionApprovedEmail::class;
$this->registry[SpeakerEditPermissionRejectedEmail::EVENT_SLUG] = SpeakerEditPermissionRejectedEmail::class;
Expand Down
1 change: 1 addition & 0 deletions app/Jobs/Emails/IMailTemplatesConstants.php
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ interface IMailTemplatesConstants
const summit_reassign_ticket_till_date = 'summit_reassign_ticket_till_date';
const summit_schedule_url = 'summit_schedule_url';
const summit_site_url = 'summit_site_url';
const summit_slug = 'summit_slug';
const summit_schedule_default_event_detail_url = 'summit_schedule_default_event_detail_url';
const summit_virtual_site_oauth2_client_id = 'summit_virtual_site_oauth2_client_id';
const summit_virtual_site_url = 'summit_virtual_site_url';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php namespace App\Jobs\Emails\PresentationSubmissions;
/**
* 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\summit\Presentation;

/**
* Class PresentationSubmissionReopenedEmail
*
* One job for all recipients (submitter, speaker, moderator) -- the reopen copy is
* role-independent, unlike the sibling trio (Creator/Speaker/Moderator notification), so the
* recipient is passed as (email, name) rather than as a typed entity.
*
* @package App\Jobs\Emails\PresentationSubmissions
*/
class PresentationSubmissionReopenedEmail extends AbstractSummitEmailJob
{
protected function getEmailEventSlug(): string
{
return self::EVENT_SLUG;
}

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

/**
* PresentationSubmissionReopenedEmail constructor.
* @param Presentation $presentation
* @param string $to_email
* @param string $to_full_name
*/
public function __construct(Presentation $presentation, string $to_email, string $to_full_name)
{
$summit = $presentation->getSummit();
$selection_plan = $presentation->getSelectionPlan();

if (is_null($selection_plan))
throw new \InvalidArgumentException('Presentation selection plan is null.');

$support_email = $summit->getSupportEmail();
$support_email = !empty($support_email) ? $support_email : Config::get("cfp.support_email", null);

if (empty($support_email))
throw new \InvalidArgumentException('cfp.support_email is null.');

$payload = [];

$payload[IMailTemplatesConstants::full_name] = $to_full_name;
$payload[IMailTemplatesConstants::presentation_title] = $presentation->getTitle();
$payload[IMailTemplatesConstants::selection_plan_name] = $selection_plan->getName();
$payload[IMailTemplatesConstants::summit_slug] = $summit->getRawSlug();
$payload[IMailTemplatesConstants::selection_plan_id] = $selection_plan->getId();
$payload[IMailTemplatesConstants::presentation_id] = $presentation->getId();
$payload[IMailTemplatesConstants::support_email] = $support_email;

// until_date deliberately breaks the sibling format (date-only): a reopen window is
// measured in hours, so render summit-local date, time and zone label.
$until = $presentation->getSubmissionReopenedUntil();
$local = $selection_plan->convertDateFromUTC2TimeZone($until);
$payload[IMailTemplatesConstants::until_date] = is_null($local)
? $until->format('F d, Y g:i a') . ' UTC'
: $local->format('F d, Y g:i a') . ' ' . $summit->getTimeZoneLabel();

$template_identifier = $this->getEmailTemplateIdentifierFromEmailEvent($summit);

parent::__construct($summit, $payload, $template_identifier, $to_email);
}

/**
* @return array
*/
public static function getEmailTemplateSchema(): array
{
$payload = parent::getEmailTemplateSchema();

$payload[IMailTemplatesConstants::full_name]['type'] = 'string';
$payload[IMailTemplatesConstants::presentation_title]['type'] = 'string';
$payload[IMailTemplatesConstants::until_date]['type'] = 'string';
$payload[IMailTemplatesConstants::selection_plan_name]['type'] = 'string';
$payload[IMailTemplatesConstants::summit_slug]['type'] = 'string';
$payload[IMailTemplatesConstants::support_email]['type'] = 'string';
$payload[IMailTemplatesConstants::selection_plan_id]['type'] = 'int';
$payload[IMailTemplatesConstants::presentation_id]['type'] = 'int';

return $payload;
}
}
31 changes: 31 additions & 0 deletions app/Services/Model/IPresentationSubmissionReopenService.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,35 @@ public function reopen(Summit $summit, int $presentation_id, ?int $hours, Member
* @throws EntityNotFoundException if the presentation is not in $summit
*/
public function closeNow(Summit $summit, int $presentation_id, Member $actor): void;

/**
* Queues one reopen-notification email per SELECTED, distinct recipient for the presentation's
* CURRENTLY ACTIVE grant.
*
* The admin chooses who is notified. $speaker_ids names speakers and/or the moderator (the
* moderator IS a PresentationSpeaker, so it needs no separate parameter); $include_submitter
* covers the submitter -- SummitEvent::getCreatedBy(), a Member with no speaker id. Every id is
* verified to belong to THIS presentation -- see the trust-boundary note in the implementation.
*
* Not a delivery count. PresentationSubmissionReopenedEmail is a ShouldQueue job, so this
* returns before any mail has been handed to mailing-api, let alone sent. Delivery outcome
* lives in mailing-api's Mail rows.
*
* Repeatable by design, with a different selection each time if the admin wants: there is no
* once-only marker and no persisted selection.
*
* @return array{queued: int, skipped: int} queued = distinct recipients with a usable email
* that were queued; skipped = selected recipients dropped for a missing email.
* @throws EntityNotFoundException if the presentation is not in $summit
* @throws ValidationException if no grant is in force, if the selection is empty, if any id
* is not attached to this presentation, or if no selected
* recipient has an email
*/
public function notify(
Summit $summit,
int $presentation_id,
array $speaker_ids,
bool $include_submitter,
Member $actor
): array;
}
Loading
Loading