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
1 change: 1 addition & 0 deletions .github/workflows/push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ jobs:
- { 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: "AttendeeMemberEmailConflict", filter: "tests/AttendeeMemberEmailConflictTest.php" }
- { name: "SummitJsonGenerator", filter: "tests/SummitJsonGeneratorCommandTest.php" }
- { name: "Repositories", filter: "tests/Repositories/" }
- { name: "Services", filter: "tests/Unit/Services/" }
Expand Down
142 changes: 142 additions & 0 deletions app/Console/Commands/RepairAttendeeMemberEmailPairsCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<?php namespace App\Console\Commands;
/**
* 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\UpdateAttendeeInfo;
use App\Jobs\Utils\JobDispatcher;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

/**
* Class RepairAttendeeMemberEmailPairsCommand
*
* A pair is an attendee linked to a member whose email differs from the member's current one,
* plus another attendee of the same summit with no member that already has the member's email.
* Only UpdateAttendeeInfo merges a pair, and only when the member's email is verified.
*
* Without --dispatch it only reads: run it before and after the repair.
* @package App\Console\Commands
*/
class RepairAttendeeMemberEmailPairsCommand extends Command
{
/**
* @var string
*/
protected $name = 'summit:repair-attendee-member-email-pairs';

/**
* @var string
*/
protected $signature = 'summit:repair-attendee-member-email-pairs {--dispatch : enqueue UpdateAttendeeInfo for each member of a pair with a verified email} {--limit= : dispatch at most this many members} {--after-member-id= : only dispatch members with an id greater than this one, to resume after the last dispatched member}';

/**
* @var string
*/
protected $description = 'Lists the attendee/member email pairs and dispatches UpdateAttendeeInfo for the ones with a verified email';

/**
* @return array
*/
private function getPairs(): array
{
return DB::connection('model')->select(<<<'SQL'
SELECT
a.SummitID AS summit_id,
m.ID AS member_id,
m.EmailVerified AS email_verified,
a.ID AS attendee_id,
b.ID AS memberless_attendee_id
FROM SummitAttendee a
JOIN Member m ON m.ID = a.MemberID
JOIN SummitAttendee b
ON b.SummitID = a.SummitID AND b.MemberID IS NULL AND b.Email = m.Email
WHERE NOT (a.Email <=> m.Email)
ORDER BY a.SummitID, m.EmailVerified DESC, m.ID
SQL
);
}

/**
* @return int
*/
public function handle(): int
{
$pairs = $this->getPairs();

$groups = [];
foreach ($pairs as $pair) {
$key = sprintf("%s|%s", $pair->summit_id, $pair->email_verified);
$groups[$key] = ($groups[$key] ?? 0) + 1;
}
ksort($groups);

$this->info(sprintf("%s pairs", count($pairs)));
$rows = [];
foreach ($groups as $key => $count) {
[$summit_id, $verified] = explode('|', $key);
$rows[] = [$summit_id, $verified ? 'yes' : 'no', $count];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
$this->table(['summit_id', 'member email verified', 'pairs'], $rows);

// ids only, no emails: enough to inspect the attendees and to follow up on the unverified ones
$detail = [];
foreach ($pairs as $pair) {
$detail[] = [
$pair->summit_id,
$pair->member_id,
$pair->email_verified ? 'yes' : 'no',
$pair->attendee_id,
$pair->memberless_attendee_id,
];
}
$this->table(['summit_id', 'member_id', 'member email verified', 'attendee_id', 'member-less attendee_id'], $detail);

if (!$this->option('dispatch')) {
$this->line('Dry run, nothing dispatched. Use --dispatch to enqueue UpdateAttendeeInfo for the verified ones.');
return 0;
}

// one job per member: it updates the member's attendees in every summit
$member_ids = [];
foreach ($pairs as $pair) {
if (!$pair->email_verified) continue;
$member_ids[(int)$pair->member_id] = true;
}
$member_ids = array_keys($member_ids);
// stable order, so --after-member-id can resume past a member whose job keeps failing
sort($member_ids);

$after_member_id = $this->option('after-member-id');
if (!is_null($after_member_id) && $after_member_id !== '') {
$after_member_id = intval($after_member_id);
$member_ids = array_values(array_filter($member_ids, fn(int $id) => $id > $after_member_id));
}

$limit = $this->option('limit');
if (!is_null($limit) && $limit !== '')
$member_ids = array_slice($member_ids, 0, max(0, intval($limit)));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

foreach ($member_ids as $member_id) {
Log::info(sprintf("RepairAttendeeMemberEmailPairsCommand dispatching UpdateAttendeeInfo member id %s", $member_id));
JobDispatcher::withDbFallback(
job: new UpdateAttendeeInfo($member_id),
logContext: ['member_id' => $member_id]
);
}

$this->info(sprintf("dispatched UpdateAttendeeInfo for %s members", count($member_ids)));
if (count($member_ids))
$this->line(sprintf("last member id dispatched: %s (resume with --after-member-id=%s)", end($member_ids), end($member_ids)));
return 0;
}
}
1 change: 1 addition & 0 deletions app/Console/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class Kernel extends ConsoleKernel
\App\Console\Commands\SummitEmailFlowTypeSeederCommand::class,
\App\Console\Commands\PresentationMaterialsCreateMUXAssetsCommand::class,
\App\Console\Commands\RecalculateAttendeesStatusCommand::class,
\App\Console\Commands\RepairAttendeeMemberEmailPairsCommand::class,
\App\Console\Commands\EnableMP4SupportAtMUXCommand::class,
\App\Console\Commands\SummitMediaUploadMigratePrivateToPublicStorage::class,
\App\Console\Commands\PresentationMediaUploadsRegenerateTemporalLinks::class,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,15 @@ public function getByEmail(string $email);
*/
public function getByEmailAndMemberNotSet(string $email);

/**
* Member-less attendees with the member's email that can be linked to the member, that is, the
* ones in summits where the member has no attendee yet (SummitAttendee_Member_Summit allows one).
* Logs a warning for each skipped summit.
* @param Member $member
* @return SummitAttendee[]
*/
public function getLinkableByMember(Member $member): array;

/**
* @param Summit $summit
* @param null|string $external_id
Expand Down
30 changes: 30 additions & 0 deletions app/Repositories/Summit/DoctrineSummitAttendeeRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,36 @@ public function getByEmailAndMemberNotSet(string $email)
return $query->getQuery()->getResult();
}

/**
* @param Member $member
* @return SummitAttendee[]
*/
public function getLinkableByMember(Member $member): array
{
$res = [];
foreach ($this->getByEmailAndMemberNotSet($member->getEmail()) as $attendee) {
if (!$attendee instanceof SummitAttendee) continue;
$summit = $attendee->getSummit();
$member_attendee = $summit->getAttendeeByMember($member);
if (!is_null($member_attendee)) {
Log::warning
(
sprintf
(
"DoctrineSummitAttendeeRepository::getLinkableByMember member %s already has attendee %s in summit %s, skipping member-less attendee %s.",
$member->getId(),
$member_attendee->getId(),
$summit->getId(),
$attendee->getId()
)
);
continue;
}
$res[] = $attendee;
}
return $res;
}

/**
* @param Summit $summit
* @param string $email
Expand Down
89 changes: 58 additions & 31 deletions app/Services/Model/AttendeeService.php
Original file line number Diff line number Diff line change
Expand Up @@ -982,49 +982,77 @@ public function updateAttendeesByMemberId(int $member_id): void
$summit = $attendee->getSummit();
if (!$summit instanceof Summit) continue;

// check if we have an attendee without member set and with same email
$attendee_without_member = $summit->getAttendeeByEmailAndMemberNotSet($email);
// check if the email is already taken by another attendee of the summit
$attendee_with_email = $summit->getAttendeeByEmail($email);
$update_email = true;

if (!is_null($attendee_without_member) && $member->isEmailVerified()) {
if (!is_null($attendee_with_email) && $attendee_with_email->getId() != $attendee->getId()) {

Log::debug
(
sprintf
$attendee_without_member = $summit->getAttendeeByEmailAndMemberNotSet($email);

if (!is_null($attendee_without_member) && $member->isEmailVerified()) {

Log::debug
(
"AttendeeService::updateAttendeesByMemberId found attendee %s with email %s and member not set ( marking it for deletion ).",
$attendee_without_member->getId(),
$attendee_without_member->getEmail()
)
);
sprintf
(
"AttendeeService::updateAttendeesByMemberId found attendee %s with email %s and member not set ( marking it for deletion ).",
$attendee_without_member->getId(),
$attendee_without_member->getEmail()
)
);

// mark the attendee to be deleted bc its dupe
// mark the attendee to be deleted bc its dupe

$attendees_2_delete[] = $attendee_without_member->getId();
$attendees_2_delete[] = $attendee_without_member->getId();

// overwrite email to avoid FK exception
$attendee_without_member->setEmail($attendee_without_member->getEmail() . '-deleted');
// overwrite email to avoid unique key exception: unique per attendee and
// always fits SummitAttendee.Email (varchar 100), unlike email + suffix
$attendee_without_member->setEmail(sprintf("%s-deleted", $attendee_without_member->getId()));
// flush now: Doctrine runs the UPDATEs in the order the entities were loaded,
// and the member's attendee was loaded first, so it would take the email
// while this row still has it.
$this->attendee_repository->add($attendee_without_member, true);

// check if we have ticket and re-assign them
if ($attendee_without_member->hasTickets()) {
foreach ($attendee_without_member->getTickets() as $ticket) {
Log::debug
(
sprintf
// check if we have ticket and re-assign them
if ($attendee_without_member->hasTickets()) {
foreach ($attendee_without_member->getTickets() as $ticket) {
Log::debug
(
"AttendeeService::updateAttendeesByMemberId re-assigning ticket %s from attendee %s to attendee %s",
$ticket->getId(),
$attendee_without_member->getId(),
$attendee->getId()
)
);
$attendee->addTicket($ticket);
sprintf
(
"AttendeeService::updateAttendeesByMemberId re-assigning ticket %s from attendee %s to attendee %s",
$ticket->getId(),
$attendee_without_member->getId(),
$attendee->getId()
)
);
$attendee->addTicket($ticket);
}
}
} else {
$update_email = false;
Log::warning
(
sprintf
(
"AttendeeService::updateAttendeesByMemberId can not update the email of attendee %s of member %s in summit %s: the new email belongs to attendee %s (member %s, email verified %s), keeping attendee email.",
$attendee->getId(),
$member_id,
$summit->getId(),
$attendee_with_email->getId(),
$attendee_with_email->hasMember() ? $attendee_with_email->getMemberId() : 'none',
$member->isEmailVerified() ? 'yes' : 'no'
)
);
}
}

$attendee->setFirstName($fname);
$attendee->setSurname($lname);
$attendee->setEmail($email);

if ($update_email)
$attendee->setEmail($email);

// company logic

Expand Down Expand Up @@ -1076,11 +1104,10 @@ public function updateAttendeesByMemberId(int $member_id): void
$email = $member->getEmail();
$company_name = $member->getCompany();

$attendees = $this->attendee_repository->getByEmailAndMemberNotSet($member->getEmail());
$attendees = $this->attendee_repository->getLinkableByMember($member);
if (!is_null($attendees)) {

foreach ($attendees as $attendee) {
if (!$attendee instanceof SummitAttendee) continue;

Log::debug
(
Expand Down
11 changes: 3 additions & 8 deletions app/Services/Model/Imp/MemberService.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@
use models\summit\ISpeakerRegistrationRequestRepository;
use models\summit\ISummitAttendeeRepository;
use models\summit\Presentation;
use models\summit\SummitAttendee;
use models\summit\SummitOrder;
use function Psy\debug;

Expand Down Expand Up @@ -766,13 +765,9 @@ public function assocSummitOrders(int $member_id):void{
$this->tx_service->transaction(function() use($member_id){
// associate attendees/tickets
$member = $this->member_repository->getById($member_id);
$attendees = $this->attendee_repository->getByEmailAndMemberNotSet($member->getEmail());
if(!is_null($attendees)) {
foreach ($attendees as $attendee) {
if (!$attendee instanceof SummitAttendee) continue;
Log::debug(sprintf("MemberService::assocSummitOrders got attendee %s for member %s", $attendee->getId(), $member_id));
$attendee->setMember($member);
}
foreach ($this->attendee_repository->getLinkableByMember($member) as $attendee) {
Log::debug(sprintf("MemberService::assocSummitOrders got attendee %s for member %s", $attendee->getId(), $member_id));
$attendee->setMember($member);
}
});
}
Expand Down
26 changes: 26 additions & 0 deletions app/Services/Model/Imp/SummitOrderService.php
Original file line number Diff line number Diff line change
Expand Up @@ -2262,6 +2262,32 @@ public function ownerAssignTicket(Member $current_user, int $order_id, int $tick
$attendee = $summit->getAttendeeByMember($member);
}

if (!is_null($member) && !is_null($attendee)) {
// the member can have only one attendee per summit
$member_attendee = $summit->getAttendeeByMember($member);
if (!is_null($member_attendee) && $member_attendee->getId() != $attendee->getId()) {
Log::warning
(
sprintf
(
"SummitOrderService::ownerAssignTicket the email belongs to member %s that already has attendee %s in summit %s, attendee found by email is %s.",
$member->getId(),
$member_attendee->getId(),
$summit->getId(),
$attendee->getId()
)
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
throw new ValidationException
(
sprintf
(
"Email %s belongs to a member that already has an attendee in this summit.",
$email
)
);
}
}

if (is_null($attendee)) {
// if attendee did not exists... create a new one
Log::debug
Expand Down
Loading
Loading