diff --git a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitAttendeesApiController.php b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitAttendeesApiController.php index 0f4c7f3aa..7ca7ba688 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 the Badge QR code of the active tickets when tickets.badge is expanded)', tags: ['Summit Attendees'], security: [ [ @@ -184,8 +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(); + + $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/app/Services/Model/AttendeeService.php b/app/Services/Model/AttendeeService.php index 57239752a..445c8121f 100644 --- a/app/Services/Model/AttendeeService.php +++ b/app/Services/Model/AttendeeService.php @@ -419,6 +419,15 @@ public function reassignAttendeeTicketByMember(Summit $summit, SummitAttendee $a $ticket->generateQRCode(); $ticket->generateHash(); + + if ($ticket->hasBadge()) { + try { + $ticket->getBadge()->generateQRCode(); + } catch (\Exception $ex) { + Log::error($ex); + } + } + if ($summit->isRegistrationSendTicketEmailAutomatically()) $new_owner->sendInvitationEmail($ticket); @@ -535,6 +544,13 @@ public function reassignAttendeeTicket(Summit $summit, SummitAttendee $attendee, $ticket->generateHash(); $new_owner->updateStatus(); + if ($ticket->hasBadge()) { + try { + $ticket->getBadge()->generateQRCode(); + } catch (\Exception $ex) { + Log::error($ex); + } + } if ($summit->isRegistrationSendTicketEmailAutomatically()) { Log::debug @@ -554,6 +570,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 */ @@ -1086,4 +1132,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..7a22d57c9 100644 --- a/app/Services/Model/Imp/SummitOrderService.php +++ b/app/Services/Model/Imp/SummitOrderService.php @@ -4027,6 +4027,14 @@ public function updateTicket(Summit $summit, int $order_id, int $ticket_id, arra $ticket->generateHash(); $new_owner->updateStatus(); $shouldSendInvitationEmail = true; + + if ($ticket->hasBadge()) { + try { + $ticket->getBadge()->generateQRCode(); + } catch (\Exception $ex) { + Log::error($ex); + } + } } if (isset($payload['ticket_type_id'])) { 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 ``` 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..f0d35b947 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,144 @@ 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 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);