Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
eac186f
fix(repositories): add deterministic ORDER BY to getAllIdsByPage pagi…
smarcet Sep 8, 2026
8accd35
feat(attendees): add SummitAttendeeAnnouncementEmail sent-proof entity
smarcet Sep 8, 2026
5d72132
feat(attendees): wire the sent-proof resume check into every email st…
smarcet Sep 8, 2026
809c4d2
feat(attendees): chunk the bulk email send instead of one unbounded job
smarcet Sep 8, 2026
cabd31a
feat(attendees): add failed() hook and fix the extra-questions N+1
smarcet Sep 8, 2026
d0aafc7
test(attendees): add job-level regression tests for resume and failur…
smarcet Sep 8, 2026
7c9c1b3
test(attendees): strengthen the filter-based chunking test to span pages
smarcet Sep 8, 2026
af1b9fe
fix(attendees): correct the $announcement_emails PHPDoc collection type
smarcet Sep 8, 2026
5ed82d8
fix(attendees): correct the stale 2000 fallback for the job chunk size
smarcet Sep 8, 2026
aea09e9
fix(attendees): route resume-skip notices to the excerpt as INFO line…
smarcet Sep 8, 2026
1f7bff3
fix(attendees): skip explicit attendee ids that belong to another summit
smarcet Sep 8, 2026
b363a5c
fix(emails): resolve bulk-send ids inside one REPEATABLE READ transac…
smarcet Sep 9, 2026
91051af
chore(ci): run the bulk-email chunk/resume test classes in the push m…
smarcet Sep 9, 2026
d27d7fb
fix(emails): redact field names of a scalar filter in the failed-chun…
smarcet Sep 9, 2026
6bc5346
fix(emails): redact range and set operator operands in failed-chunk logs
smarcet Sep 9, 2026
d043893
fix(attendees): build the email strategy from the attendee's managed …
smarcet Sep 9, 2026
bff2b4f
fix(attendees): report a per-attendee send failure as an ERROR line i…
smarcet Sep 9, 2026
df5dc7e
test(attendees): seed the email flow types the resume and failed-hook…
smarcet Sep 9, 2026
874fa87
fix(speakers): resolve the summit inside each per-speaker send transa…
smarcet Sep 9, 2026
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: 2 additions & 0 deletions .github/workflows/push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ jobs:
# 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 tests/PresentationSubmissionReopenedEmailTest.php" }
- { name: "SpeakerSubmitterPublishedFilter", filter: "tests/ProcessSpeakersEmailRequestJobTest.php tests/ProcessSubmittersEmailRequestJobTest.php tests/SpeakerRepositoryTest.php tests/SubmitterRepositoryTest.php tests/SpeakerServiceOriginalFilterTest.php tests/SubmitterServiceOriginalFilterTest.php" }
- { name: "SpeakerBulkEmailChunkResume", filter: "tests/SpeakerServiceBulkSendChunkingTest.php tests/SpeakerServiceResumeSendEmailsTest.php tests/ProcessSpeakersEmailRequestJobResumeTest.php tests/ProcessSpeakersEmailRequestJobFailedHookTest.php" }
- { name: "AttendeeBulkEmailChunkResume", filter: "tests/AttendeeServiceBulkSendChunkingTest.php tests/AttendeeServiceResumeSendEmailsTest.php tests/ProcessAttendeesEmailRequestJobResumeTest.php tests/ProcessAttendeesEmailRequestJobFailedHookTest.php tests/SummitAttendeeAnnouncementEmailTest.php tests/DoctrineSummitAttendeeRepositoryTest.php tests/SummitExtraQuestionsMemoizationTest.php" }
- { name: "Repositories", filter: "tests/Repositories/" }
- { name: "Services", filter: "tests/Unit/Services/" }
- { name: "Integration", filter: "tests/Integration/" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
use utils\Filter;
use utils\FilterElement;
use Illuminate\Support\Facades\Validator;
use services\model\IAttendeeEmailFilterFields;
use utils\FilterParser;

/**
Expand Down Expand Up @@ -1485,42 +1486,7 @@ public function send($summit_id)
$filter = null;

if (Request::has('filter')) {
$filter = FilterParser::parse(Request::input('filter'), [
'id' => ['=='],
'not_id' => ['=='],
'first_name' => ['=@', '=='],
'last_name' => ['=@', '=='],
'full_name' => ['=@', '=='],
'company' => ['=@', '=='],
'has_company' => ['=='],
'email' => ['=@', '=='],
'external_order_id' => ['=@', '=='],
'external_attendee_id' => ['=@', '=='],
'member_id' => ['==', '>'],
'ticket_type' => ['=@', '==', '@@'],
'ticket_type_id' => ['=='],
'badge_type' => ['=@', '==', '@@'],
'badge_type_id' => ['=='],
'features' => ['=@', '==', '@@'],
'features_id' => ['=='],
'access_levels' => ['=@', '==', '@@'],
'access_levels_id' => ['=='],
'status' => ['=@', '=='],
'has_member' => ['=='],
'has_tickets' => ['=='],
'has_virtual_checkin' => ['=='],
'has_checkin' => ['=='],
'tickets_count' => ['==', '>=', '<=', '>', '<'],
'presentation_votes_date' => ['==', '>=', '<=', '>', '<'],
'presentation_votes_count' => ['==', '>=', '<=', '>', '<'],
'presentation_votes_track_group_id' => ['=='],
'summit_hall_checked_in_date' => ['==', '>=', '<=', '>', '<','[]'],
'tags' => ['=@', '==', '@@'],
'tags_id' => ['=='],
'notes' => ['=@', '@@'],
'has_notes' => ['=='],
'has_manager' => ['=='],
]);
$filter = FilterParser::parse(Request::input('filter'), IAttendeeEmailFilterFields::OPERATORS);
}

if (is_null($filter))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,15 @@ public function failed(\Throwable $e): void
*/
private function redactFilterFieldNames($filter): array
{
if (empty($filter) || !is_array($filter)) return [];
return array_map(fn($condition) => preg_replace('/[=<>@!].*/', '', (string)$condition), $filter);
if (empty($filter)) return [];
// FiltersParams::getFilterParam() passes the raw request value through: filter[] arrives
// as an array, a bare filter= as a string. FilterParser::parse accepts both by wrapping the
// scalar, so the redaction accepts the same shape instead of dropping the field names.
if (!is_array($filter)) $filter = [$filter];
$conditions = array_filter($filter, 'is_scalar');
// Cut at the first character of ANY operator FilterParser::filterExpresion recognizes
// (==, =@, @@, <>, <, >, <=, >=, [] range, () set) - [ and ( included, or a range/set
// condition's operands are logged verbatim.
return array_values(array_map(fn($condition) => preg_replace('/[=<>@!\[(].*/', '', (string)$condition), $conditions));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,33 @@
* See the License for the specific language governing permissions and
* limitations under the License.
**/
use App\Jobs\Emails\Registration\Attendees\SummitAttendeeExcerptEmail;
use App\Jobs\Emails\Traits\ResumableChunkJob;
use App\Jobs\Utils\JobDispatcher;
use App\Services\Model\IAttendeeService;
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\IAttendeeEmailFilterFields;
use utils\FilterParser;
/**
* Class ProcessAttendeesEmailRequestJob
* @package App\Jobs\Emails
*/
final class ProcessAttendeesEmailRequestJob implements ShouldQueue
{
public $timeout = 0;

public $tries = 1;

use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
// $timeout/$tries/$backoff and the resume-on-retry mechanics come from ResumableChunkJob -
// see that trait's doc comment for why timeout must stay below every retry_after / worker
// --timeout this job can run under.
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, ResumableChunkJob;

private $summit_id;

Expand Down Expand Up @@ -63,44 +70,124 @@ public function handle(IAttendeeService $service){
)
);

$filter = !is_null($this->filter) ? FilterParser::parse($this->filter, [
'id' => ['=='],
'not_id' => ['=='],
'first_name' => ['=@', '=='],
'last_name' => ['=@', '=='],
'full_name' => ['=@', '=='],
'company' => ['=@', '=='],
'has_company' => ['=='],
'email' => ['=@', '=='],
'external_order_id' => ['=@', '=='],
'external_attendee_id' => ['=@', '=='],
'member_id' => ['==', '>'],
'ticket_type' => ['=@', '==', '@@'],
'ticket_type_id' => ['=='],
'badge_type' => ['=@', '==', '@@'],
'badge_type_id' => ['=='],
'features' => ['=@', '==', '@@'],
'features_id' => ['=='],
'access_levels' => ['=@', '==', '@@'],
'access_levels_id' => ['=='],
'status' => ['=@', '=='],
'has_member' => ['=='],
'has_tickets' => ['=='],
'has_virtual_checkin' => ['=='],
'has_checkin' => ['=='],
'tickets_count' => ['==', '>=', '<=', '>', '<'],
'presentation_votes_date' => ['==', '>=', '<=', '>', '<'],
'presentation_votes_count' => ['==', '>=', '<=', '>', '<'],
'presentation_votes_track_group_id' => ['=='],
'summit_hall_checked_in_date' => ['==', '>=', '<=', '>', '<','[]'],
'tags' => ['=@', '==', '@@'],
'tags_id' => ['=='],
'notes' => ['=@', '@@'],
'has_notes' => ['=='],
'has_manager' => ['==']
]) : null;
// ResumableChunkJob::activateResumeIfRetrying(): resume, not resend. On a retry it sets
// resume_since = dispatched_at in $this->payload so AttendeeService::send skips only the
// attendees (or, for the ticket flow event, attendee+ticket pairs) whose proof for this
// run was written by THIS run.
$this->activateResumeIfRetrying();

$filter = !is_null($this->filter) ? FilterParser::parse($this->filter, IAttendeeEmailFilterFields::OPERATORS) : null;

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

/**
* Invoked by the queue worker once this job is marked failed - with ResumableChunkJob's
* tries = 2 and the resume check inside AttendeeService::send (Task 3), that means BOTH
* attempts failed: a chunk whose worker was merely killed mid-run (rolling deploy, OOM,
* scale-down) is re-served and automatically resumed once, skipping only the attendees this
* run already reached. This hook only fires when that automatic resume itself also failed to
* finish the chunk. Nothing else reports that loss - the outcome excerpt is only sent when
* send() runs to completion - so without this hook a dead chunk leaves no trace beyond a
* queue_failed_jobs row.
*
* Log the chunk's attendee 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. Mirrors
* ProcessSpeakersEmailRequestJob::failed(), simplified: attendees generate no promo codes on
* this path, so there is no should_resend/promo-code resend caveat to add.
*
* @param \Throwable $e
*/
public function failed(\Throwable $e): void
{
$attendee_ids = $this->payload['attendees_ids'] ?? [];
$flow_event = $this->payload['email_flow_event'] ?? '';
$ids_list = implode(', ', $attendee_ids);

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

$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("ProcessAttendeesEmailRequestJob::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 attendee(s) failed (%s); up to %s of them may not have been processed. Attendee ids in the chunk: %s.",
count($attendee_ids),
$e->getMessage(),
count($attendee_ids),
$ids_list
),
],
[
'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 (AttendeeService::triggerSend): 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 SummitAttendeeExcerptEmail($summit, $outcome_email_recipient, $report),
logContext: ['summit_id' => $this->summit_id, 'attendee_count' => count($attendee_ids)],
primaryConnection: Config::get('queue.default')
);
}
catch (\Throwable $ex) {
Log::error($ex);
}
}

/**
* Reduces a raw filter (["email==foo@bar.com", "first_name==Jane"]) to just its field names
* (["email", "first_name"]) so error logs never carry filter values that may be PII - email
* and first_name are valid filter fields (IAttendeeEmailFilterFields::OPERATORS).
*
* @param mixed $filter
* @return string[]
*/
private function redactFilterFieldNames($filter): array
{
if (empty($filter)) return [];
// FiltersParams::getFilterParam() passes the raw request value through: filter[] arrives
// as an array, a bare filter= as a string. FilterParser::parse accepts both by wrapping the
// scalar, so the redaction accepts the same shape instead of dropping the field names.
if (!is_array($filter)) $filter = [$filter];
$conditions = array_filter($filter, 'is_scalar');
// Cut at the first character of ANY operator FilterParser::filterExpresion recognizes
// (==, =@, @@, <>, <, >, <=, >=, [] range, () set) - [ and ( included, or a range/set
// condition's operands are logged verbatim.
return array_values(array_map(fn($condition) => preg_replace('/[=<>@!\[(].*/', '', (string)$condition), $conditions));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,12 @@ public function getSummitHallCheckedIn()
#[ORM\ManyToMany(targetEntity: \models\main\Tag::class, cascade: ['persist'], fetch: 'EXTRA_LAZY')]
private $tags;

/**
* @var \Doctrine\Common\Collections\Collection<int, SummitAttendeeAnnouncementEmail>
*/
#[ORM\OneToMany(targetEntity: \models\summit\SummitAttendeeAnnouncementEmail::class, mappedBy: 'attendee', cascade: ['persist', 'remove'], orphanRemoval: true, fetch: 'EXTRA_LAZY')]
private $announcement_emails;

/**
* @param bool $summit_hall_checked_in
*/
Expand Down Expand Up @@ -297,6 +303,51 @@ public function getFirstTicket():?SummitAttendeeTicket{
return $this->tickets->first();
}

public function addAnnouncementEmail(SummitAttendeeAnnouncementEmail $announcementEmail)
{
if ($this->announcement_emails->contains($announcementEmail)) return;
$this->announcement_emails->add($announcementEmail);
$announcementEmail->setAttendee($this);
}

public function removeAnnouncementEmail(SummitAttendeeAnnouncementEmail $announcementEmail)
{
if (!$this->announcement_emails->contains($announcementEmail)) return;
$this->announcement_emails->removeElement($announcementEmail);
$announcementEmail->clearAttendee();
}

/**
* Per-recipient, per-email-type, timestamped proof check backing the bulk-send resume
* predicate (AbstractEmailAction::alreadySentSince). Bounded matching() query on the
* EXTRA_LAZY announcement_emails collection - does not hydrate it. $ticket distinguishes
* SummitAttendeeTicketEmailStrategy's per-ticket proofs (one email per active paid ticket)
* from every other strategy's single per-attendee proof (ticket left null).
*
* @param Summit $summit
* @param string $type
* @param \DateTime $since
* @param SummitAttendeeTicket|null $ticket
* @return bool
*/
public function hasAnnouncementEmailTypeSentSince(Summit $summit, string $type, \DateTime $since, ?SummitAttendeeTicket $ticket = null): bool
{
$criteria = Criteria::create();

$criteria
->where(Criteria::expr()->eq('summit', $summit))
->andWhere(Criteria::expr()->eq('type', $type))
->andWhere(Criteria::expr()->gte('send_date', $since));

if (!is_null($ticket)) {
$criteria->andWhere(Criteria::expr()->eq('ticket', $ticket));
} else {
$criteria->andWhere(Criteria::expr()->isNull('ticket'));
}

return $this->announcement_emails->matching($criteria)->count() > 0;
}

/**
* @param SummitAttendeeTicket $ticket
*/
Expand Down Expand Up @@ -349,6 +400,7 @@ public function __construct()
$this->managed_attendees = new ArrayCollection();
$this->manager = null;
$this->rsvp_invitations = new ArrayCollection();
$this->announcement_emails = new ArrayCollection();
}

public function isVirtualCheckedIn(): bool
Expand Down
Loading
Loading