diff --git a/backend/app/DomainObjects/Enums/MessageTypeEnum.php b/backend/app/DomainObjects/Enums/MessageTypeEnum.php index f1179b2d1b..8eb21e76f2 100644 --- a/backend/app/DomainObjects/Enums/MessageTypeEnum.php +++ b/backend/app/DomainObjects/Enums/MessageTypeEnum.php @@ -20,4 +20,10 @@ enum MessageTypeEnum // Emails all customers who have purchased a specific product, ticket or merchandise etc. case ORDER_OWNERS_WITH_PRODUCT; + + // Emails attendees who have checked in to the event + case CHECKED_IN_ATTENDEES; + + // Emails attendees who have not checked in to the event + case NOT_CHECKED_IN_ATTENDEES; } diff --git a/backend/app/Http/Actions/Messages/SendMessageAction.php b/backend/app/Http/Actions/Messages/SendMessageAction.php index 8c72b11049..107aedfbf3 100644 --- a/backend/app/Http/Actions/Messages/SendMessageAction.php +++ b/backend/app/Http/Actions/Messages/SendMessageAction.php @@ -43,6 +43,7 @@ public function __invoke(SendMessageRequest $request, int $eventId): JsonRespons 'sent_by_user_id' => $user->getId(), 'account_id' => $this->getAuthenticatedAccountId(), 'scheduled_at' => $request->input('scheduled_at'), + 'check_in_list_id' => $request->input('check_in_list_id'), ])); } catch (AccountNotVerifiedException $e) { return $this->errorResponse($e->getMessage(), Response::HTTP_UNAUTHORIZED); diff --git a/backend/app/Http/Request/Message/SendMessageRequest.php b/backend/app/Http/Request/Message/SendMessageRequest.php index 5b12e009c6..31dabf2cb4 100644 --- a/backend/app/Http/Request/Message/SendMessageRequest.php +++ b/backend/app/Http/Request/Message/SendMessageRequest.php @@ -26,6 +26,8 @@ public function rules(): array new In([OrderStatus::COMPLETED->name, OrderStatus::AWAITING_OFFLINE_PAYMENT->name]), ], 'scheduled_at' => 'nullable|date', + 'check_in_list_id' => 'nullable|integer|required_if:message_type,' . MessageTypeEnum::CHECKED_IN_ATTENDEES->name + . '|required_if:message_type,' . MessageTypeEnum::NOT_CHECKED_IN_ATTENDEES->name, ]; } diff --git a/backend/app/Repository/Eloquent/AttendeeRepository.php b/backend/app/Repository/Eloquent/AttendeeRepository.php index 8f2ce62ff0..4ff11ac1b5 100644 --- a/backend/app/Repository/Eloquent/AttendeeRepository.php +++ b/backend/app/Repository/Eloquent/AttendeeRepository.php @@ -139,4 +139,97 @@ public function getAttendeesByCheckInShortId(string $shortId, QueryParamsDTO $pa limit: min($params->per_page, 250), ); } + + public function findCheckedInAttendees(int $eventId, ?int $checkInListId = null, array $columns = ['*']): Collection + { + $query = $this->model + ->where('event_id', $eventId) + ->where('status', AttendeeStatus::ACTIVE->name) + ->whereIn('product_id', function ($sub) use ($checkInListId) { + $sub->select('product_id')->from('product_check_in_lists')->whereNull('deleted_at'); + if ($checkInListId) { + $sub->where('check_in_list_id', $checkInListId); + } + }) + ->whereHas('check_ins', function ($sub) use ($checkInListId) { + if ($checkInListId) { + $sub->where('check_in_list_id', $checkInListId); + } + }); + + $results = $query->get($columns); + $this->resetModel(); + + return $this->handleResults($results); + } + + public function findNotCheckedInAttendees(int $eventId, ?int $checkInListId = null, array $columns = ['*']): Collection + { + $query = $this->model + ->where('event_id', $eventId) + ->where('status', AttendeeStatus::ACTIVE->name) + ->whereIn('product_id', function ($sub) use ($checkInListId) { + $sub->select('product_id')->from('product_check_in_lists')->whereNull('deleted_at'); + if ($checkInListId) { + $sub->where('check_in_list_id', $checkInListId); + } + }) + ->whereDoesntHave('check_ins', function ($sub) use ($checkInListId) { + if ($checkInListId) { + $sub->where('check_in_list_id', $checkInListId); + } + }); + + $results = $query->get($columns); + $this->resetModel(); + + return $this->handleResults($results); + } + + public function countCheckedInAttendees(int $eventId, ?int $checkInListId = null): int + { + $query = $this->model + ->where('event_id', $eventId) + ->where('status', AttendeeStatus::ACTIVE->name) + ->whereIn('product_id', function ($sub) use ($checkInListId) { + $sub->select('product_id')->from('product_check_in_lists')->whereNull('deleted_at'); + if ($checkInListId) { + $sub->where('check_in_list_id', $checkInListId); + } + }) + ->whereHas('check_ins', function ($sub) use ($checkInListId) { + if ($checkInListId) { + $sub->where('check_in_list_id', $checkInListId); + } + }); + + $count = $query->count(); + $this->resetModel(); + + return $count; + } + + public function countNotCheckedInAttendees(int $eventId, ?int $checkInListId = null): int + { + $query = $this->model + ->where('event_id', $eventId) + ->where('status', AttendeeStatus::ACTIVE->name) + ->whereIn('product_id', function ($sub) use ($checkInListId) { + $sub->select('product_id')->from('product_check_in_lists')->whereNull('deleted_at'); + if ($checkInListId) { + $sub->where('check_in_list_id', $checkInListId); + } + }) + ->whereDoesntHave('check_ins', function ($sub) use ($checkInListId) { + if ($checkInListId) { + $sub->where('check_in_list_id', $checkInListId); + } + }); + + $count = $query->count(); + + $this->resetModel(); + + return $count; + } } diff --git a/backend/app/Repository/Interfaces/AttendeeRepositoryInterface.php b/backend/app/Repository/Interfaces/AttendeeRepositoryInterface.php index e176a4ce54..5bf17b1005 100644 --- a/backend/app/Repository/Interfaces/AttendeeRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/AttendeeRepositoryInterface.php @@ -18,4 +18,12 @@ public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAware public function findByEventIdForExport(int $eventId): Collection; public function getAttendeesByCheckInShortId(string $shortId, QueryParamsDTO $params): Paginator; + + public function findCheckedInAttendees(int $eventId, ?int $checkInListId = null, array $columns = ['*']): Collection; + + public function findNotCheckedInAttendees(int $eventId, ?int $checkInListId = null, array $columns = ['*']): Collection; + + public function countCheckedInAttendees(int $eventId, ?int $checkInListId = null): int; + + public function countNotCheckedInAttendees(int $eventId, ?int $checkInListId = null): int; } diff --git a/backend/app/Services/Application/Handlers/Message/DTO/SendMessageDTO.php b/backend/app/Services/Application/Handlers/Message/DTO/SendMessageDTO.php index ef6eb83ce4..68ecdcf254 100644 --- a/backend/app/Services/Application/Handlers/Message/DTO/SendMessageDTO.php +++ b/backend/app/Services/Application/Handlers/Message/DTO/SendMessageDTO.php @@ -22,6 +22,7 @@ public function __construct( public readonly ?array $attendee_ids = [], public readonly ?array $product_ids = [], public readonly ?string $scheduled_at = null, + public readonly ?int $check_in_list_id = null, ) { } diff --git a/backend/app/Services/Application/Handlers/Message/SendMessageHandler.php b/backend/app/Services/Application/Handlers/Message/SendMessageHandler.php index 9f1b7b9872..e7abebe281 100644 --- a/backend/app/Services/Application/Handlers/Message/SendMessageHandler.php +++ b/backend/app/Services/Application/Handlers/Message/SendMessageHandler.php @@ -119,6 +119,7 @@ public function handle(SendMessageDTO $messageData): MessageDomainObject 'account_id' => $messageData->account_id, 'attendee_ids' => $messageData->attendee_ids, 'product_ids' => $messageData->product_ids, + 'check_in_list_id' => $messageData->check_in_list_id, ], ]); @@ -139,6 +140,7 @@ public function handle(SendMessageDTO $messageData): MessageDomainObject 'id' => $message->getId(), 'attendee_ids' => $message->getAttendeeIds(), 'product_ids' => $message->getProductIds(), + 'check_in_list_id' => $messageData->check_in_list_id, ]); SendMessagesJob::dispatch($updatedData); @@ -164,6 +166,14 @@ private function estimateRecipientCount(SendMessageDTO $messageData): int productIds: $messageData->product_ids ?? [], orderStatuses: $messageData->order_statuses ?? ['COMPLETED'], ), + MessageTypeEnum::CHECKED_IN_ATTENDEES => $this->attendeeRepository->countCheckedInAttendees( + eventId: $messageData->event_id, + checkInListId: $messageData->check_in_list_id, + ), + MessageTypeEnum::NOT_CHECKED_IN_ATTENDEES => $this->attendeeRepository->countNotCheckedInAttendees( + eventId: $messageData->event_id, + checkInListId: $messageData->check_in_list_id, + ), }; } diff --git a/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php b/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php index 833ac9b164..c7d1338b42 100644 --- a/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php +++ b/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php @@ -82,6 +82,12 @@ public function send(SendMessageDTO $messageData): void case MessageTypeEnum::ORDER_OWNERS_WITH_PRODUCT: $this->sendProductMessages($messageData, $event); break; + case MessageTypeEnum::CHECKED_IN_ATTENDEES: + $this->sendCheckedInMessages($messageData, $event); + break; + case MessageTypeEnum::NOT_CHECKED_IN_ATTENDEES: + $this->sendNotCheckedInMessages($messageData, $event); + break; } $this->updateMessageStatus($messageData, MessageStatus::SENT); @@ -195,6 +201,28 @@ private function sendEventMessages(SendMessageDTO $messageData, EventDomainObjec $this->emailAttendees($attendees, $messageData, $event); } + private function sendCheckedInMessages(SendMessageDTO $messageData, EventDomainObject $event): void + { + $attendees = $this->attendeeRepository->findCheckedInAttendees( + eventId: $messageData->event_id, + checkInListId: $messageData->check_in_list_id, + columns: ['first_name', 'last_name', 'email'], + ); + + $this->emailAttendees($attendees, $messageData, $event); + } + + private function sendNotCheckedInMessages(SendMessageDTO $messageData, EventDomainObject $event): void + { + $attendees = $this->attendeeRepository->findNotCheckedInAttendees( + eventId: $messageData->event_id, + checkInListId: $messageData->check_in_list_id, + columns: ['first_name', 'last_name', 'email'], + ); + + $this->emailAttendees($attendees, $messageData, $event); + } + private function sendEmailToMessageSender(SendMessageDTO $messageData, EventDomainObject $event): void { if (!$messageData->send_copy_to_current_user && !$messageData->is_test) { diff --git a/backend/tests/Unit/Services/Domain/Mail/SendEventEmailMessagesServiceCheckinTest.php b/backend/tests/Unit/Services/Domain/Mail/SendEventEmailMessagesServiceCheckinTest.php new file mode 100644 index 0000000000..e4956b4bcb --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Mail/SendEventEmailMessagesServiceCheckinTest.php @@ -0,0 +1,186 @@ +attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class); + $this->eventRepository = Mockery::mock(EventRepositoryInterface::class); + $this->messageRepository = Mockery::mock(MessageRepositoryInterface::class); + $this->orderRepository = Mockery::mock(OrderRepositoryInterface::class); + $this->userRepository = Mockery::mock(UserRepositoryInterface::class); + $this->dispatcher = Mockery::mock(Dispatcher::class); + + $this->service = new SendEventEmailMessagesService( + orderRepository: $this->orderRepository, + attendeeRepository: $this->attendeeRepository, + eventRepository: $this->eventRepository, + messageRepository: $this->messageRepository, + userRepository: $this->userRepository, + logger: new Logger(), + dispatcher: $this->dispatcher, + ); + } + + private function createMessageDTO(MessageTypeEnum $type): SendMessageDTO + { + return SendMessageDTO::fromArray([ + 'account_id' => 1, + 'event_id' => 10, + 'subject' => 'Test', + 'message' => 'Test message', + 'type' => $type, + 'is_test' => false, + 'send_copy_to_current_user' => false, + 'sent_by_user_id' => 1, + 'id' => 100, + ]); + } + + private function mockEvent(): EventDomainObject + { + $eventSettings = Mockery::mock(EventSettingDomainObject::class); + $organizer = Mockery::mock(OrganizerDomainObject::class); + + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getEventSettings')->andReturn($eventSettings); + $event->shouldReceive('getOrganizer')->andReturn($organizer); + + return $event; + } + + private function setupEventRepository(EventDomainObject $event): void + { + $this->eventRepository + ->shouldReceive('loadRelation') + ->andReturnSelf(); + $this->eventRepository + ->shouldReceive('findById') + ->andReturn($event); + + $this->orderRepository + ->shouldReceive('findFirstWhere') + ->andReturnNull(); + } + + private function createAttendee(string $email, string $firstName, string $lastName): AttendeeDomainObject + { + $attendee = new AttendeeDomainObject(); + $attendee->setEmail($email); + $attendee->setFirstName($firstName); + $attendee->setLastName($lastName); + + return $attendee; + } + + public function testSendCheckedInMessagesQueriesCheckedInAttendees(): void + { + $event = $this->mockEvent(); + $this->setupEventRepository($event); + $messageDTO = $this->createMessageDTO(MessageTypeEnum::CHECKED_IN_ATTENDEES); + + $attendee = $this->createAttendee('checked@example.com', 'John', 'Doe'); + + $this->attendeeRepository + ->shouldReceive('findCheckedInAttendees') + ->once() + ->with(10, ['first_name', 'last_name', 'email']) + ->andReturn(new Collection([$attendee])); + + $this->dispatcher + ->shouldReceive('dispatch') + ->once(); + + $this->messageRepository + ->shouldReceive('updateWhere') + ->once(); + + $this->service->send($messageDTO); + } + + public function testSendNotCheckedInMessagesQueriesNotCheckedInAttendees(): void + { + $event = $this->mockEvent(); + $this->setupEventRepository($event); + $messageDTO = $this->createMessageDTO(MessageTypeEnum::NOT_CHECKED_IN_ATTENDEES); + + $attendee = $this->createAttendee('noshow@example.com', 'Jane', 'Doe'); + + $this->attendeeRepository + ->shouldReceive('findNotCheckedInAttendees') + ->once() + ->with(10, ['first_name', 'last_name', 'email']) + ->andReturn(new Collection([$attendee])); + + $this->dispatcher + ->shouldReceive('dispatch') + ->once(); + + $this->messageRepository + ->shouldReceive('updateWhere') + ->once(); + + $this->service->send($messageDTO); + } + + public function testSendCheckedInMessagesWithNoAttendeesDoesNotDispatch(): void + { + $event = $this->mockEvent(); + $this->setupEventRepository($event); + $messageDTO = $this->createMessageDTO(MessageTypeEnum::CHECKED_IN_ATTENDEES); + + $this->attendeeRepository + ->shouldReceive('findCheckedInAttendees') + ->once() + ->andReturn(new Collection()); + + $this->dispatcher + ->shouldNotReceive('dispatch'); + + $this->messageRepository + ->shouldReceive('updateWhere') + ->once(); + + $this->service->send($messageDTO); + } + + protected function tearDown(): void + { + if ($container = Mockery::getContainer()) { + $this->addToAssertionCount($container->mockery_getExpectationCount()); + } + Mockery::close(); + parent::tearDown(); + } +} diff --git a/frontend/src/components/common/MessageList/index.tsx b/frontend/src/components/common/MessageList/index.tsx index 73361fd17c..eb28481f8a 100644 --- a/frontend/src/components/common/MessageList/index.tsx +++ b/frontend/src/components/common/MessageList/index.tsx @@ -29,6 +29,8 @@ export const typeLabel = (type: MessageType) => { [MessageType.AllAttendees]: t`All attendees`, [MessageType.TicketHolders]: t`Ticket holders`, [MessageType.OrderOwner]: t`Order owner`, + [MessageType.CheckedInAttendees]: t`Checked-in attendees`, + [MessageType.NotCheckedInAttendees]: t`Not checked-in attendees`, }; return map[type] || type; }; diff --git a/frontend/src/components/modals/SendMessageModal/index.tsx b/frontend/src/components/modals/SendMessageModal/index.tsx index f33cc8f5f4..d9bd68765e 100644 --- a/frontend/src/components/modals/SendMessageModal/index.tsx +++ b/frontend/src/components/modals/SendMessageModal/index.tsx @@ -35,6 +35,7 @@ import {useSendEventMessage} from "../../../mutations/useSendEventMessage.ts"; import {ProductSelector} from "../../common/ProductSelector"; import {useEffect, useMemo, useState} from "react"; import {useGetAccount} from "../../../queries/useGetAccount.ts"; +import {useGetEventCheckInLists} from "../../../queries/useGetCheckInLists.ts"; import {StripeConnectButton} from "../../common/StripeConnectButton"; import {getConfig} from "../../../utilites/config"; import {utcToTz} from "../../../utilites/dates.ts"; @@ -139,6 +140,7 @@ export const SendMessageModal = (props: EventMessageModalProps) => { const [tierLimitError, setTierLimitError] = useState(null); const [isScheduled, setIsScheduled] = useState(false); const [selectedPreset, setSelectedPreset] = useState(null); + const {data: checkInListsData} = useGetEventCheckInLists(eventId); const presets = useMemo(() => event ? getSchedulePresets(event) : [], [event]); @@ -163,6 +165,7 @@ export const SendMessageModal = (props: EventMessageModalProps) => { acknowledgement: false, order_statuses: ['COMPLETED'], scheduled_at: '', + check_in_list_id: '', }, validate: { acknowledgement: (value) => value === true ? null : t`You must acknowledge that this email is not promotional`, @@ -208,6 +211,7 @@ export const SendMessageModal = (props: EventMessageModalProps) => { useEffect(() => { form.setFieldValue('product_ids', []); + form.setFieldValue('check_in_list_id', ''); }, [form.values.message_type]); if (!event || !me || !product_categories) { @@ -293,6 +297,14 @@ export const SendMessageModal = (props: EventMessageModalProps) => { value: 'ORDER_OWNERS_WITH_PRODUCT', label: t`Order owners with a specific product`, }, + { + value: 'CHECKED_IN_ATTENDEES', + label: t`Attendees who checked in`, + }, + { + value: 'NOT_CHECKED_IN_ATTENDEES', + label: t`Attendees who did not check in`, + }, ]} label={t`Recipients`} description={t`Select which attendees should receive this message`} @@ -338,6 +350,19 @@ export const SendMessageModal = (props: EventMessageModalProps) => { )} + {(form.values.message_type === MessageType.CheckedInAttendees || form.values.message_type === MessageType.NotCheckedInAttendees) && ( +