diff --git a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitTicketApiController.php b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitTicketApiController.php index d04c74c8e..b77584a41 100644 --- a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitTicketApiController.php +++ b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitTicketApiController.php @@ -745,7 +745,7 @@ public function ingestExternalTicketData($summit_id) path: '/api/v1/summits/{id}/tickets/csv/template', operationId: 'getTicketImportTemplate', summary: 'Get ticket import template', - description: 'Returns a CSV template for importing ticket data', + description: 'Returns a CSV template for importing ticket data. Includes one column per summit badge feature name plus one extra_question:{question name} column per summit order extra question (Ticket/Both usage).', security: [['summit_tickets_oauth2' => [ SummitScopes::WriteSummitData, SummitScopes::WriteRegistrationData, @@ -788,7 +788,8 @@ public function getImportTicketDataTemplate($summit_id) * ticket_promo_code (optional) * badge_type_id (optional) * badge_type_name (optional) - * badge_features (optional) + * badge_features (optional, one col per badge feature name) + * extra_question:{question name} (optional, one col per order extra question - Ticket/Both usage) */ $summit = SummitFinderStrategyFactory::build($this->summit_repository, $this->getResourceServerContext())->find($summit_id); @@ -816,6 +817,11 @@ public function getImportTicketDataTemplate($summit_id) $row[$featuresType->getName()] = ''; } + // order extra questions for summit ( ticket / attendee scoped ones ) + foreach ($summit->getOrderExtraQuestionsByUsage(SummitOrderExtraQuestionTypeConstants::TicketQuestionUsage) as $question) { + $row[sprintf('%s%s', ISummitOrderService::ExtraQuestionColumnPrefix, $question->getName())] = ''; + } + $template = [ $row ]; @@ -835,7 +841,7 @@ public function getImportTicketDataTemplate($summit_id) path: '/api/v1/summits/{id}/tickets/csv', operationId: 'importTicketData', summary: 'Import ticket data from CSV', - description: 'Imports ticket data from a CSV file', + description: 'Imports ticket data from a CSV file. Supported columns: id, number, attendee_email, attendee_first_name, attendee_last_name, attendee_tags, attendee_company, attendee_company_id, ticket_type_name, ticket_type_id, promo_code_id, promo_code, ticket_promo_code, badge_type_id, badge_type_name, one column per badge feature name (1/0) and one extra_question:{question name} column per order extra question (Ticket/Both usage; for list type questions use the value name/label, "|" separated for multi value).', security: [['summit_tickets_oauth2' => [ SummitScopes::WriteSummitData, SummitScopes::WriteRegistrationData, diff --git a/app/Services/Model/ISummitOrderService.php b/app/Services/Model/ISummitOrderService.php index c16b67385..273acbe70 100644 --- a/app/Services/Model/ISummitOrderService.php +++ b/app/Services/Model/ISummitOrderService.php @@ -25,6 +25,9 @@ */ interface ISummitOrderService extends IProcessPaymentService { + // ticket data import csv column naming convention for order extra question answers + const ExtraQuestionColumnPrefix = 'extra_question:'; + /** * @param Member|null $owner * @param Summit $summit diff --git a/app/Services/Model/Imp/SummitOrderService.php b/app/Services/Model/Imp/SummitOrderService.php index bff004ce2..6b29c4b8c 100644 --- a/app/Services/Model/Imp/SummitOrderService.php +++ b/app/Services/Model/Imp/SummitOrderService.php @@ -26,6 +26,8 @@ use App\Jobs\ProcessTicketDataImport; use App\Jobs\SendAttendeeInvitationEmail; use App\Jobs\Utils\JobDispatcher; +use App\Models\Foundation\ExtraQuestions\ExtraQuestionType; +use App\Models\Foundation\ExtraQuestions\ExtraQuestionTypeConstants; use App\Models\Foundation\Summit\Factories\SummitOrderFactory; use App\Models\Foundation\Summit\Registration\IBuildDefaultPaymentGatewayProfileStrategy; use App\Models\Foundation\Summit\Registration\PromoCodes\PromoCodesUtils; @@ -4284,6 +4286,7 @@ public function importTicketData(Summit $summit, UploadedFile $csv_file): void * badge_type_id (optional) * badge_type_name (optional) * one col per feature + * extra_question:{question name} (optional, one col per order extra question - Ticket/Both usage) */ // validate format with col names @@ -4583,6 +4586,11 @@ public function processTicketData(int $summit_id, string $filename) Log::debug(sprintf("SummitOrderService::processTicketData - got ticket %s (%s)", $ticket->getId(), $ticket->getNumber())); + // extra questions ( extra_question:{question name} columns ) + $answers_owner = !is_null($attendee) ? $attendee : ($ticket->hasOwner() ? $ticket->getOwner() : null); + if (!is_null($answers_owner)) + $this->upsertAttendeeExtraQuestionAnswers($summit, $answers_owner, $row); + // badge data if (!$badge_data_present) { Log::warning("SummitOrderService::processTicketData badge data is not present stop current row processing."); @@ -4657,6 +4665,182 @@ public function processTicketData(int $summit_id, string $filename) $this->download_strategy->delete($path); } + /** + * Upserts attendee extra question answers from a ticket data import row + * (one column per question, "extra_question:{question name}" naming convention). + * Unknown question names, order-scoped questions, disallowed questions and empty values are + * skipped, never fail the row. Mandatory-question completeness is not enforced ( admin bulk load ). + * Former answers not present on the row are carried over on a best effort basis: the shared + * persistence path ( ExtraQuestionAnswerHolder::hadCompletedExtraQuestions ) rebuilds the full + * answers set and drops answers whose question no longer exists or is no longer allowed for the + * attendee, same as the admin attendee update flow. + * @param Summit $summit + * @param SummitAttendee $attendee + * @param array $row + * @throws ValidationException + */ + private function upsertAttendeeExtraQuestionAnswers(Summit $summit, SummitAttendee $attendee, array $row): void + { + $new_answers = []; + + foreach ($row as $col => $value) { + + if (!str_starts_with(strval($col), self::ExtraQuestionColumnPrefix)) continue; + + $question_name = trim(substr($col, strlen(self::ExtraQuestionColumnPrefix))); + $value = trim(strval($value)); + + if ($value === '') { + Log::debug + ( + sprintf + ( + "SummitOrderService::upsertAttendeeExtraQuestionAnswers question %s has an empty value for attendee %s, skipping it", + $question_name, + $attendee->getEmail() + ) + ); + continue; + } + + $question = $summit->getOrderExtraQuestionByName($question_name); + if (is_null($question)) { + Log::warning + ( + sprintf + ( + "SummitOrderService::upsertAttendeeExtraQuestionAnswers question %s does not exist on summit %s, skipping it", + $question_name, + $summit->getId() + ) + ); + continue; + } + + if ($question->getUsage() === SummitOrderExtraQuestionTypeConstants::OrderQuestionUsage) { + Log::warning + ( + sprintf + ( + "SummitOrderService::upsertAttendeeExtraQuestionAnswers question %s is order scoped, can not be answered per attendee, skipping it", + $question_name + ) + ); + continue; + } + + if (!$attendee->isAllowedQuestion($question)) { + Log::warning + ( + sprintf + ( + "SummitOrderService::upsertAttendeeExtraQuestionAnswers question %s is not allowed for attendee %s, skipping it", + $question_name, + $attendee->getEmail() + ) + ); + continue; + } + + if ($question->allowsValues()) { + // list type questions store the value id(s) ( comma separated when multi value ) + // csv cells accept the value name/label ( "|" separated for multi value ) or the raw value id + $value_ids = []; + foreach (explode('|', $value) as $v) { + $v = trim($v); + if ($v === '') continue; + $question_value = $question->getValueByName($v); + if (is_null($question_value)) + $question_value = $question->getValueByLabel($v); + if (is_null($question_value) && is_numeric($v)) + $question_value = $question->getValueById(intval($v)); + if (is_null($question_value)) { + Log::warning + ( + sprintf + ( + "SummitOrderService::upsertAttendeeExtraQuestionAnswers value %s does not exist on question %s, skipping it", + $v, + $question_name + ) + ); + continue; + } + $value_ids[] = $question_value->getId(); + } + if (count($value_ids) === 0) continue; + // only CheckBoxList questions admit multiple selected values + if (count($value_ids) > 1 && $question->getType() !== ExtraQuestionTypeConstants::CheckBoxListQuestionType) { + Log::warning + ( + sprintf + ( + "SummitOrderService::upsertAttendeeExtraQuestionAnswers question %s admits a single value, got %s, skipping it", + $question_name, + count($value_ids) + ) + ); + continue; + } + $value = implode(ExtraQuestionType::QuestionChoicesCharSeparator, $value_ids); + } + + $former_answer = $attendee->getExtraQuestionAnswerByQuestion($question); + $former_value = is_null($former_answer) ? '' : $former_answer->getValue(); + if (!empty($former_value) && $former_value != $value && !$attendee->canChangeAnswerValue($question)) { + Log::warning + ( + sprintf + ( + "SummitOrderService::upsertAttendeeExtraQuestionAnswers answer for question %s can not be changed by this time for attendee %s, skipping it", + $question_name, + $attendee->getEmail() + ) + ); + continue; + } + + $new_answers[$question->getId()] = $value; + } + + if (count($new_answers) === 0) return; + + if (!$attendee->hasAllowedExtraQuestions()) { + Log::warning + ( + sprintf + ( + "SummitOrderService::upsertAttendeeExtraQuestionAnswers attendee %s does not have allowed extra questions, skipping answers", + $attendee->getEmail() + ) + ); + return; + } + + // upsert semantics: the persistence path rebuilds the full answers set, so carry over + // the former answers not overridden by the csv row ( best effort, see docblock ) + $extra_questions = []; + foreach ($attendee->getExtraQuestionAnswers() as $former_answer) { + $question_id = $former_answer->getQuestionId(); + if (!array_key_exists($question_id, $new_answers)) + $extra_questions[] = ['question_id' => $question_id, 'answer' => $former_answer->getValue()]; + } + foreach ($new_answers as $question_id => $value) + $extra_questions[] = ['question_id' => $question_id, 'answer' => $value]; + + Log::debug + ( + sprintf + ( + "SummitOrderService::upsertAttendeeExtraQuestionAnswers attendee %s answers %s", + $attendee->getEmail(), + json_encode($extra_questions) + ) + ); + + $attendee->hadCompletedExtraQuestions($extra_questions); + } + /** * @param Summit $summit * @param array $payload diff --git a/tests/SummitOrderServiceTest.php b/tests/SummitOrderServiceTest.php index b75299c49..470ec60a7 100644 --- a/tests/SummitOrderServiceTest.php +++ b/tests/SummitOrderServiceTest.php @@ -14,6 +14,8 @@ use App\Jobs\Emails\Registration\Reminders\SummitOrderReminderEmail; use App\Jobs\Emails\Registration\Reminders\SummitTicketReminderEmail; +use App\Models\Foundation\ExtraQuestions\ExtraQuestionTypeConstants; +use App\Models\Foundation\ExtraQuestions\ExtraQuestionTypeValue; use App\Models\Foundation\Main\IGroup; use App\Models\Foundation\Summit\Registration\IBuildDefaultPaymentGatewayProfileStrategy; use App\Models\Foundation\Summit\Repositories\ISummitAttendeeBadgePrintRuleRepository; @@ -29,6 +31,7 @@ use App\Services\Model\SummitOrderService; use App\Services\Utils\ILockManagerService; use Illuminate\Support\Facades\App; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Queue; use libs\utils\ITransactionService; use Mockery; @@ -43,7 +46,11 @@ use models\summit\Summit; use models\summit\SummitAttendee; use models\summit\SummitAttendeeTicket; +use models\summit\SummitBadgeFeatureType; use models\summit\SummitOrder; +use models\summit\SummitOrderExtraQuestionAnswer; +use models\summit\SummitOrderExtraQuestionType; +use models\summit\SummitOrderExtraQuestionTypeConstants; use models\summit\SummitTicketType; /** @@ -373,4 +380,296 @@ public function testAutoAssignDifferentPrePaidTicketsUntilEmpty() { $this->assertTrue(str_starts_with($ex->getMessage(), 'No more available PrePaid Tickets for Promo Code')); } } + + /** + * @param string $csv_content + * @return ISummitOrderService + */ + private function buildTicketDataImportService(string $csv_content): ISummitOrderService + { + $upload_strategy = Mockery::mock(IFileUploadStrategy::class); + + $download_strategy = Mockery::mock(IFileDownloadStrategy::class); + $download_strategy->shouldReceive('exists')->andReturn(true); + $download_strategy->shouldReceive('get')->andReturn($csv_content); + $download_strategy->shouldReceive('getDriver')->andReturn('mock'); + $download_strategy->shouldReceive('delete'); + + return new SummitOrderService( + App::make(ISummitTicketTypeRepository::class), + App::make(IMemberRepository::class), + App::make(ISummitRegistrationPromoCodeRepository::class), + App::make(\models\summit\ISummitPromoCodeMemberReservationRepository::class), + App::make(ISummitAttendeeRepository::class), + App::make(ISummitOrderRepository::class), + App::make(ISummitAttendeeTicketRepository::class), + App::make(ISummitAttendeeBadgeRepository::class), + App::make(ISummitRepository::class), + App::make(ISummitAttendeeBadgePrintRuleRepository::class), + App::make(IMemberService::class), + App::make(IBuildDefaultPaymentGatewayProfileStrategy::class), + $upload_strategy, + $download_strategy, + App::make(ICompanyRepository::class), + App::make(ITagRepository::class), + App::make(ISummitRefundRequestRepository::class), + App::make(ICompanyService::class), + App::make(ITicketFinderStrategyFactory::class), + App::make(ITransactionService::class), + App::make(ILockManagerService::class) + ); + } + + /** + * @param string $name + * @param string $type + * @param array $values + * @return SummitOrderExtraQuestionType + */ + private function insertOrderExtraQuestion + ( + string $name, + string $type = ExtraQuestionTypeConstants::TextQuestionType, + array $values = [] + ): SummitOrderExtraQuestionType + { + $question = new SummitOrderExtraQuestionType(); + $question->setName($name); + $question->setLabel($name); + $question->setType($type); + $question->setUsage(SummitOrderExtraQuestionTypeConstants::TicketQuestionUsage); + + foreach ($values as $value_name) { + $value = new ExtraQuestionTypeValue(); + $value->setValue($value_name); + $value->setLabel($value_name); + $question->addValue($value); + } + + self::$summit->addOrderExtraQuestion($question); + self::$em->persist(self::$summit); + self::$em->flush(); + + return $question; + } + + /** + * @param SummitAttendee $attendee + * @param SummitOrderExtraQuestionType $question + * @param string $value + */ + private function insertExtraQuestionAnswer(SummitAttendee $attendee, SummitOrderExtraQuestionType $question, string $value): void + { + $answer = new SummitOrderExtraQuestionAnswer(); + $answer->setQuestion($question); + $answer->setValue($value); + $attendee->addExtraQuestionAnswer($answer); + self::$em->persist($attendee); + self::$em->flush(); + } + + /** + * @return SummitAttendeeTicket + */ + private function getUnassignedTicket(): SummitAttendeeTicket + { + foreach (self::$summit->getOrders() as $order) { + foreach ($order->getTickets() as $ticket) { + if (!$ticket->hasOwner()) return $ticket; + } + } + $this->fail('no unassigned ticket available on test fixture'); + } + + /** + * @return SummitAttendee + */ + private function getDefaultAttendee(): SummitAttendee + { + $attendee = App::make(ISummitAttendeeRepository::class) + ->getBySummitAndEmail(self::$summit, self::$defaultMember->getEmail()); + $this->assertNotNull($attendee); + return $attendee; + } + + public function testImportTicketDataSetsExtraQuestionAnswerOnNewAttendee() + { + Queue::fake(); + + $question = $this->insertOrderExtraQuestion('Dietary Requirements'); + $ticket = $this->getUnassignedTicket(); + + $csv_content = <<getNumber()},new.attendee@nowhere.com,New,Attendee,Vegan +CSV; + + $service = $this->buildTicketDataImportService($csv_content); + $service->processTicketData(self::$summit->getId(), 'tickets.csv'); + + $attendee = App::make(ISummitAttendeeRepository::class) + ->getBySummitAndEmail(self::$summit, 'new.attendee@nowhere.com'); + + $this->assertNotNull($attendee); + $answer = $attendee->getExtraQuestionAnswerByQuestion($question); + $this->assertNotNull($answer); + $this->assertEquals('Vegan', $answer->getValue()); + } + + public function testImportTicketDataUpdatesExtraQuestionAnswerOnExistingAttendee() + { + Queue::fake(); + + // background import runs without an authenticated admin, so answer updates + // are gated by the summit level setting + self::$summit->setAllowUpdateAttendeeExtraQuestions(true); + self::$em->persist(self::$summit); + self::$em->flush(); + + // SummitAttendee::canChangeAnswerValue caches per attendee id for 60 secs, + // drop any stale entry from a previous run ( attendee ids are reused across DB re-seeds ) + Cache::flush(); + + $question = $this->insertOrderExtraQuestion('Dietary Requirements'); + $attendee = $this->getDefaultAttendee(); + $this->insertExtraQuestionAnswer($attendee, $question, 'Meat'); + + $ticket = $attendee->getTickets()->first(); + + $csv_content = <<getNumber()},{$attendee->getEmail()},Vegan +CSV; + + $service = $this->buildTicketDataImportService($csv_content); + $service->processTicketData(self::$summit->getId(), 'tickets.csv'); + + $answer = $attendee->getExtraQuestionAnswerByQuestion($question); + $this->assertNotNull($answer); + $this->assertEquals('Vegan', $answer->getValue()); + } + + public function testImportTicketDataSkipsUnknownExtraQuestion() + { + Queue::fake(); + + $this->insertOrderExtraQuestion('Dietary Requirements'); + $ticket = $this->getUnassignedTicket(); + + $csv_content = <<getNumber()},new.attendee@nowhere.com,New,Attendee,Some Value +CSV; + + $service = $this->buildTicketDataImportService($csv_content); + $service->processTicketData(self::$summit->getId(), 'tickets.csv'); + + // row is still processed ( attendee created and ticket assigned ), unknown question is skipped + $attendee = App::make(ISummitAttendeeRepository::class) + ->getBySummitAndEmail(self::$summit, 'new.attendee@nowhere.com'); + + $this->assertNotNull($attendee); + $this->assertCount(0, $attendee->getExtraQuestionAnswers()); + } + + public function testImportTicketDataIgnoresEmptyExtraQuestionValue() + { + Queue::fake(); + + $question = $this->insertOrderExtraQuestion('Dietary Requirements'); + $attendee = $this->getDefaultAttendee(); + $this->insertExtraQuestionAnswer($attendee, $question, 'Vegan'); + + $ticket = $attendee->getTickets()->first(); + + $csv_content = <<getNumber()},{$attendee->getEmail()}, +CSV; + + $service = $this->buildTicketDataImportService($csv_content); + $service->processTicketData(self::$summit->getId(), 'tickets.csv'); + + // former answer is preserved, empty values never clear answers + $answer = $attendee->getExtraQuestionAnswerByQuestion($question); + $this->assertNotNull($answer); + $this->assertEquals('Vegan', $answer->getValue()); + } + + public function testImportTicketDataListQuestionStoresValueIds() + { + Queue::fake(); + + $question = $this->insertOrderExtraQuestion + ( + 'T-Shirt Size', + ExtraQuestionTypeConstants::CheckBoxListQuestionType, + ['Small', 'Large'] + ); + + $ticket = $this->getUnassignedTicket(); + + $csv_content = <<getNumber()},new.attendee@nowhere.com,New,Attendee,Small|Large +CSV; + + $service = $this->buildTicketDataImportService($csv_content); + $service->processTicketData(self::$summit->getId(), 'tickets.csv'); + + $attendee = App::make(ISummitAttendeeRepository::class) + ->getBySummitAndEmail(self::$summit, 'new.attendee@nowhere.com'); + + $this->assertNotNull($attendee); + $answer = $attendee->getExtraQuestionAnswerByQuestion($question); + $this->assertNotNull($answer); + + $expected_value = implode(',', [ + $question->getValueByName('Small')->getId(), + $question->getValueByName('Large')->getId(), + ]); + $this->assertEquals($expected_value, $answer->getValue()); + } + + public function testImportTicketDataBadgeFeaturesStillClearedAndReSet() + { + Queue::fake(); + + $feature1 = new SummitBadgeFeatureType(); + $feature1->setName('FEATURE 1'); + self::$summit->addFeatureType($feature1); + + $feature2 = new SummitBadgeFeatureType(); + $feature2->setName('FEATURE 2'); + self::$summit->addFeatureType($feature2); + + $question = $this->insertOrderExtraQuestion('Dietary Requirements'); + + $attendee = $this->getDefaultAttendee(); + $ticket = $attendee->getTickets()->first(); + $badge = $ticket->getBadge(); + $badge->addFeature($feature1); + self::$em->persist(self::$summit); + self::$em->flush(); + + $csv_content = <<getNumber()},{$attendee->getEmail()},BADGE TYPE1,0,1,Vegan +CSV; + + $service = $this->buildTicketDataImportService($csv_content); + $service->processTicketData(self::$summit->getId(), 'tickets.csv'); + + // badge features are cleared and re set from the csv columns + $feature_names = []; + foreach ($badge->getFeatures() as $feature) { + $feature_names[] = $feature->getName(); + } + $this->assertEquals(['FEATURE 2'], $feature_names); + + // extra question answer is set on the same row + $answer = $attendee->getExtraQuestionAnswerByQuestion($question); + $this->assertNotNull($answer); + $this->assertEquals('Vegan', $answer->getValue()); + } }