Skip to content

Commit d3bfdb6

Browse files
authored
fix(speakers): remove the quadratic hydration and the unordered pagination behind the bulk send ( promo codes ) (#593)
* fix(promo-codes): stop hydrating the whole owners collection to mark a speaker as sent SpeakersPromoCodeTrait::setEmailSent found the AssignedPromoCodeSpeaker to mark through $this->owners->filter(). Collection::filter() initializes the whole collection regardless of the EXTRA_LAZY mapping, and the closure then dereferenced ->getSpeaker()->getEmail() on every element, lazily hydrating one PresentationSpeaker per element as well. Every speaker of a bulk send shares the same promo code, so the cost of marking speaker N grew with the number of speakers already assigned to it, and every speaker of the run stayed pinned in the identity map. Over a 683 recipient send that is quadratic hydration on top of a heap that only grows. The lookup is now a targeted query. The e-mail matching mirrors PresentationSpeaker::getEmail(), which is computed rather than mapped: the member's e-mail wins, and the registration request's is only used when the speaker has no member. Behaviour is unchanged. The tests were written against the previous implementation and pass unmodified against this one. They cover both branches of the e-mail precedence, a speaker carrying both a member and a registration request, the scoping to this promo code when the same speaker is assigned to another one, and an unknown recipient. Signed-off-by: smarcet <smarcet@gmail.com> * fix(repositories): apply the default order regardless of the filter in getParametrizedAllIdsByPage The default-order callback of getParametrizedAllIdsByPage was chained to $filter instead of $order, so it only ran when no filter was given. Any filtered page was therefore emitted with LIMIT/OFFSET and no ORDER BY at all, and MySQL makes no promise about the order of such a result: paging through one can skip rows or return the same row on two different pages. The sibling getParametrizedAllByPage already chains the same callback to $order, which is the intended contract - an explicit order wins, otherwise the caller's default applies. This aligns the two. The only caller is DoctrineSpeakerRepository::getSpeakersIdsBySummit, whose callback is the default speaker order (e.id ASC). It never passes an explicit order, and ParametrizedSendEmails substitutes an empty Filter when none was given, so in practice the bulk speaker send has been paging an unordered query. The regression test asserts the generated DQL rather than paged data on purpose: an unordered query frequently happens to come back in a stable order, so a data-level test would pass by luck against the defect. It covers a filtered query, an unfiltered one, and that an explicit order still suppresses the default. Signed-off-by: smarcet <smarcet@gmail.com> * feat(speakers): chunk the bulk speaker email send and unify the speaker filter whitelists (#595) * feat(speakers): chunk the bulk speaker email send and unify its filter whitelist SpeakerService::triggerSendEmails dispatched a single ProcessSpeakersEmailRequestJob for the whole matched set, which the ParametrizedSendEmails trait then paged through internally inside that one queued job. A killed or failed job lost the entire run; on 2026-08-31 that lost 183 of 683 speakers mid-send with no trace. triggerSendEmails now resolves the full set of matched speaker ids synchronously (from an explicit speaker_ids payload if given, otherwise by paging getSpeakersIdsBySummit), applies excluded_speaker_ids, de-duplicates, and dispatches one ProcessSpeakersEmailRequestJob per 100-id chunk (SpeakerService::CHUNK_SIZE) - the same job class, unchanged in its own per-speaker processing. A killed or failed job now loses at most one chunk. Every dispatched chunk receives the exact same raw, unparsed $filter value triggerSendEmails itself received - not a Filter object, not the filter used to select ids. That raw value also scopes which of a speaker's presentations count as accepted/alternate/rejected inside SpeakerActionsEmailStrategy::process, which decides the email type sent to that speaker; passing anything else would silently change that. This is asserted directly in the tests via ReflectionObject on the dispatched job's private filter property. Two behavior changes, both deliberate: a filter matching zero speakers now dispatches nothing (previously a single job still ran and could send a "0 sent" outcome e-mail); and duplicate ids are de-duplicated before dispatch (previously a repeated id was emailed twice). While rewriting every filter-parsing call site the send path depends on, unify them onto a new ISpeakerFilterFields interface (OPERATORS + VALIDATION_RULES constants, following the IEmailExcerptService interface-constants precedent already used in this codebase). Three of the four call sites already used the same 19-21 fields; one (SpeakerService's original_filter parse) was missing presentations_track_group_id. The bulk-send endpoint gains member_id/member_user_external_id as valid filter fields as a result - they were already supported by the repository and by the sibling listing/CSV/count endpoints, just never wired into the send path. tests/SpeakerServiceBulkSendChunkingTest.php: 9 tests. Most use an explicit speaker_ids payload with fabricated ids rather than seeded speakers, since Queue::fake() intercepts dispatch before the job's handle() ever runs and that path never queries the repository. Only the filter-based-selection and member_id cases seed real speakers. Coverage: chunk count and non-overlapping slices above/at/below CHUNK_SIZE, zero-match, exclusion, de-duplication, payload key pass-through, raw filter identity across both the explicit-ids and filter-based paths, and that member_id actually narrows the query rather than merely being accepted. Two mutations were run against the implementation to confirm the tests have teeth: swapping the raw filter for the internally-parsed one at dispatch time, and removing the de-duplication step. Both were caught. Signed-off-by: smarcet <smarcet@gmail.com> * refactor(speakers): unify getSpeakers/getSpeakersActivitiesCount/getSpeakersCSV/getAll onto ISpeakerFilterFields getSpeakers(), getSpeakersActivitiesCount(), and getSpeakersCSV() already carried the exact 21-field whitelist ISpeakerFilterFields formalizes (byte-identical across all three). Point them at the shared interface instead of three independently-maintained inline copies. getAll() (the global, non-summit-scoped speaker listing) was planned to widen to the same 21 fields, matching the other three - but that's unsafe. Reproduced directly against getAllByPage(): applying presentations_track_id throws Doctrine\ORM\Query\QueryException ("too few parameters"). Every presentations_*/ has_*_presentations mapping in DoctrineSpeakerRepository::getFilterMappings() hard-codes a :summit bound parameter in its DQL (shared verbatim with the summit-scoped query methods, which bind it on their own base query); getAllByPage()/getAllIdsByPage() never do, because there is no single summit to scope a global listing by. 13 of the 21 fields hit this; only the 8 with no :summit reference (id, not_id, first_name, last_name, email, full_name, member_id, member_user_external_id) are safe on a summit-independent query - exactly getAll()'s original set. getAll() now references a new ISpeakerFilterFields::GLOBAL_OPERATORS/ GLOBAL_VALIDATION_RULES pair covering exactly those 8 fields, documented with the full field-by-field breakdown of why the other 13 don't apply. All four methods end up on one shared interface with zero drift risk; getAll() gains no new fields. Making the :summit clause conditional so those mappings work for both summit-scoped and global callers would be real repository-level work across ~13 shared DQL templates - out of scope here. Also corrects send()'s and getAll()'s Swagger filter descriptions, which were already stale before this change. tests/oauth2/OAuth2SummitSpeakersApiTest.php: testGetAllSpeakersFilteredByMemberId proves the swap to GLOBAL_OPERATORS doesn't regress getAll()'s existing member_id support; testGetAllSpeakersRejectsPresentationScopedFilter proves a presentation-scoped field still returns a clean validation error, not a 500. The other four target methods are covered by the existing functional suite (member/external-id/selection-plan/ media-upload/accepted/rejected/name filters), which passes unchanged - confirming the inline-array-to-constant swap is behavior-preserving there. Signed-off-by: smarcet <smarcet@gmail.com> * fix(speakers): dispatch bulk-send chunks through JobDispatcher::withDbFallback Plain ProcessSpeakersEmailRequestJob::dispatch() left the chunk loop exposed to a queue-backend failure part-way through: some chunks already queued, the rest lost with the aborted request, and an operator retry re-emailing the chunks that already went out (should_resend defaults to true and the admin UI never sends it, so re-runs are not deduplicated today). JobDispatcher::withDbFallback tries the primary connection, fails over to the database queue, and runs the chunk synchronously on a double failure - same pattern as PresentationSubmissionReopenService::notify's per-recipient loop. Each iteration additionally wraps the dispatch in its own try/catch so one chunk whose three fallback tiers all failed cannot abort the sibling chunks that would have succeeded - the chunk-isolation property this whole feature exists for. That catch logs at error level: by then the primary, the database fallback, and the synchronous run have all failed, which is an alert-worthy infrastructure event, not a routine warning. Signed-off-by: smarcet <smarcet@gmail.com> * test(speakers): cover chunk-failure isolation and the send() member filter at the HTTP layer Addresses the three findings from the changes review: - testOneChunkFailingAllFallbackTiersDoesNotAbortSiblingChunks: forces every Bus dispatch (queued and sync) to throw so all three JobDispatcher fallback tiers fail for every chunk, then asserts the loop still visited every chunk (one-plus Log::error per chunk) instead of aborting on the first. Mutation-verified: moving the per-chunk try/catch outside the foreach fails the count; removing it fails on the propagated exception. - testSendSpeakersBulkEmailFilteredByMemberUserExternalId: drives the real PUT send() action with a member_user_external_id filter, exercising the controller's FilterParser::parse + Filter::validate against the shared ISpeakerFilterFields constants and the service's id resolution end to end - the review noted the member filter was only proven at the service layer, and only for member_id. - Rewords the getAll() code comment so it no longer contains the literal "ISpeakerFilterFields::" substring, making Task 2's documented DoD grep count (8) match what the command actually returns. Signed-off-by: smarcet <smarcet@gmail.com> * fix(speakers): report a lost bulk-send chunk from ProcessSpeakersEmailRequestJob::failed() With tries = 1, a chunk whose worker is killed mid-run sits reserved until the connection's retry_after elapses, is re-served, and is marked failed without re-running. Nothing reported that loss: the outcome excerpt is only sent when sendEmails() runs to completion, so a dead chunk left no trace beyond a queue_failed_jobs row - the same silence as the 2026-08-31 incident, capped at 100 speakers instead of the whole run. The new failed() hook (invoked by Job::fail() -> CallQueuedHandler::failed(), including the sync tier of JobDispatcher::withDbFallback) logs the summit, flow event, exception, unprocessed speaker ids and raw filter at error level, and when the payload carries outcome_email_recipient dispatches PresentationSpeakerSelectionProcessExcerptEmail with an ERROR line naming the ids and the cause, so the operator can re-send that chunk by id. The excerpt dispatch is best-effort inside a try/catch so it never masks the original failure; without a recipient the hook only logs and never touches the database. tests/ProcessSpeakersEmailRequestJobFailedHookTest.php invokes the hook directly (Queue::fake stops at dispatch, so the framework plumbing cannot be driven end to end here) and pins both paths. Mutation-verified: dropping the recipient guard fails the "exactly one error line" expectation, dropping the ids from the ERROR line fails the id assertion. Named apart from ProcessSpeakersEmailRequestJobTest.php, which exists on another branch. Signed-off-by: smarcet <smarcet@gmail.com> * fix(speakers): add chunk context to the all-tiers-failed log and prove send() narrowing with a control speaker Two review-thread follow-ups on #595: - SpeakerService::triggerSendEmails's per-chunk catch logged only the Throwable. JobDispatcher::withDbFallback already logs the first two tiers with summit_id / speaker_count and, since 126616d, the sync tier's failure runs ProcessSpeakersEmailRequestJob::failed() before this catch fires, but the last line should stand on its own: it now names the summit, the chunk size, the exception and the unprocessed speaker ids, with summit_id / speaker_ids / exception in the context array. - testSendSpeakersBulkEmailFilteredByMemberUserExternalId asserted only that the filtered member's speaker was in the dispatched chunk. Exact equality alone would not have proven narrowing either - the fixture summit has a single speaker with presentations - so the test now seeds a control speaker (different member, with a presentation in the summit) and requires the chunk to equal exactly [defaultSpeaker]. Mutation-verified: a filter matching every speaker (not_id==0) fails it on the control speaker. Signed-off-by: smarcet <smarcet@gmail.com> * fix(speakers): stop reporting a failed chunk as fully unprocessed and cover multi-page id resolution ProcessSpeakersEmailRequestJob::failed() said "chunk of N speaker(s) NOT processed. Unprocessed speaker ids: [...]" for every id in the chunk. The chunk is processed one speaker per transaction, so the hook's own trigger case (a worker killed mid-run) has already e-mailed and written the "already sent" proof for the speakers before the kill. An operator re-sending that list from summit-admin, which never sends should_resend, would mail those speakers twice (the DTO defaults should_resend to true). The log line and the excerpt ERROR line now say up to N of them may not have been processed, name the ids as the chunk's ids, and tell the operator to re-send with should_resend=false so the resend guard skips the ones with a proof. When the send carries a promo_code_spec the same line warns that a re-send creates a new code for every speaker in the list, because AutomaticMultiSpeakerPromoCodeStrategy generates a fresh code before the resend guard runs. The INFO line no longer claims 0 processed. Add a chunking test for the filter-based path across a page boundary: CHUNK_SIZE + 1 seeded speakers behind a first_name filter must resolve into exactly two chunks (100 + 1) covering every seeded id once, with the fixture speaker left out. This is the only path summit-admin drives and nothing exercised the do/while beyond a single page. Mutation-verified: dropping the array_merge of the pages fails it. * fix(queue): give the database fallback connection a retry_after matching redis The database connection only carried the dead Laravel-4 'expire' key, so Laravel applied its 60 s retry_after default. A ProcessSpeakersEmailRequestJob chunk that failed over to that tier (JobDispatcher::withDbFallback) and ran longer than 60 s was re-served by a sibling worker-db-fallback replica, failed on tries = 1, and the failed() hook reported a false chunk loss while the original run was still completing. Align it with the redis primary (1800, DB_QUEUE_RETRY_AFTER) and drop the unused key. * fix(speakers): route the failed-chunk excerpt through JobDispatcher::withDbFallback ProcessSpeakersEmailRequestJob::failed() dispatched the lost-chunk excerpt with a bare ::dispatch() on the default connection. A chunk runs on the database fallback worker precisely when the redis primary was down at dispatch time, so if it then failed while redis was still down the excerpt push threw, the best-effort catch swallowed it, and the operator report was lost in the one scenario it exists for. Dispatch it through JobDispatcher::withDbFallback with primaryConnection following queue.default, the same route the chunk itself takes in SpeakerService::triggerSendEmails. The try/catch stays so an excerpt failure never masks the original one. Adds a test that scripts the primary dispatch to throw and asserts the excerpt is re-dispatched on the database connection with the chunk's speaker ids; it fails against the bare dispatch (nothing captured) and passes with the fallback. * chore(config): add chunk sizes to config chore(debug): add log info * test(speakers): fix chunk-size assumptions in bulk send chunking tests SpeakerService::CHUNK_SIZE does not exist on the class; two other tests in the same file separately hardcoded a 100-based chunk size. All three now read emails.speakers_process_job_chunk_size (default 200), the same value triggerSendEmails() actually chunks by. Verified against a real dev send of 661 speakers: the app log's chunk sizes (200/200/200/61) matched the summit-admin CSV export exactly. CodeRabbit also proposed lowering the config default from 200 to 100; rejected — 200 is the value actually in effect and verified correct. * fix(speakers): stop logging access_token and raw filter PII in bulk send debug/error logs SpeakerService::triggerSendEmails's debug log json_encode()'d the full payload, including access_token - confirmed leaking a live bearer token in a real dev send today. Replaced with summit id, flow_event, speaker_ids count and whether a filter was given. ProcessSpeakersEmailRequestJob::failed()'s error log json_encode()'d the raw filter, which can carry email/full_name PII (valid speaker filter fields). New redactFilterFieldNames() logs only the filter's field names. Found and confirmed via adversarial review of CodeRabbit's full-review findings on PR #595. test(speakers): add red-green verified regression test for filter PII redaction testFailedChunkLogsFilterFieldNamesButNotTheirValues asserts the failed() error log contains the filter's field names but not an email value from it; reverting the redaction makes it fail (Mockery 0 matching calls). --------- Signed-off-by: smarcet <smarcet@gmail.com> --------- Signed-off-by: smarcet <smarcet@gmail.com>
1 parent ee22a21 commit d3bfdb6

14 files changed

Lines changed: 1546 additions & 253 deletions

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ SESSION_COOKIE_SECURE=false
3434
QUEUE_DRIVER=database
3535
QUEUE_CONN=
3636
QUEUE_DATABASE=
37+
DB_QUEUE_RETRY_AFTER=1800
3738

3839
MAIL_DRIVER=sendgrid
3940
SENDGRID_API_KEY='YOUR_SENDGRID_API_KEY'

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

Lines changed: 14 additions & 203 deletions
Large diffs are not rendered by default.

app/Jobs/Emails/PresentationSubmissions/ProcessSpeakersEmailRequestJob.php

Lines changed: 120 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,19 @@
1111
* See the License for the specific language governing permissions and
1212
* limitations under the License.
1313
**/
14+
use App\Jobs\Emails\PresentationSubmissions\SelectionProcess\PresentationSpeakerSelectionProcessExcerptEmail;
15+
use App\Jobs\Utils\JobDispatcher;
16+
use App\Services\utils\IEmailExcerptService;
1417
use Illuminate\Bus\Queueable;
1518
use Illuminate\Contracts\Queue\ShouldQueue;
1619
use Illuminate\Foundation\Bus\Dispatchable;
1720
use Illuminate\Queue\InteractsWithQueue;
1821
use Illuminate\Queue\SerializesModels;
22+
use Illuminate\Support\Facades\App;
23+
use Illuminate\Support\Facades\Config;
1924
use Illuminate\Support\Facades\Log;
2025
use models\summit\ISummitRepository;
26+
use models\summit\Summit;
2127
use services\model\ISpeakerService;
2228
use utils\FilterParser;
2329
/**
@@ -79,28 +85,121 @@ public function handle
7985
)
8086
);
8187

82-
$filter = !is_null($this->filter) ? FilterParser::parse($this->filter, [
83-
'id' => ['=='],
84-
'not_id' => ['=='],
85-
'first_name' => ['=@', '@@', '=='],
86-
'last_name' => ['=@', '@@', '=='],
87-
'email' => ['=@', '@@', '=='],
88-
'full_name' => ['=@', '@@', '=='],
89-
'has_accepted_presentations' => ['=='],
90-
'has_alternate_presentations' => ['=='],
91-
'has_rejected_presentations' => ['=='],
92-
'presentations_track_id' => ['=='],
93-
'presentations_track_group_id' => ['=='],
94-
'presentations_selection_plan_id' => ['=='],
95-
'presentations_type_id' => ['=='],
96-
'presentations_title' => ['=@', '@@', '=='],
97-
'presentations_abstract' => ['=@', '@@', '=='],
98-
'presentations_submitter_full_name' => ['=@', '@@', '=='],
99-
'presentations_submitter_email' => ['=@', '@@', '=='],
100-
'has_media_upload_with_type' => ['=='],
101-
'has_not_media_upload_with_type' => ['=='],
102-
]) : null;
88+
$filter = !is_null($this->filter) ? FilterParser::parse($this->filter, \services\model\ISpeakerFilterFields::OPERATORS) : null;
10389

10490
$service->sendEmails($this->summit_id, $this->payload, $filter);
10591
}
92+
93+
/**
94+
* Invoked by the queue worker once this job is marked failed. With tries = 1 that includes a
95+
* chunk whose worker was killed mid-run: the job sits reserved until the connection's
96+
* retry_after elapses, is re-served, and is failed without re-running. Nothing else reports
97+
* that loss - the outcome excerpt is only sent when sendEmails() runs to completion - so
98+
* without this hook a dead chunk leaves no trace beyond a queue_failed_jobs row.
99+
*
100+
* Log the chunk's speaker ids at error, and when the operator asked for an outcome e-mail
101+
* send one naming them, so the chunk can be re-sent by id. The chunk is processed one speaker
102+
* per transaction, so a worker killed mid-run has already e-mailed (and written the "already
103+
* sent" proof for) the speakers before the kill: the ids are an upper bound on what was lost,
104+
* not confirmed misses, and both messages say so and tell the operator to re-send with
105+
* should_resend=false so the resend guard skips the speakers that already have a proof. The
106+
* excerpt goes through JobDispatcher::withDbFallback (primary, then the database queue, then
107+
* an inline run) and is best-effort: a failure there must not mask the original failure.
108+
*
109+
* @param \Throwable $e
110+
*/
111+
public function failed(\Throwable $e): void
112+
{
113+
$speaker_ids = $this->payload['speaker_ids'] ?? [];
114+
$flow_event = $this->payload['email_flow_event'] ?? '';
115+
$ids_list = implode(', ', $speaker_ids);
116+
117+
$resend_hint = "Re-send these ids with should_resend=false so the speakers already e-mailed are skipped";
118+
if (isset($this->payload['promo_code_spec'])) {
119+
// AutomaticMultiSpeakerPromoCodeStrategy::getPromoCode() generates a fresh code on every
120+
// call, before the resend guard runs, so should_resend=false does not prevent this one.
121+
$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";
122+
}
123+
124+
Log::error
125+
(
126+
sprintf
127+
(
128+
"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.",
129+
$this->summit_id,
130+
$flow_event,
131+
count($speaker_ids),
132+
get_class($e),
133+
$e->getMessage(),
134+
count($speaker_ids),
135+
$ids_list,
136+
json_encode($this->redactFilterFieldNames($this->filter)),
137+
$resend_hint
138+
)
139+
);
140+
141+
$outcome_email_recipient = $this->payload['outcome_email_recipient'] ?? null;
142+
if (empty($outcome_email_recipient)) return;
143+
144+
try {
145+
$summit = App::make(ISummitRepository::class)->getById($this->summit_id);
146+
if (!$summit instanceof Summit) {
147+
Log::warning(sprintf("ProcessSpeakersEmailRequestJob::failed summit %s not found, outcome excerpt not sent", $this->summit_id));
148+
return;
149+
}
150+
151+
// Same line types AbstractExcerptEmailJob renders for a completed run, so the
152+
// operator's inbox reads the same either way.
153+
$report = [
154+
[
155+
'type' => IEmailExcerptService::InfoType,
156+
'message' => sprintf("Processing EMAIL %s for Summit %s", $flow_event, $this->summit_id),
157+
],
158+
[
159+
'type' => IEmailExcerptService::ErrorType,
160+
'message' => sprintf
161+
(
162+
"Chunk of %s speaker(s) failed (%s); up to %s of them may not have been processed. Speaker ids in the chunk: %s. %s",
163+
count($speaker_ids),
164+
$e->getMessage(),
165+
count($speaker_ids),
166+
$ids_list,
167+
$resend_hint
168+
),
169+
],
170+
[
171+
'type' => IEmailExcerptService::InfoType,
172+
'message' => "TOTAL processed for this chunk is unknown, the job did not run to completion",
173+
],
174+
];
175+
176+
// Same failover route as the chunk itself (SpeakerService::triggerSendEmails): a chunk
177+
// runs on the database fallback worker precisely when the redis primary was down at
178+
// dispatch time, so a bare ::dispatch() here would throw into the catch below and lose
179+
// the report in the one scenario it exists for.
180+
JobDispatcher::withDbFallback(
181+
job: new PresentationSpeakerSelectionProcessExcerptEmail($summit, $outcome_email_recipient, $report),
182+
logContext: ['summit_id' => $this->summit_id, 'speaker_count' => count($speaker_ids)],
183+
primaryConnection: Config::get('queue.default')
184+
);
185+
}
186+
catch (\Throwable $ex) {
187+
Log::error($ex);
188+
}
189+
}
190+
191+
/**
192+
* Reduces a raw filter (["email==foo@bar.com", "presentations_selection_plan_id==78"]) to
193+
* just its field names (["email", "presentations_selection_plan_id"]) so error logs never
194+
* carry filter values that may be PII - email and full_name are valid filter fields
195+
* (ISpeakerFilterFields::OPERATORS).
196+
*
197+
* @param mixed $filter
198+
* @return string[]
199+
*/
200+
private function redactFilterFieldNames($filter): array
201+
{
202+
if (empty($filter) || !is_array($filter)) return [];
203+
return array_map(fn($condition) => preg_replace('/[=<>@!].*/', '', (string)$condition), $filter);
204+
}
106205
}

app/Models/Foundation/Summit/Registration/PromoCodes/Traits/SpeakersPromoCodeTrait.php

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -271,9 +271,30 @@ public function setEmailSent(string $recipient = null)
271271
);
272272

273273
try{
274-
$existing_owner = $this->owners->filter(function ($e) use($recipient){
275-
return strtolower($e->getSpeaker()->getEmail()) == strtolower(trim($recipient)) && !$e->isSent();
276-
})->first();
274+
$recipient = strtolower(trim($recipient ?? ''));
275+
276+
if (empty($recipient))
277+
throw new ValidationException("Can't mark the promo_code as sent without a recipient.");
278+
279+
// Resolve the assignment with a targeted query instead of $this->owners->filter():
280+
// Collection::filter() initializes the whole collection regardless of the EXTRA_LAZY
281+
// mapping and then dereferences ->getSpeaker()->getEmail() on every element, so the
282+
// cost of marking one speaker grows with the number of speakers already assigned to
283+
// this code - quadratic over a bulk send.
284+
// The matching mirrors PresentationSpeaker::getEmail(): the member's e-mail wins, and
285+
// the registration request's is only used when the speaker has no member.
286+
$query = $this->createQuery("SELECT e from models\summit\AssignedPromoCodeSpeaker e
287+
JOIN e.registration_promo_code pc
288+
JOIN e.speaker s
289+
LEFT JOIN s.member m
290+
LEFT JOIN s.registration_request rr
291+
WHERE pc.id = :promo_code_id and e.sent is null
292+
and ( LOWER(m.email) = :recipient or ( m.id is null and LOWER(rr.email) = :recipient ) )");
293+
294+
$query->setParameter('promo_code_id', $this->getId());
295+
$query->setParameter('recipient', $recipient);
296+
297+
$existing_owner = $query->setMaxResults(1)->getOneOrNullResult();
277298

278299
if (!$existing_owner instanceof AssignedPromoCodeSpeaker)
279300
throw new ValidationException("Can't find an owner with the email {$recipient} for the promo_code.");

app/Repositories/DoctrineRepository.php

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -242,15 +242,19 @@ public function getParametrizedAllIdsByPage(callable $fnQuery,PagingInfo $paging
242242
if(!is_null($filter)){
243243
$filter->apply2Query($query, $this->getFilterMappings($filter));
244244
}
245-
else if(!is_null($fnDefaultFilter)){
246-
$query = call_user_func($fnDefaultFilter, $query);
247-
}
248245

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

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

255259
$query = $query
256260
->setFirstResult($paging_info->getOffset())
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
<?php namespace services\model;
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+
15+
/**
16+
* Interface ISpeakerFilterFields
17+
*
18+
* The speaker filter field whitelist shared by every summit-scoped speaker filter/search
19+
* endpoint and the bulk speaker email send path. Referenced directly by name
20+
* (ISpeakerFilterFields::OPERATORS / ::VALIDATION_RULES) without implementing it, the same
21+
* way IEmailExcerptService's constants are used.
22+
*
23+
* @package services\model
24+
*/
25+
interface ISpeakerFilterFields
26+
{
27+
const OPERATORS = [
28+
'id' => ['=='],
29+
'not_id' => ['=='],
30+
'first_name' => ['=@', '@@', '=='],
31+
'last_name' => ['=@', '@@', '=='],
32+
'email' => ['=@', '@@', '=='],
33+
'full_name' => ['=@', '@@', '=='],
34+
'member_id' => ['=='],
35+
'member_user_external_id' => ['=='],
36+
'has_accepted_presentations' => ['=='],
37+
'has_alternate_presentations' => ['=='],
38+
'has_rejected_presentations' => ['=='],
39+
'presentations_track_id' => ['=='],
40+
'presentations_track_group_id' => ['=='],
41+
'presentations_selection_plan_id' => ['=='],
42+
'presentations_type_id' => ['=='],
43+
'presentations_title' => ['=@', '@@', '=='],
44+
'presentations_abstract' => ['=@', '@@', '=='],
45+
'presentations_submitter_full_name' => ['=@', '@@', '=='],
46+
'presentations_submitter_email' => ['=@', '@@', '=='],
47+
'has_media_upload_with_type' => ['=='],
48+
'has_not_media_upload_with_type' => ['=='],
49+
];
50+
51+
const VALIDATION_RULES = [
52+
'id' => 'sometimes|integer',
53+
'not_id' => 'sometimes|integer',
54+
'first_name' => 'sometimes|string',
55+
'last_name' => 'sometimes|string',
56+
'email' => 'sometimes|string',
57+
'full_name' => 'sometimes|string',
58+
'member_id' => 'sometimes|integer',
59+
'member_user_external_id' => 'sometimes|integer',
60+
'has_accepted_presentations' => 'sometimes|string|in:true,false',
61+
'has_alternate_presentations' => 'sometimes|string|in:true,false',
62+
'has_rejected_presentations' => 'sometimes|string|in:true,false',
63+
'presentations_track_id' => 'sometimes|integer',
64+
'presentations_track_group_id' => 'sometimes|integer',
65+
'presentations_selection_plan_id' => 'sometimes|integer',
66+
'presentations_type_id' => 'sometimes|integer',
67+
'presentations_title' => 'sometimes|string',
68+
'presentations_abstract' => 'sometimes|string',
69+
'presentations_submitter_full_name' => 'sometimes|string',
70+
'presentations_submitter_email' => 'sometimes|string',
71+
'has_media_upload_with_type' => 'sometimes|integer',
72+
'has_not_media_upload_with_type' => 'sometimes|integer',
73+
];
74+
75+
/**
76+
* The subset of OPERATORS safe for a summit-independent query (e.g.
77+
* OAuth2SummitSpeakersApiController::getAll(), which uses
78+
* DoctrineSpeakerRepository::getAllByPage() / getAllIdsByPage()).
79+
*
80+
* The 13 fields excluded here (has_accepted/alternate/rejected_presentations,
81+
* every presentations_* field, has_(not_)media_upload_with_type) all hard-code a
82+
* ":summit" bound parameter inside DoctrineSpeakerRepository::getFilterMappings()'s
83+
* DQL templates (e.g. "__p41_:i.summit = :summit"), because that method is shared
84+
* verbatim by the summit-scoped query methods (getSpeakersBySummit,
85+
* getSpeakersIdsBySummit), which bind that parameter on their own base query.
86+
* getAllByPage()/getAllIdsByPage() never bind it - there is no single summit to
87+
* scope by on a global listing - so applying any of those 13 fields there throws
88+
* Doctrine\ORM\Query\QueryException ("too few parameters"), not a silent no-op.
89+
* Verified by reproducing the exception directly against getAllByPage() with a
90+
* presentations_track_id filter. Removing the hard-coded :summit clause from those
91+
* DQL templates would need it to become conditional depending on caller context -
92+
* real repository work, out of scope for a filter-whitelist unification.
93+
*/
94+
const GLOBAL_OPERATORS = [
95+
'id' => ['=='],
96+
'not_id' => ['=='],
97+
'first_name' => ['=@', '@@', '=='],
98+
'last_name' => ['=@', '@@', '=='],
99+
'email' => ['=@', '@@', '=='],
100+
'full_name' => ['=@', '@@', '=='],
101+
'member_id' => ['=='],
102+
'member_user_external_id' => ['=='],
103+
];
104+
105+
const GLOBAL_VALIDATION_RULES = [
106+
'id' => 'sometimes|integer',
107+
'not_id' => 'sometimes|integer',
108+
'first_name' => 'sometimes|string',
109+
'last_name' => 'sometimes|string',
110+
'email' => 'sometimes|string',
111+
'full_name' => 'sometimes|string',
112+
'member_id' => 'sometimes|integer',
113+
'member_user_external_id' => 'sometimes|integer',
114+
];
115+
}

0 commit comments

Comments
 (0)