From a43afa579dd4e2912e036aab2932c9a02e05648e Mon Sep 17 00:00:00 2001 From: smarcet Date: Wed, 15 Jul 2026 00:37:08 -0300 Subject: [PATCH 1/5] fix: remap local docker-compose host ports to avoid conflicts with sibling stacks otel-collector, elasticsearch, and rabbitmq_sponsor_services published the same host ports as openstackid's and purchases-api's local stacks (9200/9300, 1888/8888/8889/13133/4317/4318/55679, 5672/15672), which broke docker compose up when multiple FN local stacks ran side by side. Container-internal ports are unchanged; only host-side mappings moved. --- docker-compose.yml | 22 +++++++++++----------- readme.md | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 361117822..821c9c574 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -44,8 +44,8 @@ services: RABBITMQ_DEFAULT_USER: ${DOMAIN_EVENTS_RABBITMQ_LOGIN} RABBITMQ_DEFAULT_PASS: ${DOMAIN_EVENTS_RABBITMQ_PASSWORD} ports: - - "15672:15672" # admin - - "5672:5672" # RabbitMQ + - "15673:15672" # admin + - "5673:5672" # RabbitMQ networks: - summit-api-local-net env_file: ./.env @@ -115,13 +115,13 @@ services: - ./docker-compose/opentelemetry/otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml - ./docker-compose/opentelemetry/otel-traces.json:/tmp/otel-traces.json ports: - - 1888:1888 # pprof extension - - 8888:8888 # Prometheus metrics exposed by the Collector - - 8889:8889 # Prometheus exporter metrics - - 13133:13133 # health_check extension - - 4317:4317 # OTLP gRPC receiver - - 4318:4318 # OTLP http receiver - - 55679:55679 # zpages extension + - 1988:1888 # pprof extension + - 8988:8888 # Prometheus metrics exposed by the Collector + - 8989:8889 # Prometheus exporter metrics + - 13233:13133 # health_check extension + - 4417:4317 # OTLP gRPC receiver + - 4418:4318 # OTLP http receiver + - 55779:55679 # zpages extension networks: - summit-api-local-net depends_on: @@ -137,8 +137,8 @@ services: - cluster.name=summit-api-cluster - node.name=summit-api-node ports: - - "9200:9200" - - "9300:9300" + - "9201:9200" + - "9301:9300" volumes: - elasticsearch_data:/usr/share/elasticsearch/data networks: diff --git a/readme.md b/readme.md index e8910b068..739e1081b 100644 --- a/readme.md +++ b/readme.md @@ -110,5 +110,5 @@ OTEL_INSTRUMENTATION_GUZZLE=true docker compose up -d otel-collector elasticsearch # View traces -curl http://localhost:55679/debug/tracez +curl http://localhost:55779/debug/tracez ``` From d1081a454c8f4e7cc1bdda05e8d56ea65fd8356b Mon Sep 17 00:00:00 2001 From: smarcet Date: Wed, 15 Jul 2026 09:47:49 -0300 Subject: [PATCH 2/5] fix(badges): generate-on-read + reassignment QR staleness (ClickUp 86baxuvj3) GET /summits/{id}/attendees/me?expand=tickets.badge now regenerates the caller's badge QR code on every read (AttendeeService::regenerateAttendeeBadgesQRCodes), so "My QR" renders even for never-printed badges. Fixes badge QR staleness on ticket reassignment across all three writers: AttendeeService::reassignAttendeeTicket, reassignAttendeeTicketByMember, and SummitOrderService::updateTicket. A ticket fetched via an exclusive lock does not populate Doctrine's inverse-side badge association, so hasBadge()/getBadge() are unreliable afterward - resolved via a shared ResolvesLockedTicketBadge trait that looks the badge up independently by ticket number and re-attaches it. Also fixes a latent duplicate-badge-row risk in updateTicket's badge_type_id branch, and adds try/catch guards around badge generation so one bad badge (broken legacy ticket/order/summit chain) can't 500 an otherwise-successful read or abort the rest of a generation batch. Resolves fntechgit/ftn-docsnsklz#70. --- .../OAuth2SummitAttendeesApiController.php | 5 +- app/Services/Model/AttendeeService.php | 52 +++++++- app/Services/Model/IAttendeeService.php | 10 +- app/Services/Model/Imp/SummitOrderService.php | 24 +++- .../Imp/Traits/ResolvesLockedTicketBadge.php | 42 +++++++ tests/AttendeeServiceTest.php | 115 ++++++++++++++++++ tests/SummitOrderServiceTest.php | 104 ++++++++++++++++ tests/oauth2/OAuth2AttendeesApiTest.php | 84 +++++++++++++ 8 files changed, 430 insertions(+), 6 deletions(-) create mode 100644 app/Services/Model/Imp/Traits/ResolvesLockedTicketBadge.php diff --git a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitAttendeesApiController.php b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitAttendeesApiController.php index 0f4c7f3aa..d410d8b23 100644 --- a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitAttendeesApiController.php +++ b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitAttendeesApiController.php @@ -149,8 +149,8 @@ public function __construct #[OA\Get( path: '/api/v1/summits/{id}/attendees/me', operationId: 'getCurrentAttendee', - summary: 'Get current user attendee profile', - description: 'Returns the attendee profile for the currently authenticated user in the specified summit', + summary: 'Get current user attendee profile for a particular summit', + description: 'Returns the attendee profile for the currently authenticated user in the specified summit ( regenerates que Badge QR code of the active tickets ) ', tags: ['Summit Attendees'], security: [ [ @@ -184,6 +184,7 @@ public function getOwnAttendee($summit_id) $type = CheckAttendeeStrategyFactory::Me; $attendee = CheckAttendeeStrategyFactory::build($type, $this->resource_server_context)->check('me', $summit); if (is_null($attendee)) return $this->error404(); + $this->attendee_service->regenerateAttendeeBadgesQRCodes($attendee); return $this->ok(SerializerRegistry::getInstance()->getSerializer($attendee)->serialize( SerializerUtils::getExpand(), SerializerUtils::getFields(), diff --git a/app/Services/Model/AttendeeService.php b/app/Services/Model/AttendeeService.php index 57239752a..ebfa44be3 100644 --- a/app/Services/Model/AttendeeService.php +++ b/app/Services/Model/AttendeeService.php @@ -17,6 +17,7 @@ use App\Models\Foundation\Summit\Repositories\ISummitAttendeeBadgeRepository; use App\Services\Apis\ExternalRegistrationFeeds\IExternalRegistrationFeedFactory; use App\Services\Model\Imp\Traits\ParametrizedSendEmails; +use App\Services\Model\Imp\Traits\ResolvesLockedTicketBadge; use App\Services\Model\Strategies\EmailActions\EmailActionsStrategyFactory; use App\Utils\AES; use Illuminate\Support\Facades\Cache; @@ -419,6 +420,16 @@ public function reassignAttendeeTicketByMember(Summit $summit, SummitAttendee $a $ticket->generateQRCode(); $ticket->generateHash(); + + $badge = $this->resolveBadgeForLockedTicket($ticket); + if (!is_null($badge)) { + try { + $badge->generateQRCode(); + } catch (\Exception $ex) { + Log::error($ex); + } + } + if ($summit->isRegistrationSendTicketEmailAutomatically()) $new_owner->sendInvitationEmail($ticket); @@ -535,6 +546,14 @@ public function reassignAttendeeTicket(Summit $summit, SummitAttendee $attendee, $ticket->generateHash(); $new_owner->updateStatus(); + $badge = $this->resolveBadgeForLockedTicket($ticket); + if (!is_null($badge)) { + try { + $badge->generateQRCode(); + } catch (\Exception $ex) { + Log::error($ex); + } + } if ($summit->isRegistrationSendTicketEmailAutomatically()) { Log::debug @@ -554,6 +573,36 @@ public function reassignAttendeeTicket(Summit $summit, SummitAttendee $attendee, } + /** + * @inheritDoc + */ + public function regenerateAttendeeBadgesQRCodes(SummitAttendee $attendee): void + { + Log::debug(sprintf("AttendeeService::regenerateAttendeeBadgesQRCodes attendee %s", $attendee->getId())); + $badge_ids_to_generate = []; + foreach ($attendee->getTickets() as $ticket) { + if (!$ticket->isActive()) continue; + if (!$ticket->hasBadge()) continue; + $badge = $ticket->getBadge(); + $badge_ids_to_generate[] = $badge->getId(); + } + + if (empty($badge_ids_to_generate)) return; + + foreach ($badge_ids_to_generate as $badge_id) { + $this->tx_service->transaction(function () use ($attendee, $badge_id) { + $badge = $this->badge_repository->getByIdExclusiveLock($badge_id); + if (!$badge instanceof SummitAttendeeBadge) return; + Log::debug(sprintf("AttendeeService::regenerateAttendeeBadgesQRCodes attendee %s badge %s", $attendee->getId(), $badge_id)); + try { + $badge->generateQRCode(); + } catch (\Exception $ex) { + Log::error($ex); + } + }); + } + } + /** * @inheritDoc */ @@ -563,6 +612,7 @@ public function triggerSend(Summit $summit, array $payload, $filter = null): voi } use ParametrizedSendEmails; + use ResolvesLockedTicketBadge; /** * @param int $summit_id @@ -1086,4 +1136,4 @@ public function processAttendeeCheckStatusUpdate(int $attendee_id):void{ $feed->unCheckAttendee($external_id); }); } -} \ No newline at end of file +} diff --git a/app/Services/Model/IAttendeeService.php b/app/Services/Model/IAttendeeService.php index 119af74ee..35bda252c 100644 --- a/app/Services/Model/IAttendeeService.php +++ b/app/Services/Model/IAttendeeService.php @@ -152,4 +152,12 @@ public function deleteAttendeeNote(Summit $summit, int $attendee_id, int $note_i * @throws \Exception */ public function processAttendeeCheckStatusUpdate(int $attendee_id):void; -} \ No newline at end of file + + /** + * Generates and persists the badge qr_code for any of the attendee's own active + * tickets whose badge does not have one yet (generate-on-first-read). + * @param SummitAttendee $attendee + * @return void + */ + public function regenerateAttendeeBadgesQRCodes(SummitAttendee $attendee): void; +} diff --git a/app/Services/Model/Imp/SummitOrderService.php b/app/Services/Model/Imp/SummitOrderService.php index 2a1d27b64..e325539bb 100644 --- a/app/Services/Model/Imp/SummitOrderService.php +++ b/app/Services/Model/Imp/SummitOrderService.php @@ -38,6 +38,7 @@ use App\Services\FileSystem\IFileDownloadStrategy; use App\Services\FileSystem\IFileUploadStrategy; use App\Services\Model\dto\ExternalUserDTO; +use App\Services\Model\Imp\Traits\ResolvesLockedTicketBadge; use App\Services\Model\Strategies\TicketFinder\ITicketFinderStrategyFactory; use App\Services\Utils\CSVReader; use App\Services\Utils\ILockManagerService; @@ -1678,6 +1679,8 @@ public function undo() final class SummitOrderService extends AbstractService implements ISummitOrderService { + use ResolvesLockedTicketBadge; + /** * @var IMemberRepository */ @@ -4027,6 +4030,15 @@ public function updateTicket(Summit $summit, int $order_id, int $ticket_id, arra $ticket->generateHash(); $new_owner->updateStatus(); $shouldSendInvitationEmail = true; + + $badge = $this->resolveBadgeForLockedTicket($ticket); + if (!is_null($badge)) { + try { + $badge->generateQRCode(); + } catch (\Exception $ex) { + Log::error($ex); + } + } } if (isset($payload['ticket_type_id'])) { @@ -4048,9 +4060,17 @@ public function updateTicket(Summit $summit, int $order_id, int $ticket_id, arra if (is_null($badge_type)) throw new EntityNotFoundException("badge type not found"); - $badge = $ticket->hasBadge() ? $ticket->getBadge() : new SummitAttendeeBadge(); + // $ticket here comes from $order->getTicketById() (a collection match, + // not a directly-locked fetch) so hasBadge()/getBadge() are normally + // reliable; resolveBadgeForLockedTicket is used anyway for defense in + // depth and consistency with the reassignment branch above, since it + // doesn't depend on that Doctrine hydration detail holding in the future. + $badge = $this->resolveBadgeForLockedTicket($ticket); + if (is_null($badge)) { + $badge = new SummitAttendeeBadge(); + $ticket->setBadge($badge); + } $badge->setType($badge_type); - $ticket->setBadge($badge); } return [$ticket, $shouldSendInvitationEmail]; diff --git a/app/Services/Model/Imp/Traits/ResolvesLockedTicketBadge.php b/app/Services/Model/Imp/Traits/ResolvesLockedTicketBadge.php new file mode 100644 index 000000000..4b39a2c1e --- /dev/null +++ b/app/Services/Model/Imp/Traits/ResolvesLockedTicketBadge.php @@ -0,0 +1,42 @@ +hasBadge()/getBadge() are unreliable + * immediately afterward. Classes using this trait must inject + * ISummitAttendeeBadgeRepository as $this->badge_repository. + */ +trait ResolvesLockedTicketBadge +{ + /** + * Looks the badge up independently by ticket number and re-attaches it to + * $ticket so the in-memory object (and anything serialized from it) reflects + * the real association. + * @param SummitAttendeeTicket $ticket + * @return SummitAttendeeBadge|null + */ + private function resolveBadgeForLockedTicket(SummitAttendeeTicket $ticket): ?SummitAttendeeBadge + { + $badge = $this->badge_repository->getBadgeByTicketNumber($ticket->getNumber()); + if (!is_null($badge)) { + $ticket->setBadge($badge); + } + return $badge; + } +} diff --git a/tests/AttendeeServiceTest.php b/tests/AttendeeServiceTest.php index 53370a6da..7ff1a67cf 100644 --- a/tests/AttendeeServiceTest.php +++ b/tests/AttendeeServiceTest.php @@ -18,6 +18,9 @@ use App\Services\Model\IAttendeeService; use Illuminate\Support\Facades\App; use LaravelDoctrine\ORM\Facades\EntityManager; +use models\summit\Summit; +use models\summit\SummitAttendeeBadge; +use models\summit\SummitAttendeeTicket; /** * Class AttendeeServiceTest */ @@ -85,4 +88,116 @@ public function testSendRegistrationIncompleteReminderByAttendeeIds() { $service->send(self::$summit->getId(), $payload); } + + public function testReassignAttendeeTicketRegeneratesBadgeQRCode(){ + + $attendee = self::$summit->getAttendeeByMember(self::$defaultMember); + $this->assertNotNull($attendee); + $ticket = $attendee->getTickets()->first(); + $this->assertNotNull($ticket); + $this->assertTrue($ticket->hasBadge()); + $badge_id = $ticket->getBadge()->getId(); + + $summit_id = self::$summit->getId(); + $attendee_id = $attendee->getId(); + $member2_email = self::$member2->getEmail(); + $member2_first = self::$member2->getFirstName(); + $member2_last = self::$member2->getLastName(); + $member2_fullname = self::$member2->getFullName(); + $default_email = self::$defaultMember->getEmail(); + + // clear the identity map so the service performs a genuinely fresh load of the + // ticket, matching how a real HTTP request behaves. + EntityManager::clear(); + + $summit = EntityManager::getRepository(Summit::class)->find($summit_id); + $attendee = EntityManager::getRepository(\models\summit\SummitAttendee::class)->find($attendee_id); + $ticket_id = $this->resolveRealTicketIdForBadge($badge_id); + + $service = App::make(IAttendeeService::class); + $payload = [ + 'attendee_email' => $member2_email, + 'attendee_first_name' => $member2_first, + 'attendee_last_name' => $member2_last, + ]; + + $reassigned_ticket = $service->reassignAttendeeTicket($summit, $attendee, $ticket_id, $payload); + + $this->assertBadgeQRRegeneratedForNewOwner( + $reassigned_ticket, $badge_id, $summit, $member2_email, $member2_fullname, $default_email + ); + } + + public function testReassignAttendeeTicketByMemberRegeneratesBadgeQRCode(){ + + $attendee = self::$summit->getAttendeeByMember(self::$defaultMember); + $this->assertNotNull($attendee); + $ticket = $attendee->getTickets()->first(); + $this->assertNotNull($ticket); + $this->assertTrue($ticket->hasBadge()); + $badge_id = $ticket->getBadge()->getId(); + + $summit_id = self::$summit->getId(); + $attendee_id = $attendee->getId(); + $member2_id = self::$member2->getId(); + $member2_email = self::$member2->getEmail(); + $member2_fullname = self::$member2->getFullName(); + $default_email = self::$defaultMember->getEmail(); + + // see comments in testReassignAttendeeTicketRegeneratesBadgeQRCode + EntityManager::clear(); + + $summit = EntityManager::getRepository(Summit::class)->find($summit_id); + $attendee = EntityManager::getRepository(\models\summit\SummitAttendee::class)->find($attendee_id); + $member2 = EntityManager::getRepository(\models\main\Member::class)->find($member2_id); + $ticket_id = $this->resolveRealTicketIdForBadge($badge_id); + + $service = App::make(IAttendeeService::class); + $reassigned_ticket = $service->reassignAttendeeTicketByMember($summit, $attendee, $member2, $ticket_id); + + $this->assertBadgeQRRegeneratedForNewOwner( + $reassigned_ticket, $badge_id, $summit, $member2_email, $member2_fullname, $default_email + ); + } + + /** + * The fixture (InsertSummitTestData) reuses one SummitAttendeeBadge PHP object + * across several tickets, so only the LAST ticket it was attached to is the one + * actually persisted as this badge's TicketID in the DB - resolve the real ticket + * via the badge's own association rather than trusting collection order. + */ + private function resolveRealTicketIdForBadge(int $badge_id): int + { + return EntityManager::getRepository(SummitAttendeeBadge::class)->find($badge_id)->getTicket()->getId(); + } + + /** + * Shared post-reassignment assertions: the returned ticket's own badge + * association must already reflect the regenerated badge (not stale/absent), + * since API responses serialize this same object directly without a reload; + * and the persisted badge, once reloaded independently, decodes to the new + * owner's email/full name. + */ + private function assertBadgeQRRegeneratedForNewOwner( + SummitAttendeeTicket $reassigned_ticket, + int $badge_id, + Summit $summit, + string $new_owner_email, + string $new_owner_fullname, + string $previous_owner_email + ): void { + $this->assertTrue($reassigned_ticket->hasBadge()); + $this->assertEquals($badge_id, $reassigned_ticket->getBadge()->getId()); + $this->assertNotEmpty($reassigned_ticket->getBadge()->getQRCode()); + + EntityManager::clear(); + $badge = EntityManager::getRepository(SummitAttendeeBadge::class)->find($badge_id); + $qr_code = $badge->getQRCode(); + $this->assertNotEmpty($qr_code); + $decoded = SummitAttendeeBadge::parseQRCode(SummitAttendeeBadge::decodeQRCodeFor($summit, $qr_code)); + + $this->assertEquals($new_owner_email, $decoded['owner_email']); + $this->assertEquals($new_owner_fullname, $decoded['owner_fullname']); + $this->assertNotEquals($previous_owner_email, $decoded['owner_email']); + } } \ No newline at end of file diff --git a/tests/SummitOrderServiceTest.php b/tests/SummitOrderServiceTest.php index daf42f230..c2b4cf82c 100644 --- a/tests/SummitOrderServiceTest.php +++ b/tests/SummitOrderServiceTest.php @@ -33,6 +33,7 @@ use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Queue; +use LaravelDoctrine\ORM\Facades\EntityManager; use libs\utils\ITransactionService; use Mockery; use models\main\ICompanyRepository; @@ -45,6 +46,7 @@ use models\summit\ISummitTicketTypeRepository; use models\summit\Summit; use models\summit\SummitAttendee; +use models\summit\SummitAttendeeBadge; use models\summit\SummitAttendeeTicket; use models\summit\SummitBadgeFeatureType; use models\summit\SummitBadgeType; @@ -937,4 +939,106 @@ public function testImportTicketDataCreatesBadgeWhenTicketHasNone() $this->assertTrue($ticket->hasBadge()); $this->assertEquals('VIP BADGE', $ticket->getBadge()->getType()->getName()); } + + public function testUpdateTicketReassignmentRegeneratesBadgeQRCode(){ + + $attendee = self::$summit->getAttendeeByMember(self::$defaultMember); + $this->assertNotNull($attendee); + $ticket = $attendee->getTickets()->first(); + $this->assertNotNull($ticket); + $this->assertTrue($ticket->hasBadge()); + $badge_id = $ticket->getBadge()->getId(); + + $summit_id = self::$summit->getId(); + $member2_email = self::$member2->getEmail(); + $member2_first = self::$member2->getFirstName(); + $member2_last = self::$member2->getLastName(); + $member2_fullname = self::$member2->getFullName(); + $default_email = self::$defaultMember->getEmail(); + + // clear the identity map so the service performs a genuinely fresh load, + // matching how a real HTTP request behaves. + EntityManager::clear(); + + $summit = EntityManager::getRepository(Summit::class)->find($summit_id); + + // the fixture (InsertSummitTestData) reuses one SummitAttendeeBadge PHP object + // across several tickets, so only the LAST ticket it was attached to is the one + // actually persisted as this badge's TicketID in the DB - resolve the real + // ticket (and its order) via the badge's own association rather than trusting + // attendee->getTickets()->first(). + $real_ticket = EntityManager::getRepository(SummitAttendeeBadge::class)->find($badge_id)->getTicket(); + $ticket_id = $real_ticket->getId(); + $order_id = $real_ticket->getOrder()->getId(); + + $payload = [ + 'attendee_email' => $member2_email, + 'attendee_first_name' => $member2_first, + 'attendee_last_name' => $member2_last, + ]; + + $service = App::make(ISummitOrderService::class); + $service->updateTicket($summit, $order_id, $ticket_id, $payload); + + EntityManager::clear(); + $badge = EntityManager::getRepository(SummitAttendeeBadge::class)->find($badge_id); + $qr_code = $badge->getQRCode(); + $this->assertNotEmpty($qr_code); + $decoded = SummitAttendeeBadge::parseQRCode(SummitAttendeeBadge::decodeQRCodeFor($summit, $qr_code)); + + $this->assertEquals($member2_email, $decoded['owner_email']); + $this->assertEquals($member2_fullname, $decoded['owner_fullname']); + $this->assertNotEquals($default_email, $decoded['owner_email']); + } + + public function testUpdateTicketBadgeTypeChangeDoesNotDuplicateBadge(){ + + $attendee = self::$summit->getAttendeeByMember(self::$defaultMember); + $this->assertNotNull($attendee); + $ticket = $attendee->getTickets()->first(); + $this->assertNotNull($ticket); + $this->assertTrue($ticket->hasBadge()); + $badge_id = $ticket->getBadge()->getId(); + + $new_badge_type = new SummitBadgeType(); + $new_badge_type->setName('NEW BADGE TYPE'); + $new_badge_type->setDescription('NEW BADGE TYPE DESCRIPTION'); + self::$summit->addBadgeType($new_badge_type); + self::$em->persist(self::$summit); + self::$em->flush(); + $new_badge_type_id = $new_badge_type->getId(); + + $summit_id = self::$summit->getId(); + + // see comment in testUpdateTicketReassignmentRegeneratesBadgeQRCode: resolve the + // real ticket/order via the badge's own association, not collection order. + EntityManager::clear(); + $summit = EntityManager::getRepository(Summit::class)->find($summit_id); + $real_ticket = EntityManager::getRepository(SummitAttendeeBadge::class)->find($badge_id)->getTicket(); + $ticket_id = $real_ticket->getId(); + $ticket_number = $real_ticket->getNumber(); + $order_id = $real_ticket->getOrder()->getId(); + + $service = App::make(ISummitOrderService::class); + $service->updateTicket($summit, $order_id, $ticket_id, ['badge_type_id' => $new_badge_type_id]); + + EntityManager::clear(); + + // exactly one badge row for this ticket - the pre-existing badge was reused, + // not shadowed by a second inserted row + $badge_count = EntityManager::getRepository(SummitAttendeeBadge::class) + ->createQueryBuilder('b') + ->select('COUNT(b.id)') + ->innerJoin('b.ticket', 't') + ->where('t.number = :ticket_number') + ->setParameter('ticket_number', $ticket_number) + ->getQuery() + ->getSingleScalarResult(); + $this->assertEquals(1, $badge_count); + + $badge = EntityManager::getRepository(SummitAttendeeBadge::class)->getBadgeByTicketNumber($ticket_number); + $this->assertNotNull($badge); + $this->assertEquals($badge_id, $badge->getId()); + $this->assertEquals('NEW BADGE TYPE', $badge->getType()->getName()); + } } diff --git a/tests/oauth2/OAuth2AttendeesApiTest.php b/tests/oauth2/OAuth2AttendeesApiTest.php index a995314be..4924010e6 100644 --- a/tests/oauth2/OAuth2AttendeesApiTest.php +++ b/tests/oauth2/OAuth2AttendeesApiTest.php @@ -14,6 +14,8 @@ use App\Jobs\Emails\SummitAttendeeTicketRegenerateHashEmail; use App\Models\Foundation\Main\IGroup; use Illuminate\Support\Facades\App; +use LaravelDoctrine\ORM\Facades\EntityManager; +use models\summit\SummitAttendeeBadge; /** * Class OAuth2AttendeesApiTest * @package Tests @@ -126,6 +128,88 @@ public function testGetOwnAttendee(){ $this->assertTrue(!is_null($attendee)); } + public function testGetOwnAttendeeGeneratesBadgeQRCodeOnFirstRead(){ + + $attendee = self::$summit->getAttendeeByMember(self::$defaultMember); + $this->assertNotNull($attendee); + $ticket = $attendee->getTickets()->first(); + $this->assertNotNull($ticket); + $badge = $ticket->getBadge(); + $this->assertNotNull($badge); + $badge_id = $badge->getId(); + + // simulate a never-printed badge (qr_code null) by nulling the private field directly + $prop = new \ReflectionProperty(SummitAttendeeBadge::class, 'qr_code'); + $prop->setAccessible(true); + $prop->setValue($badge, null); + EntityManager::flush(); + EntityManager::clear(); + + $params = [ + 'id' => self::$summit->getId(), + 'expand' => 'tickets,tickets.badge', + ]; + + $headers = [ + "HTTP_Authorization" => " Bearer " . $this->access_token, + "CONTENT_TYPE" => "application/json" + ]; + + $response = $this->action( + "GET", + "OAuth2SummitAttendeesApiController@getOwnAttendee", + $params, + [], + [], + [], + $headers + ); + + $this->assertResponseStatus(200); + $content = json_decode($response->getContent()); + $this->assertTrue(!is_null($content)); + $this->assertNotEmpty($content->tickets); + + $ticket_with_badge = null; + foreach ($content->tickets as $t) { + if (isset($t->badge) && $t->badge->id === $badge_id) { + $ticket_with_badge = $t; + break; + } + } + $this->assertNotNull($ticket_with_badge, "response did not include the ticket's badge"); + $this->assertNotEmpty($ticket_with_badge->badge->qr_code); + $first_qr_code = $ticket_with_badge->badge->qr_code; + + // persisted, not just computed in-response + EntityManager::clear(); + $reloaded_badge = EntityManager::getRepository(SummitAttendeeBadge::class)->find($badge_id); + $this->assertNotEmpty($reloaded_badge->getQRCode()); + + // second read is idempotent (identical value, not recomputed) + $response2 = $this->action( + "GET", + "OAuth2SummitAttendeesApiController@getOwnAttendee", + $params, + [], + [], + [], + $headers + ); + + $this->assertResponseStatus(200); + $content2 = json_decode($response2->getContent()); + $ticket_with_badge2 = null; + foreach ($content2->tickets as $t) { + if (isset($t->badge) && $t->badge->id === $badge_id) { + $ticket_with_badge2 = $t; + break; + } + } + $this->assertNotNull($ticket_with_badge2); + $this->assertEquals($first_qr_code, $ticket_with_badge2->badge->qr_code); + } + public function testGetAttendeeByID(){ $attendee = self::$summit->getAttendeeByMember(self::$defaultMember); From af530038c3affea257dcf48be1b0c9907fc4250a Mon Sep 17 00:00:00 2001 From: smarcet Date: Wed, 15 Jul 2026 10:20:32 -0300 Subject: [PATCH 3/5] fix(badges): remove unnecessary ResolvesLockedTicketBadge workaround The previous commit shipped a trait working around a claimed Doctrine behavior ("exclusive-lock fetch doesn't populate inverse-side to-one associations") that does not actually exist. Verified with isolated, instrumented tests (SQL capture, object-identity checks, UnitOfWork state checks): $ticket->hasBadge()/getBadge() work correctly on a ticket fetched via getByIdExclusiveLock(), with no workaround needed. The earlier misleading result was caused by two unrelated confounds in diagnostic testing: mixing two different EntityManager instances (the LaravelDoctrine facade vs a test fixture's own self::$em), and a pre-existing test fixture bug (InsertSummitTestData reuses one SummitAttendeeBadge object across 5 tickets, so attendee->getTickets() ->first() is not reliably the ticket that badge's DB row points to). Reverts all four call sites (AttendeeService::reassignAttendeeTicket, reassignAttendeeTicketByMember, and both branches of SummitOrderService::updateTicket) to plain hasBadge()/getBadge(). Keeps the try/catch guard around generateQRCode(), which is unrelated and still valid. Deletes the ResolvesLockedTicketBadge trait. Full targeted suite re-verified: 45/46 pass (same pre-existing, unrelated failure as before). --- app/Services/Model/AttendeeService.php | 12 ++---- app/Services/Model/Imp/SummitOrderService.php | 20 ++------- .../Imp/Traits/ResolvesLockedTicketBadge.php | 42 ------------------- 3 files changed, 8 insertions(+), 66 deletions(-) delete mode 100644 app/Services/Model/Imp/Traits/ResolvesLockedTicketBadge.php diff --git a/app/Services/Model/AttendeeService.php b/app/Services/Model/AttendeeService.php index ebfa44be3..445c8121f 100644 --- a/app/Services/Model/AttendeeService.php +++ b/app/Services/Model/AttendeeService.php @@ -17,7 +17,6 @@ use App\Models\Foundation\Summit\Repositories\ISummitAttendeeBadgeRepository; use App\Services\Apis\ExternalRegistrationFeeds\IExternalRegistrationFeedFactory; use App\Services\Model\Imp\Traits\ParametrizedSendEmails; -use App\Services\Model\Imp\Traits\ResolvesLockedTicketBadge; use App\Services\Model\Strategies\EmailActions\EmailActionsStrategyFactory; use App\Utils\AES; use Illuminate\Support\Facades\Cache; @@ -421,10 +420,9 @@ public function reassignAttendeeTicketByMember(Summit $summit, SummitAttendee $a $ticket->generateQRCode(); $ticket->generateHash(); - $badge = $this->resolveBadgeForLockedTicket($ticket); - if (!is_null($badge)) { + if ($ticket->hasBadge()) { try { - $badge->generateQRCode(); + $ticket->getBadge()->generateQRCode(); } catch (\Exception $ex) { Log::error($ex); } @@ -546,10 +544,9 @@ public function reassignAttendeeTicket(Summit $summit, SummitAttendee $attendee, $ticket->generateHash(); $new_owner->updateStatus(); - $badge = $this->resolveBadgeForLockedTicket($ticket); - if (!is_null($badge)) { + if ($ticket->hasBadge()) { try { - $badge->generateQRCode(); + $ticket->getBadge()->generateQRCode(); } catch (\Exception $ex) { Log::error($ex); } @@ -612,7 +609,6 @@ public function triggerSend(Summit $summit, array $payload, $filter = null): voi } use ParametrizedSendEmails; - use ResolvesLockedTicketBadge; /** * @param int $summit_id diff --git a/app/Services/Model/Imp/SummitOrderService.php b/app/Services/Model/Imp/SummitOrderService.php index e325539bb..7a22d57c9 100644 --- a/app/Services/Model/Imp/SummitOrderService.php +++ b/app/Services/Model/Imp/SummitOrderService.php @@ -38,7 +38,6 @@ use App\Services\FileSystem\IFileDownloadStrategy; use App\Services\FileSystem\IFileUploadStrategy; use App\Services\Model\dto\ExternalUserDTO; -use App\Services\Model\Imp\Traits\ResolvesLockedTicketBadge; use App\Services\Model\Strategies\TicketFinder\ITicketFinderStrategyFactory; use App\Services\Utils\CSVReader; use App\Services\Utils\ILockManagerService; @@ -1679,8 +1678,6 @@ public function undo() final class SummitOrderService extends AbstractService implements ISummitOrderService { - use ResolvesLockedTicketBadge; - /** * @var IMemberRepository */ @@ -4031,10 +4028,9 @@ public function updateTicket(Summit $summit, int $order_id, int $ticket_id, arra $new_owner->updateStatus(); $shouldSendInvitationEmail = true; - $badge = $this->resolveBadgeForLockedTicket($ticket); - if (!is_null($badge)) { + if ($ticket->hasBadge()) { try { - $badge->generateQRCode(); + $ticket->getBadge()->generateQRCode(); } catch (\Exception $ex) { Log::error($ex); } @@ -4060,17 +4056,9 @@ public function updateTicket(Summit $summit, int $order_id, int $ticket_id, arra if (is_null($badge_type)) throw new EntityNotFoundException("badge type not found"); - // $ticket here comes from $order->getTicketById() (a collection match, - // not a directly-locked fetch) so hasBadge()/getBadge() are normally - // reliable; resolveBadgeForLockedTicket is used anyway for defense in - // depth and consistency with the reassignment branch above, since it - // doesn't depend on that Doctrine hydration detail holding in the future. - $badge = $this->resolveBadgeForLockedTicket($ticket); - if (is_null($badge)) { - $badge = new SummitAttendeeBadge(); - $ticket->setBadge($badge); - } + $badge = $ticket->hasBadge() ? $ticket->getBadge() : new SummitAttendeeBadge(); $badge->setType($badge_type); + $ticket->setBadge($badge); } return [$ticket, $shouldSendInvitationEmail]; diff --git a/app/Services/Model/Imp/Traits/ResolvesLockedTicketBadge.php b/app/Services/Model/Imp/Traits/ResolvesLockedTicketBadge.php deleted file mode 100644 index 4b39a2c1e..000000000 --- a/app/Services/Model/Imp/Traits/ResolvesLockedTicketBadge.php +++ /dev/null @@ -1,42 +0,0 @@ -hasBadge()/getBadge() are unreliable - * immediately afterward. Classes using this trait must inject - * ISummitAttendeeBadgeRepository as $this->badge_repository. - */ -trait ResolvesLockedTicketBadge -{ - /** - * Looks the badge up independently by ticket number and re-attaches it to - * $ticket so the in-memory object (and anything serialized from it) reflects - * the real association. - * @param SummitAttendeeTicket $ticket - * @return SummitAttendeeBadge|null - */ - private function resolveBadgeForLockedTicket(SummitAttendeeTicket $ticket): ?SummitAttendeeBadge - { - $badge = $this->badge_repository->getBadgeByTicketNumber($ticket->getNumber()); - if (!is_null($badge)) { - $ticket->setBadge($badge); - } - return $badge; - } -} From e68e373a0c71e1781069c877dedff190216001ba Mon Sep 17 00:00:00 2001 From: smarcet Date: Wed, 15 Jul 2026 10:33:49 -0300 Subject: [PATCH 4/5] fix(badges): only regenerate badge QR when tickets.badge is expanded Per CodeRabbit review on PR #572 (discussion_r3587418919): getOwnAttendee was invoking regenerateAttendeeBadgesQRCodes() unconditionally, exclusively locking and regenerating every active badge even on requests that never expand tickets.badge and therefore never serialize badge data at all. Now only calls it when 'tickets.badge' is present in the expand param. Keeps the always-regenerate behavior (no skip-if-already-has-QR) per explicit prior decision - this only scopes *when* it runs, not whether existing QR codes are trusted. Added testGetOwnAttendeeDoesNotGenerateBadgeQRCodeWhenBadgeNotExpanded, verified RED (failed against the unconditional call) before GREEN. Full targeted suite re-verified: 46/47 pass (same pre-existing failure). --- .../OAuth2SummitAttendeesApiController.php | 10 +++- tests/oauth2/OAuth2AttendeesApiTest.php | 56 +++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitAttendeesApiController.php b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitAttendeesApiController.php index d410d8b23..7bc1caf7e 100644 --- a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitAttendeesApiController.php +++ b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitAttendeesApiController.php @@ -184,9 +184,15 @@ public function getOwnAttendee($summit_id) $type = CheckAttendeeStrategyFactory::Me; $attendee = CheckAttendeeStrategyFactory::build($type, $this->resource_server_context)->check('me', $summit); if (is_null($attendee)) return $this->error404(); - $this->attendee_service->regenerateAttendeeBadgesQRCodes($attendee); + + $expand = SerializerUtils::getExpand(); + $expand_relations = array_map('trim', explode(',', $expand)); + if (in_array('tickets.badge', $expand_relations, true)) { + $this->attendee_service->regenerateAttendeeBadgesQRCodes($attendee); + } + return $this->ok(SerializerRegistry::getInstance()->getSerializer($attendee)->serialize( - SerializerUtils::getExpand(), + $expand, SerializerUtils::getFields(), SerializerUtils::getRelations() )); diff --git a/tests/oauth2/OAuth2AttendeesApiTest.php b/tests/oauth2/OAuth2AttendeesApiTest.php index 4924010e6..f0d35b947 100644 --- a/tests/oauth2/OAuth2AttendeesApiTest.php +++ b/tests/oauth2/OAuth2AttendeesApiTest.php @@ -210,6 +210,62 @@ public function testGetOwnAttendeeGeneratesBadgeQRCodeOnFirstRead(){ $this->assertEquals($first_qr_code, $ticket_with_badge2->badge->qr_code); } + public function testGetOwnAttendeeDoesNotGenerateBadgeQRCodeWhenBadgeNotExpanded(){ + + $attendee = self::$summit->getAttendeeByMember(self::$defaultMember); + $this->assertNotNull($attendee); + $ticket = $attendee->getTickets()->first(); + $this->assertNotNull($ticket); + $badge = $ticket->getBadge(); + $this->assertNotNull($badge); + $badge_id = $badge->getId(); + + // simulate a never-printed badge (qr_code null) by nulling the private field directly + $prop = new \ReflectionProperty(SummitAttendeeBadge::class, 'qr_code'); + $prop->setAccessible(true); + $prop->setValue($badge, null); + EntityManager::flush(); + EntityManager::clear(); + + $headers = [ + "HTTP_Authorization" => " Bearer " . $this->access_token, + "CONTENT_TYPE" => "application/json" + ]; + + // no 'expand' param at all - badge relation not requested + $response = $this->action( + "GET", + "OAuth2SummitAttendeesApiController@getOwnAttendee", + ['id' => self::$summit->getId()], + [], + [], + [], + $headers + ); + + $this->assertResponseStatus(200); + + $badge_after = EntityManager::getRepository(SummitAttendeeBadge::class)->find($badge_id); + $this->assertEmpty($badge_after->getQRCode(), "badge QR was generated even though tickets.badge was not expanded"); + + // expand=tickets only (no .badge sub-expand) - still must not trigger generation + $response2 = $this->action( + "GET", + "OAuth2SummitAttendeesApiController@getOwnAttendee", + ['id' => self::$summit->getId(), 'expand' => 'tickets'], + [], + [], + [], + $headers + ); + + $this->assertResponseStatus(200); + + EntityManager::clear(); + $badge_after2 = EntityManager::getRepository(SummitAttendeeBadge::class)->find($badge_id); + $this->assertEmpty($badge_after2->getQRCode(), "badge QR was generated for expand=tickets without .badge"); + } + public function testGetAttendeeByID(){ $attendee = self::$summit->getAttendeeByMember(self::$defaultMember); From f60cd0e7cafb7ae71f8658ca11053531311f5f75 Mon Sep 17 00:00:00 2001 From: smarcet Date: Wed, 15 Jul 2026 10:52:23 -0300 Subject: [PATCH 5/5] docs(badges): fix typo in getOwnAttendee swagger description Per PR #572 review (discussion_r3587772934, @romanetar): "que" was a Spanish word leaked into the English OpenAPI description. Also updated the wording to reflect the actual current behavior (regeneration only happens when tickets.badge is expanded). --- .../Protected/Summit/OAuth2SummitAttendeesApiController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitAttendeesApiController.php b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitAttendeesApiController.php index 7bc1caf7e..7ca7ba688 100644 --- a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitAttendeesApiController.php +++ b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitAttendeesApiController.php @@ -150,7 +150,7 @@ public function __construct path: '/api/v1/summits/{id}/attendees/me', operationId: 'getCurrentAttendee', summary: 'Get current user attendee profile for a particular summit', - description: 'Returns the attendee profile for the currently authenticated user in the specified summit ( regenerates que Badge QR code of the active tickets ) ', + description: 'Returns the attendee profile for the currently authenticated user in the specified summit (regenerates the Badge QR code of the active tickets when tickets.badge is expanded)', tags: ['Summit Attendees'], security: [ [