diff --git a/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php index e9b7b492e5..c571b2c67d 100644 --- a/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php @@ -61,6 +61,7 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\ final public const TICKET_DESIGN_SETTINGS = 'ticket_design_settings'; final public const ATTENDEE_DETAILS_COLLECTION_METHOD = 'attendee_details_collection_method'; final public const SHOW_MARKETING_OPT_IN = 'show_marketing_opt_in'; + final public const ALLOW_COPY_DETAILS_TO_ALL_ATTENDEES = 'allow_copy_details_to_all_attendees'; final public const HOMEPAGE_THEME_SETTINGS = 'homepage_theme_settings'; final public const PASS_PLATFORM_FEE_TO_BUYER = 'pass_platform_fee_to_buyer'; final public const ALLOW_ATTENDEE_SELF_EDIT = 'allow_attendee_self_edit'; @@ -119,6 +120,7 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\ protected array|string|null $ticket_design_settings = null; protected string $attendee_details_collection_method = 'PER_TICKET'; protected bool $show_marketing_opt_in = true; + protected bool $allow_copy_details_to_all_attendees = true; protected array|string|null $homepage_theme_settings = null; protected bool $pass_platform_fee_to_buyer = false; protected bool $allow_attendee_self_edit = true; @@ -180,6 +182,7 @@ public function toArray(): array 'ticket_design_settings' => $this->ticket_design_settings ?? null, 'attendee_details_collection_method' => $this->attendee_details_collection_method ?? null, 'show_marketing_opt_in' => $this->show_marketing_opt_in ?? null, + 'allow_copy_details_to_all_attendees' => $this->allow_copy_details_to_all_attendees ?? null, 'homepage_theme_settings' => $this->homepage_theme_settings ?? null, 'pass_platform_fee_to_buyer' => $this->pass_platform_fee_to_buyer ?? null, 'allow_attendee_self_edit' => $this->allow_attendee_self_edit ?? null, @@ -751,6 +754,17 @@ public function getShowMarketingOptIn(): bool return $this->show_marketing_opt_in; } + public function setAllowCopyDetailsToAllAttendees(bool $allow_copy_details_to_all_attendees): self + { + $this->allow_copy_details_to_all_attendees = $allow_copy_details_to_all_attendees; + return $this; + } + + public function getAllowCopyDetailsToAllAttendees(): bool + { + return $this->allow_copy_details_to_all_attendees; + } + public function setHomepageThemeSettings(array|string|null $homepage_theme_settings): self { $this->homepage_theme_settings = $homepage_theme_settings; diff --git a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php index beb64f2b7c..d99533f0c1 100644 --- a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php +++ b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php @@ -92,6 +92,9 @@ public function rules(): array // Marketing settings 'show_marketing_opt_in' => ['boolean'], + // Attendee detail copy control + 'allow_copy_details_to_all_attendees' => ['boolean'], + // Platform fee settings 'pass_platform_fee_to_buyer' => ['boolean'], diff --git a/backend/app/Resources/Event/EventSettingsResource.php b/backend/app/Resources/Event/EventSettingsResource.php index b61c69bf09..c10b065be9 100644 --- a/backend/app/Resources/Event/EventSettingsResource.php +++ b/backend/app/Resources/Event/EventSettingsResource.php @@ -71,6 +71,9 @@ public function toArray($request): array // Marketing settings 'show_marketing_opt_in' => $this->getShowMarketingOptIn(), + // Attendee detail copy control + 'allow_copy_details_to_all_attendees' => $this->getAllowCopyDetailsToAllAttendees(), + // Platform fee settings 'pass_platform_fee_to_buyer' => $this->getPassPlatformFeeToBuyer(), diff --git a/backend/app/Resources/Event/EventSettingsResourcePublic.php b/backend/app/Resources/Event/EventSettingsResourcePublic.php index 02ed37b5c2..822d6a0cc3 100644 --- a/backend/app/Resources/Event/EventSettingsResourcePublic.php +++ b/backend/app/Resources/Event/EventSettingsResourcePublic.php @@ -77,6 +77,9 @@ public function toArray($request): array // Marketing settings 'show_marketing_opt_in' => $this->getShowMarketingOptIn(), + // Attendee detail copy control + 'allow_copy_details_to_all_attendees' => $this->getAllowCopyDetailsToAllAttendees(), + // Platform fee settings 'pass_platform_fee_to_buyer' => $this->getPassPlatformFeeToBuyer(), diff --git a/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php b/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php index ca73dec5a3..86ce00afdc 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php +++ b/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php @@ -76,6 +76,9 @@ public function __construct( // Marketing settings public readonly bool $show_marketing_opt_in = true, + // Attendee detail copy control + public readonly bool $allow_copy_details_to_all_attendees = true, + // Platform fee settings public readonly bool $pass_platform_fee_to_buyer = false, @@ -158,6 +161,9 @@ public static function createWithDefaults( // Marketing defaults show_marketing_opt_in: true, + // Attendee detail copy control default + allow_copy_details_to_all_attendees: true, + // Platform fee defaults pass_platform_fee_to_buyer: false, diff --git a/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php b/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php index cbad542a81..f873196507 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php @@ -127,6 +127,9 @@ public function handle(PartialUpdateEventSettingsDTO $eventSettingsDTO): EventSe // Marketing settings 'show_marketing_opt_in' => $eventSettingsDTO->settings['show_marketing_opt_in'] ?? $existingSettings->getShowMarketingOptIn(), + // Attendee detail copy control + 'allow_copy_details_to_all_attendees' => $eventSettingsDTO->settings['allow_copy_details_to_all_attendees'] ?? $existingSettings->getAllowCopyDetailsToAllAttendees(), + // Platform fee settings 'pass_platform_fee_to_buyer' => $eventSettingsDTO->settings['pass_platform_fee_to_buyer'] ?? $existingSettings->getPassPlatformFeeToBuyer(), diff --git a/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php b/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php index de98ee5862..953d819401 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php @@ -89,6 +89,9 @@ public function handle(UpdateEventSettingsDTO $settings): EventSettingDomainObje // Marketing settings 'show_marketing_opt_in' => $settings->show_marketing_opt_in, + // Attendee detail copy control + 'allow_copy_details_to_all_attendees' => $settings->allow_copy_details_to_all_attendees, + // Platform fee settings 'pass_platform_fee_to_buyer' => $settings->pass_platform_fee_to_buyer, diff --git a/backend/app/Services/Domain/Event/CreateEventService.php b/backend/app/Services/Domain/Event/CreateEventService.php index 76e08b974d..a963784ee3 100644 --- a/backend/app/Services/Domain/Event/CreateEventService.php +++ b/backend/app/Services/Domain/Event/CreateEventService.php @@ -226,6 +226,7 @@ private function createEventSettings( 'attendee_details_collection_method' => $organizerSettings->getDefaultAttendeeDetailsCollectionMethod(), 'show_marketing_opt_in' => $organizerSettings->getDefaultShowMarketingOptIn(), + 'allow_copy_details_to_all_attendees' => true, 'pass_platform_fee_to_buyer' => $organizerSettings->getDefaultPassPlatformFeeToBuyer(), 'allow_attendee_self_edit' => $organizerSettings->getDefaultAllowAttendeeSelfEdit() ?? false, 'ticket_design_settings' => [ diff --git a/backend/database/migrations/2026_06_04_120000_add_allow_copy_details_to_all_attendees_to_event_settings.php b/backend/database/migrations/2026_06_04_120000_add_allow_copy_details_to_all_attendees_to_event_settings.php new file mode 100644 index 0000000000..d508f44bf3 --- /dev/null +++ b/backend/database/migrations/2026_06_04_120000_add_allow_copy_details_to_all_attendees_to_event_settings.php @@ -0,0 +1,22 @@ +boolean('allow_copy_details_to_all_attendees')->default(true)->after('show_marketing_opt_in'); + }); + } + + public function down(): void + { + Schema::table('event_settings', function (Blueprint $table) { + $table->dropColumn('allow_copy_details_to_all_attendees'); + }); + } +}; diff --git a/backend/tests/Unit/Http/Request/EventSettings/UpdateEventSettingsRequestTest.php b/backend/tests/Unit/Http/Request/EventSettings/UpdateEventSettingsRequestTest.php index 0e66005956..66d8ffa3f2 100644 --- a/backend/tests/Unit/Http/Request/EventSettings/UpdateEventSettingsRequestTest.php +++ b/backend/tests/Unit/Http/Request/EventSettings/UpdateEventSettingsRequestTest.php @@ -43,4 +43,34 @@ public function test_date_display_mode_is_optional(): void $this->assertFalse($validator->errors()->has('ticket_design_settings.date_display_mode')); } + + public function test_allow_copy_details_to_all_attendees_accepts_boolean(): void + { + $validator = Validator::make( + ['allow_copy_details_to_all_attendees' => false], + (new UpdateEventSettingsRequest)->rules() + ); + + $this->assertFalse($validator->errors()->has('allow_copy_details_to_all_attendees')); + } + + public function test_allow_copy_details_to_all_attendees_rejects_non_boolean(): void + { + $validator = Validator::make( + ['allow_copy_details_to_all_attendees' => 'not-a-boolean'], + (new UpdateEventSettingsRequest)->rules() + ); + + $this->assertTrue($validator->errors()->has('allow_copy_details_to_all_attendees')); + } + + public function test_allow_copy_details_to_all_attendees_is_optional(): void + { + $validator = Validator::make( + ['ticket_design_settings' => ['accent_color' => '#333333']], + (new UpdateEventSettingsRequest)->rules() + ); + + $this->assertFalse($validator->errors()->has('allow_copy_details_to_all_attendees')); + } } diff --git a/backend/tests/Unit/Resources/Event/EventSettingsResourcePublicTest.php b/backend/tests/Unit/Resources/Event/EventSettingsResourcePublicTest.php new file mode 100644 index 0000000000..96fa0dccaa --- /dev/null +++ b/backend/tests/Unit/Resources/Event/EventSettingsResourcePublicTest.php @@ -0,0 +1,34 @@ +setAllowCopyDetailsToAllAttendees(true); + + $resource = (new EventSettingsResourcePublic($settings))->toArray(Request::create('/')); + + // Load-bearing: the checkout can only hide the control if this flag is on the + // public payload, so it must always be present (outside the post-checkout block). + $this->assertArrayHasKey('allow_copy_details_to_all_attendees', $resource); + $this->assertTrue($resource['allow_copy_details_to_all_attendees']); + } + + public function test_public_resource_exposes_allow_copy_details_when_disabled(): void + { + $settings = (new EventSettingDomainObject()) + ->setAllowCopyDetailsToAllAttendees(false); + + $resource = (new EventSettingsResourcePublic($settings))->toArray(Request::create('/')); + + $this->assertFalse($resource['allow_copy_details_to_all_attendees']); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandlerTest.php new file mode 100644 index 0000000000..55d841ed00 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandlerTest.php @@ -0,0 +1,76 @@ +runPartialUpdate( + existingValue: true, + settings: ['allow_copy_details_to_all_attendees' => false], + ); + + $this->assertFalse($dto->allow_copy_details_to_all_attendees); + } + + public function test_omitted_show_copy_details_key_falls_back_to_existing_value(): void + { + // Existing event has the control OFF; the PATCH omits the key entirely. + // It must keep the existing value (false), NOT reset to the default (true). + $dto = $this->runPartialUpdate( + existingValue: false, + settings: [], + ); + + $this->assertFalse($dto->allow_copy_details_to_all_attendees); + } + + /** + * Drives the partial handler and returns the UpdateEventSettingsDTO it forwards + * to the (mocked) full handler, so we can assert how the field was resolved. + */ + private function runPartialUpdate(bool $existingValue, array $settings): UpdateEventSettingsDTO + { + $existingSettings = (new EventSettingDomainObject()) + ->setAllowCopyDetailsToAllAttendees($existingValue) + ->setPaymentProviders([]); + + $repository = Mockery::mock(EventSettingsRepositoryInterface::class); + $repository->shouldReceive('findFirstWhere') + ->with(['event_id' => 1]) + ->andReturn($existingSettings); + + $captured = null; + $fullHandler = Mockery::mock(UpdateEventSettingsHandler::class); + $fullHandler->shouldReceive('handle') + ->once() + ->andReturnUsing(function (UpdateEventSettingsDTO $dto) use (&$captured, $existingSettings) { + $captured = $dto; + return $existingSettings; + }); + + $handler = new PartialUpdateEventSettingsHandler($fullHandler, $repository); + + $handler->handle(new PartialUpdateEventSettingsDTO( + account_id: 1, + event_id: 1, + settings: array_merge(['location_details' => []], $settings), + )); + + return $captured; + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandlerTest.php index 57d97eebc6..9b364bfd04 100644 --- a/backend/tests/Unit/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandlerTest.php @@ -118,7 +118,43 @@ public function testDoesNotDispatchEventWhenAutoProcessDisabled(): void Event::assertNotDispatched(CapacityChangedEvent::class); } - private function createDTO(?bool $waitlist_auto_process = null): UpdateEventSettingsDTO + public function testPersistsAllowCopyDetailsToAllAttendees(): void + { + Event::fake(); + + $existingSettings = new EventSettingDomainObject(); + + $this->eventSettingsRepository + ->shouldReceive('findFirstWhere') + ->with(['event_id' => 1]) + ->twice() + ->andReturn($existingSettings); + + $captured = null; + $this->eventSettingsRepository + ->shouldReceive('updateWhere') + ->once() + ->andReturnUsing(function (...$args) use (&$captured) { + foreach ($args as $arg) { + if (is_array($arg) && array_key_exists('allow_copy_details_to_all_attendees', $arg)) { + $captured = $arg['allow_copy_details_to_all_attendees']; + } + } + return 1; + }); + + $this->handler->handle($this->createDTO(allow_copy_details_to_all_attendees: false)); + + $this->assertFalse( + $captured, + 'Expected allow_copy_details_to_all_attendees=false to be persisted via updateWhere' + ); + } + + private function createDTO( + ?bool $waitlist_auto_process = null, + bool $allow_copy_details_to_all_attendees = true, + ): UpdateEventSettingsDTO { return UpdateEventSettingsDTO::fromArray([ 'account_id' => 1, @@ -143,6 +179,7 @@ private function createDTO(?bool $waitlist_auto_process = null): UpdateEventSett 'seo_title' => null, 'seo_description' => null, 'seo_keywords' => null, + 'allow_copy_details_to_all_attendees' => $allow_copy_details_to_all_attendees, 'waitlist_auto_process' => $waitlist_auto_process, 'waitlist_offer_timeout_minutes' => 60, ]); diff --git a/frontend/src/components/routes/event/Settings/Sections/HomepageAndCheckoutSettings/index.tsx b/frontend/src/components/routes/event/Settings/Sections/HomepageAndCheckoutSettings/index.tsx index 76fba0c3c2..a62306b6dc 100644 --- a/frontend/src/components/routes/event/Settings/Sections/HomepageAndCheckoutSettings/index.tsx +++ b/frontend/src/components/routes/event/Settings/Sections/HomepageAndCheckoutSettings/index.tsx @@ -26,6 +26,7 @@ export const HomepageAndCheckoutSettings = () => { order_timeout_in_minutes: 15, attendee_details_collection_method: 'PER_TICKET' as 'PER_TICKET' | 'PER_ORDER', show_marketing_opt_in: true, + allow_copy_details_to_all_attendees: true, }, transformValues: (values) => ({ ...values, @@ -58,6 +59,7 @@ export const HomepageAndCheckoutSettings = () => { order_timeout_in_minutes: eventSettingsQuery.data.order_timeout_in_minutes, attendee_details_collection_method: eventSettingsQuery.data.attendee_details_collection_method || 'PER_TICKET', show_marketing_opt_in: eventSettingsQuery.data.show_marketing_opt_in ?? true, + allow_copy_details_to_all_attendees: eventSettingsQuery.data.allow_copy_details_to_all_attendees ?? true, }); } }, [eventSettingsQuery.isFetched]); @@ -128,6 +130,13 @@ export const HomepageAndCheckoutSettings = () => { {...form.getInputProps('show_marketing_opt_in', {type: 'checkbox'})} /> + + diff --git a/frontend/src/components/routes/product-widget/CollectInformation/index.tsx b/frontend/src/components/routes/product-widget/CollectInformation/index.tsx index 671cf19412..a0777f36ba 100644 --- a/frontend/src/components/routes/product-widget/CollectInformation/index.tsx +++ b/frontend/src/components/routes/product-widget/CollectInformation/index.tsx @@ -71,6 +71,7 @@ export const CollectInformation = () => { const products = productCategories?.flatMap(category => category.products); const requireBillingAddress = event?.settings?.require_billing_address; const isPerOrderCollection = event?.settings?.attendee_details_collection_method === 'PER_ORDER'; + const allowCopyToAllAttendees = event?.settings?.allow_copy_details_to_all_attendees ?? true; const [copyOption, setCopyOption] = useState<'none' | 'first' | 'all'>('none'); const isEmailValid = (email: string) => { @@ -498,7 +499,7 @@ export const CollectInformation = () => { data={[ {label: t`None`, value: 'none'}, {label: t`First attendee`, value: 'first'}, - {label: t`All attendees`, value: 'all'}, + ...(allowCopyToAllAttendees ? [{label: t`All attendees`, value: 'all'}] : []), ]} /> diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 039bc3e615..707ddcabc6 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -247,6 +247,9 @@ export interface EventSettings { // Marketing settings show_marketing_opt_in?: boolean; + // Attendee detail copy control + allow_copy_details_to_all_attendees?: boolean; + // Platform fee settings pass_platform_fee_to_buyer?: boolean;