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 @@ -34,6 +34,7 @@ SESSION_COOKIE_SECURE=false
QUEUE_DRIVER=database
QUEUE_CONN=
QUEUE_DATABASE=
DB_QUEUE_RETRY_AFTER=1800

MAIL_DRIVER=sendgrid
SENDGRID_API_KEY='YOUR_SENDGRID_API_KEY'
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,19 @@
* See the License for the specific language governing permissions and
* limitations under the License.
**/
use App\Jobs\Emails\PresentationSubmissions\SelectionProcess\PresentationSpeakerSelectionProcessExcerptEmail;
use App\Jobs\Utils\JobDispatcher;
use App\Services\utils\IEmailExcerptService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Log;
use models\summit\ISummitRepository;
use models\summit\Summit;
use services\model\ISpeakerService;
use utils\FilterParser;
/**
Expand Down Expand Up @@ -79,28 +85,121 @@ public function handle
)
);

$filter = !is_null($this->filter) ? FilterParser::parse($this->filter, [
'id' => ['=='],
'not_id' => ['=='],
'first_name' => ['=@', '@@', '=='],
'last_name' => ['=@', '@@', '=='],
'email' => ['=@', '@@', '=='],
'full_name' => ['=@', '@@', '=='],
'has_accepted_presentations' => ['=='],
'has_alternate_presentations' => ['=='],
'has_rejected_presentations' => ['=='],
'presentations_track_id' => ['=='],
'presentations_track_group_id' => ['=='],
'presentations_selection_plan_id' => ['=='],
'presentations_type_id' => ['=='],
'presentations_title' => ['=@', '@@', '=='],
'presentations_abstract' => ['=@', '@@', '=='],
'presentations_submitter_full_name' => ['=@', '@@', '=='],
'presentations_submitter_email' => ['=@', '@@', '=='],
'has_media_upload_with_type' => ['=='],
'has_not_media_upload_with_type' => ['=='],
]) : null;
$filter = !is_null($this->filter) ? FilterParser::parse($this->filter, \services\model\ISpeakerFilterFields::OPERATORS) : null;

$service->sendEmails($this->summit_id, $this->payload, $filter);
}

/**
* Invoked by the queue worker once this job is marked failed. With tries = 1 that includes a
* chunk whose worker was killed mid-run: the job sits reserved until the connection's
* retry_after elapses, is re-served, and is failed without re-running. Nothing else reports
* that loss - the outcome excerpt is only sent when sendEmails() runs to completion - so
* without this hook a dead chunk leaves no trace beyond a queue_failed_jobs row.
*
* Log the chunk's speaker ids at error, and when the operator asked for an outcome e-mail
* send one naming them, so the chunk can be re-sent by id. The chunk is processed one speaker
* per transaction, so a worker killed mid-run has already e-mailed (and written the "already
* sent" proof for) the speakers before the kill: the ids are an upper bound on what was lost,
* not confirmed misses, and both messages say so and tell the operator to re-send with
* should_resend=false so the resend guard skips the speakers that already have a proof. The
* excerpt goes through JobDispatcher::withDbFallback (primary, then the database queue, then
* an inline run) and is best-effort: a failure there must not mask the original failure.
*
* @param \Throwable $e
*/
public function failed(\Throwable $e): void
{
$speaker_ids = $this->payload['speaker_ids'] ?? [];
$flow_event = $this->payload['email_flow_event'] ?? '';
$ids_list = implode(', ', $speaker_ids);

$resend_hint = "Re-send these ids with should_resend=false so the speakers already e-mailed are skipped";
if (isset($this->payload['promo_code_spec'])) {
// AutomaticMultiSpeakerPromoCodeStrategy::getPromoCode() generates a fresh code on every
// call, before the resend guard runs, so should_resend=false does not prevent this one.
$resend_hint .= "; this send auto-generates promo codes and a re-send creates a new code for every speaker in the list, including the ones already e-mailed";
}

Log::error
(
sprintf
(
"ProcessSpeakersEmailRequestJob::failed summit %s flow_event %s: chunk of %s speaker(s) failed (%s: %s); up to %s of them may not have been processed. Speaker ids in the chunk: [%s] filter fields %s. %s.",
$this->summit_id,
$flow_event,
count($speaker_ids),
get_class($e),
$e->getMessage(),
count($speaker_ids),
$ids_list,
json_encode($this->redactFilterFieldNames($this->filter)),
$resend_hint
)
);

$outcome_email_recipient = $this->payload['outcome_email_recipient'] ?? null;
if (empty($outcome_email_recipient)) return;

try {
$summit = App::make(ISummitRepository::class)->getById($this->summit_id);
if (!$summit instanceof Summit) {
Log::warning(sprintf("ProcessSpeakersEmailRequestJob::failed summit %s not found, outcome excerpt not sent", $this->summit_id));
return;
}

// Same line types AbstractExcerptEmailJob renders for a completed run, so the
// operator's inbox reads the same either way.
$report = [
[
'type' => IEmailExcerptService::InfoType,
'message' => sprintf("Processing EMAIL %s for Summit %s", $flow_event, $this->summit_id),
],
[
'type' => IEmailExcerptService::ErrorType,
'message' => sprintf
(
"Chunk of %s speaker(s) failed (%s); up to %s of them may not have been processed. Speaker ids in the chunk: %s. %s",
count($speaker_ids),
$e->getMessage(),
count($speaker_ids),
$ids_list,
$resend_hint
),
],
[
'type' => IEmailExcerptService::InfoType,
'message' => "TOTAL processed for this chunk is unknown, the job did not run to completion",
],
];

// Same failover route as the chunk itself (SpeakerService::triggerSendEmails): a chunk
// runs on the database fallback worker precisely when the redis primary was down at
// dispatch time, so a bare ::dispatch() here would throw into the catch below and lose
// the report in the one scenario it exists for.
JobDispatcher::withDbFallback(
job: new PresentationSpeakerSelectionProcessExcerptEmail($summit, $outcome_email_recipient, $report),
logContext: ['summit_id' => $this->summit_id, 'speaker_count' => count($speaker_ids)],
primaryConnection: Config::get('queue.default')
);
}
catch (\Throwable $ex) {
Log::error($ex);
}
}

/**
* Reduces a raw filter (["email==foo@bar.com", "presentations_selection_plan_id==78"]) to
* just its field names (["email", "presentations_selection_plan_id"]) so error logs never
* carry filter values that may be PII - email and full_name are valid filter fields
* (ISpeakerFilterFields::OPERATORS).
*
* @param mixed $filter
* @return string[]
*/
private function redactFilterFieldNames($filter): array
{
if (empty($filter) || !is_array($filter)) return [];
return array_map(fn($condition) => preg_replace('/[=<>@!].*/', '', (string)$condition), $filter);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -271,9 +271,30 @@ public function setEmailSent(string $recipient = null)
);

try{
$existing_owner = $this->owners->filter(function ($e) use($recipient){
return strtolower($e->getSpeaker()->getEmail()) == strtolower(trim($recipient)) && !$e->isSent();
})->first();
$recipient = strtolower(trim($recipient ?? ''));

if (empty($recipient))
throw new ValidationException("Can't mark the promo_code as sent without a recipient.");
Comment on lines +274 to +277

// Resolve the assignment with a targeted query instead of $this->owners->filter():
// Collection::filter() initializes the whole collection regardless of the EXTRA_LAZY
// mapping and then dereferences ->getSpeaker()->getEmail() on every element, so the
// cost of marking one speaker grows with the number of speakers already assigned to
// this code - quadratic over a bulk send.
// The matching mirrors PresentationSpeaker::getEmail(): the member's e-mail wins, and
// the registration request's is only used when the speaker has no member.
$query = $this->createQuery("SELECT e from models\summit\AssignedPromoCodeSpeaker e
JOIN e.registration_promo_code pc
JOIN e.speaker s
LEFT JOIN s.member m
LEFT JOIN s.registration_request rr
WHERE pc.id = :promo_code_id and e.sent is null
and ( LOWER(m.email) = :recipient or ( m.id is null and LOWER(rr.email) = :recipient ) )");

$query->setParameter('promo_code_id', $this->getId());
$query->setParameter('recipient', $recipient);

$existing_owner = $query->setMaxResults(1)->getOneOrNullResult();

if (!$existing_owner instanceof AssignedPromoCodeSpeaker)
throw new ValidationException("Can't find an owner with the email {$recipient} for the promo_code.");
Expand Down
10 changes: 7 additions & 3 deletions app/Repositories/DoctrineRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -242,15 +242,19 @@ public function getParametrizedAllIdsByPage(callable $fnQuery,PagingInfo $paging
if(!is_null($filter)){
$filter->apply2Query($query, $this->getFilterMappings($filter));
}
else if(!is_null($fnDefaultFilter)){
$query = call_user_func($fnDefaultFilter, $query);
}

$query = $this->applyExtraFilters($query);

if(!is_null($order)){
$order->apply2Query($query, $this->getOrderMappings($filter));
}
else if(!is_null($fnDefaultFilter)){
// default order fallback, same contract as getParametrizedAllByPage: it applies
// whenever no explicit order was given, regardless of the filter. Chaining it to
// $filter instead left every filtered page paginated with LIMIT/OFFSET and no
// ORDER BY, which MySQL is free to return in a different order per page.
$query = call_user_func($fnDefaultFilter, $query);
}

$query = $query
->setFirstResult($paging_info->getOffset())
Expand Down
115 changes: 115 additions & 0 deletions app/Services/Model/ISpeakerFilterFields.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?php namespace services\model;
/**
* 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.
**/

/**
* Interface ISpeakerFilterFields
*
* The speaker filter field whitelist shared by every summit-scoped speaker filter/search
* endpoint and the bulk speaker email send path. Referenced directly by name
* (ISpeakerFilterFields::OPERATORS / ::VALIDATION_RULES) without implementing it, the same
* way IEmailExcerptService's constants are used.
*
* @package services\model
*/
interface ISpeakerFilterFields
{
const OPERATORS = [
'id' => ['=='],
'not_id' => ['=='],
'first_name' => ['=@', '@@', '=='],
'last_name' => ['=@', '@@', '=='],
'email' => ['=@', '@@', '=='],
'full_name' => ['=@', '@@', '=='],
'member_id' => ['=='],
'member_user_external_id' => ['=='],
'has_accepted_presentations' => ['=='],
'has_alternate_presentations' => ['=='],
'has_rejected_presentations' => ['=='],
'presentations_track_id' => ['=='],
'presentations_track_group_id' => ['=='],
'presentations_selection_plan_id' => ['=='],
'presentations_type_id' => ['=='],
'presentations_title' => ['=@', '@@', '=='],
'presentations_abstract' => ['=@', '@@', '=='],
'presentations_submitter_full_name' => ['=@', '@@', '=='],
'presentations_submitter_email' => ['=@', '@@', '=='],
'has_media_upload_with_type' => ['=='],
'has_not_media_upload_with_type' => ['=='],
];

const VALIDATION_RULES = [
'id' => 'sometimes|integer',
'not_id' => 'sometimes|integer',
'first_name' => 'sometimes|string',
'last_name' => 'sometimes|string',
'email' => 'sometimes|string',
'full_name' => 'sometimes|string',
'member_id' => 'sometimes|integer',
'member_user_external_id' => 'sometimes|integer',
'has_accepted_presentations' => 'sometimes|string|in:true,false',
'has_alternate_presentations' => 'sometimes|string|in:true,false',
'has_rejected_presentations' => 'sometimes|string|in:true,false',
'presentations_track_id' => 'sometimes|integer',
'presentations_track_group_id' => 'sometimes|integer',
'presentations_selection_plan_id' => 'sometimes|integer',
'presentations_type_id' => 'sometimes|integer',
'presentations_title' => 'sometimes|string',
'presentations_abstract' => 'sometimes|string',
'presentations_submitter_full_name' => 'sometimes|string',
'presentations_submitter_email' => 'sometimes|string',
'has_media_upload_with_type' => 'sometimes|integer',
'has_not_media_upload_with_type' => 'sometimes|integer',
];

/**
* The subset of OPERATORS safe for a summit-independent query (e.g.
* OAuth2SummitSpeakersApiController::getAll(), which uses
* DoctrineSpeakerRepository::getAllByPage() / getAllIdsByPage()).
*
* The 13 fields excluded here (has_accepted/alternate/rejected_presentations,
* every presentations_* field, has_(not_)media_upload_with_type) all hard-code a
* ":summit" bound parameter inside DoctrineSpeakerRepository::getFilterMappings()'s
* DQL templates (e.g. "__p41_:i.summit = :summit"), because that method is shared
* verbatim by the summit-scoped query methods (getSpeakersBySummit,
* getSpeakersIdsBySummit), which bind that parameter on their own base query.
* getAllByPage()/getAllIdsByPage() never bind it - there is no single summit to
* scope by on a global listing - so applying any of those 13 fields there throws
* Doctrine\ORM\Query\QueryException ("too few parameters"), not a silent no-op.
* Verified by reproducing the exception directly against getAllByPage() with a
* presentations_track_id filter. Removing the hard-coded :summit clause from those
* DQL templates would need it to become conditional depending on caller context -
* real repository work, out of scope for a filter-whitelist unification.
*/
const GLOBAL_OPERATORS = [
'id' => ['=='],
'not_id' => ['=='],
'first_name' => ['=@', '@@', '=='],
'last_name' => ['=@', '@@', '=='],
'email' => ['=@', '@@', '=='],
'full_name' => ['=@', '@@', '=='],
'member_id' => ['=='],
'member_user_external_id' => ['=='],
];

const GLOBAL_VALIDATION_RULES = [
'id' => 'sometimes|integer',
'not_id' => 'sometimes|integer',
'first_name' => 'sometimes|string',
'last_name' => 'sometimes|string',
'email' => 'sometimes|string',
'full_name' => 'sometimes|string',
'member_id' => 'sometimes|integer',
'member_user_external_id' => 'sometimes|integer',
];
}
Loading
Loading