Skip to content

Commit ef89551

Browse files
committed
feat: add CFP reopen speaker/submitter notification endpoint
Admin viewing a presentation with a live CFP reopen grant can select any combination of submitter, speakers and moderator, and trigger PUT .../submission-period/reopen/notify to queue one reopen-notification email per selected, distinct recipient (deduped by lowercased email). Response reports how many were queued and how many were skipped for a missing email. - New PresentationSubmissionReopenedEmail job, mirroring PresentationSpeakerNotificationEmail but role-independent. - notify() on PresentationSubmissionReopenService: builds the allowed recipient set from the presentation's own getSpeakers()/getModerator() (never by looking speakers up from the request), intersects against the caller's selection, dedupes by email. - New controller action + route, rate-limited via rate.limit:30,60 middleware (matching the discover/preValidatePromoCode precedent). - Additive config + model migrations registering the endpoint and the email flow event type for already-deployed environments, plus the matching fresh-install seeder entries. ClickUp: https://app.clickup.com/t/9014802374/86bbkbrue
1 parent 76b79ae commit ef89551

15 files changed

Lines changed: 1013 additions & 0 deletions

app/Http/Controllers/Apis/Protected/Summit/OAuth2PresentationApiController.php

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,75 @@ public function closeSubmissionPeriod($summit_id, $presentation_id)
641641
});
642642
}
643643

644+
#[OA\Put(
645+
path: "/api/v1/summits/{id}/presentations/{presentation_id}/submission-period/reopen/notify",
646+
summary: "Admin-only: notify selected recipients (submitter/speakers/moderator) that the submission period has been reopened",
647+
operationId: "notifySubmissionReopened",
648+
security: [['summit_presentations_auth' => [SummitScopes::WriteSummitData, SummitScopes::WriteEventData, SummitScopes::WritePresentationData]]],
649+
tags: ['Presentations'],
650+
parameters: [
651+
new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
652+
new OA\Parameter(name: 'presentation_id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
653+
],
654+
requestBody: new OA\RequestBody(
655+
required: false,
656+
content: new OA\JsonContent(
657+
properties: [
658+
new OA\Property(property: 'speaker_ids', type: 'array', items: new OA\Items(type: 'integer')),
659+
new OA\Property(property: 'include_submitter', type: 'boolean'),
660+
]
661+
)
662+
),
663+
responses: [
664+
new OA\Response(
665+
response: Response::HTTP_OK,
666+
description: "OK",
667+
content: new OA\JsonContent(
668+
properties: [
669+
new OA\Property(property: 'recipients', type: 'integer'),
670+
new OA\Property(property: 'skipped', type: 'integer'),
671+
]
672+
)
673+
),
674+
new OA\Response(response: Response::HTTP_UNAUTHORIZED, description: "Unauthorized"),
675+
new OA\Response(response: Response::HTTP_FORBIDDEN, description: "Forbidden"),
676+
new OA\Response(response: Response::HTTP_NOT_FOUND, description: "Not Found"),
677+
new OA\Response(response: Response::HTTP_PRECONDITION_FAILED, description: "Validation Error"),
678+
new OA\Response(response: Response::HTTP_INTERNAL_SERVER_ERROR, description: "Server Error"),
679+
]
680+
)]
681+
public function notifySubmissionReopened($summit_id, $presentation_id)
682+
{
683+
return $this->processRequest(function () use ($summit_id, $presentation_id) {
684+
685+
$summit = SummitFinderStrategyFactory::build($this->summit_repository, $this->resource_server_context)->find($summit_id);
686+
if (is_null($summit)) return $this->error404();
687+
688+
$current_member = $this->resource_server_context->getCurrentUser();
689+
if (is_null($current_member)) return $this->error403();
690+
691+
$isAdmin = $current_member->isAdmin()
692+
|| $current_member->hasPermissionForOnGroup($summit, IGroup::SummitAdministrators);
693+
if (!$isAdmin) return $this->error403();
694+
695+
$payload = $this->getJsonPayload([
696+
'speaker_ids' => 'sometimes|array',
697+
'speaker_ids.*' => 'integer',
698+
'include_submitter' => 'sometimes|boolean',
699+
]);
700+
701+
$result = $this->presentation_submission_reopen_service->notify(
702+
$summit,
703+
intval($presentation_id),
704+
$payload['speaker_ids'] ?? [],
705+
boolval($payload['include_submitter'] ?? false),
706+
$current_member
707+
);
708+
709+
return $this->ok(['recipients' => $result['queued'], 'skipped' => $result['skipped']]);
710+
});
711+
}
712+
644713
#[OA\Put(
645714
path: "/api/v1/summits/{id}/presentations/{presentation_id}/completed",
646715
summary: "Mark a presentation submission as completed",

app/Jobs/Emails/EmailTemplatesSchemaSerializerRegistry.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
use App\Jobs\Emails\PresentationSubmissions\PresentationCreatorNotificationEmail;
3030
use App\Jobs\Emails\PresentationSubmissions\PresentationModeratorNotificationEmail;
3131
use App\Jobs\Emails\PresentationSubmissions\PresentationSpeakerNotificationEmail;
32+
use App\Jobs\Emails\PresentationSubmissions\PresentationSubmissionReopenedEmail;
3233
use App\Jobs\Emails\PresentationSubmissions\SelectionProcess\PresentationSpeakerSelectionProcessAcceptedAlternateEmail;
3334
use App\Jobs\Emails\PresentationSubmissions\SelectionProcess\PresentationSpeakerSelectionProcessAcceptedOnlyEmail;
3435
use App\Jobs\Emails\PresentationSubmissions\SelectionProcess\PresentationSpeakerSelectionProcessAcceptedRejectedEmail;
@@ -146,6 +147,7 @@ private function __construct()
146147
$this->registry[PresentationCreatorNotificationEmail::EVENT_SLUG] = PresentationCreatorNotificationEmail::class;
147148
$this->registry[PresentationModeratorNotificationEmail::EVENT_SLUG] = PresentationModeratorNotificationEmail::class;
148149
$this->registry[PresentationSpeakerNotificationEmail::EVENT_SLUG] = PresentationSpeakerNotificationEmail::class;
150+
$this->registry[PresentationSubmissionReopenedEmail::EVENT_SLUG] = PresentationSubmissionReopenedEmail::class;
149151
$this->registry[SpeakerCreationEmail::EVENT_SLUG] = SpeakerCreationEmail::class;
150152
$this->registry[SpeakerEditPermissionApprovedEmail::EVENT_SLUG] = SpeakerEditPermissionApprovedEmail::class;
151153
$this->registry[SpeakerEditPermissionRejectedEmail::EVENT_SLUG] = SpeakerEditPermissionRejectedEmail::class;

app/Jobs/Emails/IMailTemplatesConstants.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ interface IMailTemplatesConstants
172172
const summit_reassign_ticket_till_date = 'summit_reassign_ticket_till_date';
173173
const summit_schedule_url = 'summit_schedule_url';
174174
const summit_site_url = 'summit_site_url';
175+
const summit_slug = 'summit_slug';
175176
const summit_schedule_default_event_detail_url = 'summit_schedule_default_event_detail_url';
176177
const summit_virtual_site_oauth2_client_id = 'summit_virtual_site_oauth2_client_id';
177178
const summit_virtual_site_url = 'summit_virtual_site_url';
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<?php namespace App\Jobs\Emails\PresentationSubmissions;
2+
/**
3+
* Copyright 2026 OpenStack Foundation
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
**/
14+
use App\Jobs\Emails\AbstractSummitEmailJob;
15+
use App\Jobs\Emails\IMailTemplatesConstants;
16+
use Illuminate\Support\Facades\Config;
17+
use models\summit\Presentation;
18+
19+
/**
20+
* Class PresentationSubmissionReopenedEmail
21+
*
22+
* One job for all recipients (submitter, speaker, moderator) -- the reopen copy is
23+
* role-independent, unlike the sibling trio (Creator/Speaker/Moderator notification), so the
24+
* recipient is passed as (email, name) rather than as a typed entity.
25+
*
26+
* @package App\Jobs\Emails\PresentationSubmissions
27+
*/
28+
class PresentationSubmissionReopenedEmail extends AbstractSummitEmailJob
29+
{
30+
protected function getEmailEventSlug(): string
31+
{
32+
return self::EVENT_SLUG;
33+
}
34+
35+
// metadata
36+
const EVENT_SLUG = 'SUMMIT_SUBMISSIONS_PRESENTATION_SUBMISSION_REOPENED';
37+
const EVENT_NAME = 'SUMMIT_SUBMISSIONS_PRESENTATION_SUBMISSION_REOPENED';
38+
const DEFAULT_TEMPLATE = 'SUMMIT_SUBMISSIONS_PRESENTATION_SUBMISSION_REOPENED';
39+
40+
/**
41+
* PresentationSubmissionReopenedEmail constructor.
42+
* @param Presentation $presentation
43+
* @param string $to_email
44+
* @param string $to_full_name
45+
*/
46+
public function __construct(Presentation $presentation, string $to_email, string $to_full_name)
47+
{
48+
$summit = $presentation->getSummit();
49+
$selection_plan = $presentation->getSelectionPlan();
50+
51+
if (is_null($selection_plan))
52+
throw new \InvalidArgumentException('Presentation selection plan is null.');
53+
54+
$support_email = $summit->getSupportEmail();
55+
$support_email = !empty($support_email) ? $support_email : Config::get("cfp.support_email", null);
56+
57+
if (empty($support_email))
58+
throw new \InvalidArgumentException('cfp.support_email is null.');
59+
60+
$payload = [];
61+
62+
$payload[IMailTemplatesConstants::full_name] = $to_full_name;
63+
$payload[IMailTemplatesConstants::presentation_title] = $presentation->getTitle();
64+
$payload[IMailTemplatesConstants::selection_plan_name] = $selection_plan->getName();
65+
$payload[IMailTemplatesConstants::summit_slug] = $summit->getRawSlug();
66+
$payload[IMailTemplatesConstants::selection_plan_id] = $selection_plan->getId();
67+
$payload[IMailTemplatesConstants::presentation_id] = $presentation->getId();
68+
$payload[IMailTemplatesConstants::support_email] = $support_email;
69+
70+
// until_date deliberately breaks the sibling format (date-only): a reopen window is
71+
// measured in hours, so render summit-local date, time and zone label.
72+
$until = $presentation->getSubmissionReopenedUntil();
73+
$local = $selection_plan->convertDateFromUTC2TimeZone($until);
74+
$payload[IMailTemplatesConstants::until_date] = is_null($local)
75+
? $until->format('F d, Y g:i a') . ' UTC'
76+
: $local->format('F d, Y g:i a') . ' ' . $summit->getTimeZoneLabel();
77+
78+
$template_identifier = $this->getEmailTemplateIdentifierFromEmailEvent($summit);
79+
80+
parent::__construct($summit, $payload, $template_identifier, $to_email);
81+
}
82+
83+
/**
84+
* @return array
85+
*/
86+
public static function getEmailTemplateSchema(): array
87+
{
88+
$payload = parent::getEmailTemplateSchema();
89+
90+
$payload[IMailTemplatesConstants::full_name]['type'] = 'string';
91+
$payload[IMailTemplatesConstants::presentation_title]['type'] = 'string';
92+
$payload[IMailTemplatesConstants::until_date]['type'] = 'string';
93+
$payload[IMailTemplatesConstants::selection_plan_name]['type'] = 'string';
94+
$payload[IMailTemplatesConstants::summit_slug]['type'] = 'string';
95+
$payload[IMailTemplatesConstants::support_email]['type'] = 'string';
96+
$payload[IMailTemplatesConstants::selection_plan_id]['type'] = 'int';
97+
$payload[IMailTemplatesConstants::presentation_id]['type'] = 'int';
98+
99+
return $payload;
100+
}
101+
}

app/Services/Model/IPresentationSubmissionReopenService.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,35 @@ public function reopen(Summit $summit, int $presentation_id, ?int $hours, Member
4545
* @throws EntityNotFoundException if the presentation is not in $summit
4646
*/
4747
public function closeNow(Summit $summit, int $presentation_id, Member $actor): void;
48+
49+
/**
50+
* Queues one reopen-notification email per SELECTED, distinct recipient for the presentation's
51+
* CURRENTLY ACTIVE grant.
52+
*
53+
* The admin chooses who is notified. $speaker_ids names speakers and/or the moderator (the
54+
* moderator IS a PresentationSpeaker, so it needs no separate parameter); $include_submitter
55+
* covers the submitter -- SummitEvent::getCreatedBy(), a Member with no speaker id. Every id is
56+
* verified to belong to THIS presentation -- see the trust-boundary note in the implementation.
57+
*
58+
* Not a delivery count. PresentationSubmissionReopenedEmail is a ShouldQueue job, so this
59+
* returns before any mail has been handed to mailing-api, let alone sent. Delivery outcome
60+
* lives in mailing-api's Mail rows.
61+
*
62+
* Repeatable by design, with a different selection each time if the admin wants: there is no
63+
* once-only marker and no persisted selection.
64+
*
65+
* @return array{queued: int, skipped: int} queued = distinct recipients with a usable email
66+
* that were queued; skipped = selected recipients dropped for a missing email.
67+
* @throws EntityNotFoundException if the presentation is not in $summit
68+
* @throws ValidationException if no grant is in force, if the selection is empty, if any id
69+
* is not attached to this presentation, or if no selected
70+
* recipient has an email
71+
*/
72+
public function notify(
73+
Summit $summit,
74+
int $presentation_id,
75+
array $speaker_ids,
76+
bool $include_submitter,
77+
Member $actor
78+
): array;
4879
}

app/Services/Model/Imp/PresentationSubmissionReopenService.php

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
* limitations under the License.
1313
**/
1414

15+
use App\Jobs\Emails\PresentationSubmissions\PresentationSubmissionReopenedEmail;
1516
use App\Services\Model\AbstractService;
1617
use Illuminate\Support\Facades\Config;
1718
use Illuminate\Support\Facades\Log;
@@ -123,4 +124,116 @@ public function closeNow(Summit $summit, int $presentation_id, Member $actor): v
123124
)
124125
);
125126
}
127+
128+
public function notify(
129+
Summit $summit,
130+
int $presentation_id,
131+
array $speaker_ids,
132+
bool $include_submitter,
133+
Member $actor
134+
): array {
135+
// Read inside the transaction, dispatch outside it. Same reasoning as closeNow()'s
136+
// deferred audit line: flush/commit happen after the closure and a retryable failure
137+
// re-runs it, so dispatching inside would queue mail for a read that had not committed
138+
// and could queue it more than once.
139+
[$presentation, $recipients, $skipped, $deadline] = $this->tx_service->transaction(
140+
function () use ($summit, $presentation_id, $speaker_ids, $include_submitter) {
141+
142+
// summit-scoped unconditionally, matching reopen() and closeNow()
143+
$presentation = $summit->getEvent($presentation_id);
144+
if (!$presentation instanceof Presentation)
145+
throw new EntityNotFoundException(sprintf("Presentation %s not found.", $presentation_id));
146+
147+
// The single grant gate. isSubmissionReopened() is false both when no grant exists
148+
// AND when the plan's submission_end_date has since been extended past now -- in
149+
// that second case the speaker is editing under normal open-window rules and the
150+
// grant is not what is letting them in, so there is no reopen deadline to announce.
151+
if (!$presentation->isSubmissionReopened())
152+
throw new ValidationException("Submission is not currently reopened for this presentation.");
153+
154+
$speaker_ids = array_values(array_unique(array_map('intval', $speaker_ids)));
155+
156+
if (empty($speaker_ids) && !$include_submitter)
157+
throw new ValidationException("Select at least one recipient.");
158+
159+
// ---------------------------------------------------------------------------
160+
// TRUST BOUNDARY. The caller now names recipients, so the set of people this
161+
// endpoint may mail must be derived from the PRESENTATION, never from the request.
162+
// Without this check the endpoint mails any speaker id in the system on behalf of
163+
// any summit admin: an authenticated mail relay. Build the allowed map first, then
164+
// intersect -- do not look speakers up by id from the repository.
165+
// ---------------------------------------------------------------------------
166+
$allowed = []; // speaker id => PresentationSpeaker
167+
$roles = []; // speaker id => 'speaker' | 'moderator' | 'speaker, moderator'
168+
foreach ($presentation->getSpeakers() as $speaker) {
169+
$allowed[$speaker->getId()] = $speaker;
170+
$roles[$speaker->getId()] = 'speaker';
171+
}
172+
173+
// Separate association, NOT necessarily a member of getSpeakers(). Dropping this
174+
// line makes every moderator-only recipient fail the intersect below as "not on
175+
// this presentation".
176+
$moderator = $presentation->getModerator();
177+
if (!is_null($moderator)) {
178+
$allowed[$moderator->getId()] = $moderator;
179+
$roles[$moderator->getId()] = isset($roles[$moderator->getId()])
180+
? 'speaker, moderator' : 'moderator';
181+
}
182+
183+
$unknown = array_diff($speaker_ids, array_keys($allowed));
184+
if (!empty($unknown))
185+
throw new ValidationException(sprintf(
186+
"Speaker(s) %s are not on this presentation.", implode(', ', $unknown)
187+
));
188+
189+
// keyed by normalized email -> display name
190+
$recipients = []; $skipped = 0;
191+
192+
$add = function (?string $email, ?string $name, string $role) use (&$recipients, &$skipped) {
193+
$key = strtolower(trim($email ?? ''));
194+
if ($key === '') {
195+
$skipped++;
196+
// Logged, never fatal: one incomplete record must not block the others.
197+
Log::warning(sprintf("PresentationSubmissionReopenService::notify: %s has no usable email; skipped.", $role));
198+
return;
199+
}
200+
if (!array_key_exists($key, $recipients)) $recipients[$key] = $name;
201+
};
202+
203+
// Submitter first: on a self-submitted talk they are also a speaker, and
204+
// first-write wins in $add, so the submitter's own name is the one used and they
205+
// get ONE email even when both boxes are ticked.
206+
if ($include_submitter) {
207+
// getCreatedBy(), NOT getCreator(): the latter is @deprecated.
208+
$submitter = $presentation->getCreatedBy();
209+
if (is_null($submitter))
210+
throw new ValidationException("This presentation has no submitter to notify.");
211+
$add($submitter->getEmail(), $submitter->getFullName(), sprintf('submitter (member %s)', $submitter->getId()));
212+
}
213+
214+
foreach ($speaker_ids as $id)
215+
$add($allowed[$id]->getEmail(), $allowed[$id]->getFullName(), sprintf('%s %s', $roles[$id], $id));
216+
217+
if (empty($recipients))
218+
throw new ValidationException("None of the selected recipients has an email address.");
219+
220+
return [$presentation, $recipients, $skipped, $presentation->getSubmissionReopenedUntil()];
221+
}
222+
);
223+
224+
foreach ($recipients as $email => $name)
225+
PresentationSubmissionReopenedEmail::dispatch($presentation, $email, $name ?? '');
226+
227+
// Report queued and skipped, NOT "queued of selected". Selection is counted in rows by the
228+
// client and in ids by the server, and one merged row (a submitter who is also a speaker)
229+
// sets two channels, so "1 of 2 selected" would be a true statement about a single ticked
230+
// box. Queued plus skipped is unambiguous at both ends.
231+
Log::info(sprintf(
232+
"PresentationSubmissionReopenService::notify summit %s presentation %s queued %s recipient(s), %s skipped for missing email, by member %s (window ends %s).",
233+
$summit->getId(), $presentation_id, count($recipients), $skipped, $actor->getId(),
234+
$deadline->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z')
235+
));
236+
237+
return ['queued' => count($recipients), 'skipped' => $skipped];
238+
}
126239
}

0 commit comments

Comments
 (0)