diff --git a/backend/app/Console/Kernel.php b/backend/app/Console/Kernel.php
index 53e011dca4..b44dab4d65 100644
--- a/backend/app/Console/Kernel.php
+++ b/backend/app/Console/Kernel.php
@@ -2,10 +2,17 @@
namespace HiEvents\Console;
+use HiEvents\Jobs\Message\SendScheduledMessagesJob;
+use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
+ protected function schedule(Schedule $schedule): void
+ {
+ $schedule->job(new SendScheduledMessagesJob)->everyMinute()->withoutOverlapping();
+ }
+
protected function commands(): void
{
$this->load(__DIR__ . '/Commands');
diff --git a/backend/app/DomainObjects/Generated/MessageDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/MessageDomainObjectAbstract.php
index 813b4f0594..30fdcfcff0 100644
--- a/backend/app/DomainObjects/Generated/MessageDomainObjectAbstract.php
+++ b/backend/app/DomainObjects/Generated/MessageDomainObjectAbstract.php
@@ -27,6 +27,7 @@ abstract class MessageDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr
final public const UPDATED_AT = 'updated_at';
final public const DELETED_AT = 'deleted_at';
final public const ELIGIBILITY_FAILURES = 'eligibility_failures';
+ final public const SCHEDULED_AT = 'scheduled_at';
protected int $id;
protected int $event_id;
@@ -45,6 +46,7 @@ abstract class MessageDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr
protected ?string $updated_at = null;
protected ?string $deleted_at = null;
protected array|string|null $eligibility_failures = null;
+ protected ?string $scheduled_at = null;
public function toArray(): array
{
@@ -66,6 +68,7 @@ public function toArray(): array
'updated_at' => $this->updated_at ?? null,
'deleted_at' => $this->deleted_at ?? null,
'eligibility_failures' => $this->eligibility_failures ?? null,
+ 'scheduled_at' => $this->scheduled_at ?? null,
];
}
@@ -255,4 +258,15 @@ public function getEligibilityFailures(): array|string|null
{
return $this->eligibility_failures;
}
+
+ public function setScheduledAt(?string $scheduled_at): self
+ {
+ $this->scheduled_at = $scheduled_at;
+ return $this;
+ }
+
+ public function getScheduledAt(): ?string
+ {
+ return $this->scheduled_at;
+ }
}
diff --git a/backend/app/DomainObjects/MessageDomainObject.php b/backend/app/DomainObjects/MessageDomainObject.php
index 00036f3b62..3d8a9af26d 100644
--- a/backend/app/DomainObjects/MessageDomainObject.php
+++ b/backend/app/DomainObjects/MessageDomainObject.php
@@ -2,11 +2,12 @@
namespace HiEvents\DomainObjects;
+use HiEvents\DomainObjects\Interfaces\IsFilterable;
use HiEvents\DomainObjects\Interfaces\IsSortable;
use HiEvents\DomainObjects\SortingAndFiltering\AllowedSorts;
use HiEvents\Helper\StringHelper;
-class MessageDomainObject extends Generated\MessageDomainObjectAbstract implements IsSortable
+class MessageDomainObject extends Generated\MessageDomainObjectAbstract implements IsSortable, IsFilterable
{
private ?UserDomainObject $sentByUser = null;
@@ -37,6 +38,13 @@ public static function getAllowedSorts(): AllowedSorts
}
+ public static function getAllowedFilterFields(): array
+ {
+ return [
+ self::STATUS,
+ ];
+ }
+
public function getSentByUser(): ?UserDomainObject
{
return $this->sentByUser;
diff --git a/backend/app/DomainObjects/Status/MessageStatus.php b/backend/app/DomainObjects/Status/MessageStatus.php
index 223b35243a..1c3216aa17 100644
--- a/backend/app/DomainObjects/Status/MessageStatus.php
+++ b/backend/app/DomainObjects/Status/MessageStatus.php
@@ -12,4 +12,6 @@ enum MessageStatus
case PROCESSING;
case SENT;
case FAILED;
+ case SCHEDULED;
+ case CANCELLED;
}
diff --git a/backend/app/Http/Actions/Messages/CancelMessageAction.php b/backend/app/Http/Actions/Messages/CancelMessageAction.php
new file mode 100644
index 0000000000..ff232f09fe
--- /dev/null
+++ b/backend/app/Http/Actions/Messages/CancelMessageAction.php
@@ -0,0 +1,28 @@
+isActionAuthorized($eventId, EventDomainObject::class);
+
+ $message = $this->cancelMessageHandler->handle($messageId, $eventId);
+
+ return $this->resourceResponse(MessageResource::class, $message);
+ }
+}
diff --git a/backend/app/Http/Actions/Messages/GetMessageRecipientsAction.php b/backend/app/Http/Actions/Messages/GetMessageRecipientsAction.php
new file mode 100644
index 0000000000..4b944ca379
--- /dev/null
+++ b/backend/app/Http/Actions/Messages/GetMessageRecipientsAction.php
@@ -0,0 +1,30 @@
+isActionAuthorized($eventId, EventDomainObject::class);
+
+ $params = $this->getPaginationQueryParams($request);
+
+ $recipients = $this->handler->handle($eventId, $messageId, $params);
+
+ return $this->resourceResponse(OutgoingMessageResource::class, $recipients);
+ }
+}
diff --git a/backend/app/Http/Actions/Messages/SendMessageAction.php b/backend/app/Http/Actions/Messages/SendMessageAction.php
index 949a19795d..8c72b11049 100644
--- a/backend/app/Http/Actions/Messages/SendMessageAction.php
+++ b/backend/app/Http/Actions/Messages/SendMessageAction.php
@@ -42,6 +42,7 @@ public function __invoke(SendMessageRequest $request, int $eventId): JsonRespons
'send_copy_to_current_user' => $request->boolean('send_copy_to_current_user'),
'sent_by_user_id' => $user->getId(),
'account_id' => $this->getAuthenticatedAccountId(),
+ 'scheduled_at' => $request->input('scheduled_at'),
]));
} 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 c6ebba1390..c91475abb6 100644
--- a/backend/app/Http/Request/Message/SendMessageRequest.php
+++ b/backend/app/Http/Request/Message/SendMessageRequest.php
@@ -13,7 +13,7 @@ public function rules(): array
{
return [
'subject' => 'required|string|max:100',
- 'message' => 'required|string|max:5000',
+ 'message' => 'required|string|max:8000',
'message_type' => [new In(MessageTypeEnum::valuesArray()), 'required'],
'is_test' => 'boolean',
'attendee_ids' => 'max:50,array|required_if:message_type,' . MessageTypeEnum::INDIVIDUAL_ATTENDEES->name,
@@ -25,6 +25,7 @@ public function rules(): array
'required_if:message_type,' . MessageTypeEnum::ORDER_OWNERS_WITH_PRODUCT->name,
new In([OrderStatus::COMPLETED->name, OrderStatus::AWAITING_OFFLINE_PAYMENT->name]),
],
+ 'scheduled_at' => 'nullable|date|after:now',
];
}
diff --git a/backend/app/Jobs/Message/SendScheduledMessagesJob.php b/backend/app/Jobs/Message/SendScheduledMessagesJob.php
new file mode 100644
index 0000000000..a7f5a02412
--- /dev/null
+++ b/backend/app/Jobs/Message/SendScheduledMessagesJob.php
@@ -0,0 +1,44 @@
+findWhere([
+ 'status' => MessageStatus::SCHEDULED->name,
+ ['scheduled_at', '<=', Carbon::now()->toDateTimeString()],
+ ]);
+
+ foreach ($messages as $message) {
+ try {
+ $messageDispatchService->dispatchMessage($message);
+ } catch (Throwable $e) {
+ Log::error('Failed to dispatch scheduled message', [
+ 'message_id' => $message->getId(),
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+ }
+}
diff --git a/backend/app/Repository/Eloquent/MessageRepository.php b/backend/app/Repository/Eloquent/MessageRepository.php
index 5325e8385b..e403c07121 100644
--- a/backend/app/Repository/Eloquent/MessageRepository.php
+++ b/backend/app/Repository/Eloquent/MessageRepository.php
@@ -38,6 +38,10 @@ public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAware
};
}
+ if ($params->filter_fields && $params->filter_fields->isNotEmpty()) {
+ $this->applyFilterFields($params, MessageDomainObject::getAllowedFilterFields());
+ }
+
$this->model = $this->model->orderBy(
$params->sort_by ?? MessageDomainObject::getDefaultSort(),
$params->sort_direction ?? 'desc',
diff --git a/backend/app/Resources/Message/MessageResource.php b/backend/app/Resources/Message/MessageResource.php
index b4b5ce81ef..c4d28de423 100644
--- a/backend/app/Resources/Message/MessageResource.php
+++ b/backend/app/Resources/Message/MessageResource.php
@@ -23,8 +23,9 @@ public function toArray(Request $request): array
'attendee_ids' => $this->getAttendeeIds(),
'order_id' => $this->getOrderId(),
'product_ids' => $this->getProductIds(),
- 'sent_at' => $this->getCreatedAt(),
+ 'sent_at' => $this->getSentAt(),
'status' => $this->getStatus(),
+ 'scheduled_at' => $this->getScheduledAt(),
'message_preview' => $this->getMessagePreview(),
$this->mergeWhen(!is_null($this->getSentByUser()), fn() => [
'sent_by_user' => new UserResource($this->getSentByUser()),
diff --git a/backend/app/Resources/Message/OutgoingMessageResource.php b/backend/app/Resources/Message/OutgoingMessageResource.php
new file mode 100644
index 0000000000..4bf92638da
--- /dev/null
+++ b/backend/app/Resources/Message/OutgoingMessageResource.php
@@ -0,0 +1,25 @@
+ $this->getId(),
+ 'message_id' => $this->getMessageId(),
+ 'recipient' => $this->getRecipient(),
+ 'status' => $this->getStatus(),
+ 'subject' => $this->getSubject(),
+ 'created_at' => $this->getCreatedAt(),
+ ];
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/Admin/ApproveMessageHandler.php b/backend/app/Services/Application/Handlers/Admin/ApproveMessageHandler.php
index 128c1477ca..0e46928dd6 100644
--- a/backend/app/Services/Application/Handlers/Admin/ApproveMessageHandler.php
+++ b/backend/app/Services/Application/Handlers/Admin/ApproveMessageHandler.php
@@ -4,13 +4,12 @@
namespace HiEvents\Services\Application\Handlers\Admin;
-use HiEvents\DomainObjects\Enums\MessageTypeEnum;
+use Carbon\Carbon;
use HiEvents\DomainObjects\MessageDomainObject;
use HiEvents\DomainObjects\Status\MessageStatus;
use HiEvents\Exceptions\ResourceNotFoundException;
-use HiEvents\Jobs\Event\SendMessagesJob;
use HiEvents\Repository\Interfaces\MessageRepositoryInterface;
-use HiEvents\Services\Application\Handlers\Message\DTO\SendMessageDTO;
+use HiEvents\Services\Domain\Message\MessageDispatchService;
use Illuminate\Database\DatabaseManager;
use Illuminate\Validation\ValidationException;
@@ -19,6 +18,7 @@ class ApproveMessageHandler
public function __construct(
private readonly MessageRepositoryInterface $messageRepository,
private readonly DatabaseManager $databaseManager,
+ private readonly MessageDispatchService $messageDispatchService,
)
{
}
@@ -44,29 +44,17 @@ private function approveMessage(int $messageId): MessageDomainObject
]);
}
- $updatedMessage = $this->messageRepository->updateFromArray($messageId, [
- 'status' => MessageStatus::PROCESSING->name,
- ]);
+ $scheduledAt = $message->getScheduledAt();
+ $isFutureScheduled = $scheduledAt !== null && Carbon::parse($scheduledAt)->isFuture();
- $sendData = $message->getSendData();
- $sendDataArray = is_string($sendData) ? json_decode($sendData, true) : $sendData;
+ if ($isFutureScheduled) {
+ return $this->messageRepository->updateFromArray($messageId, [
+ 'status' => MessageStatus::SCHEDULED->name,
+ ]);
+ }
- SendMessagesJob::dispatch(new SendMessageDTO(
- account_id: $sendDataArray['account_id'],
- event_id: $message->getEventId(),
- subject: $message->getSubject(),
- message: $message->getMessage(),
- type: MessageTypeEnum::fromName($message->getType()),
- is_test: false,
- send_copy_to_current_user: $sendDataArray['send_copy_to_current_user'] ?? false,
- sent_by_user_id: $message->getSentByUserId(),
- order_id: $message->getOrderId(),
- order_statuses: $sendDataArray['order_statuses'] ?? [],
- id: $message->getId(),
- attendee_ids: $message->getAttendeeIds() ?? [],
- product_ids: $message->getProductIds() ?? [],
- ));
+ $this->messageDispatchService->dispatchMessage($message, MessageStatus::PENDING_REVIEW);
- return $updatedMessage;
+ return $this->messageRepository->findFirst($messageId);
}
}
diff --git a/backend/app/Services/Application/Handlers/Message/CancelMessageHandler.php b/backend/app/Services/Application/Handlers/Message/CancelMessageHandler.php
new file mode 100644
index 0000000000..29206a250b
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/Message/CancelMessageHandler.php
@@ -0,0 +1,51 @@
+messageRepository->findFirstWhere([
+ 'id' => $messageId,
+ 'event_id' => $eventId,
+ ]);
+
+ if ($message === null) {
+ throw new ResourceNotFoundException(__('Message not found'));
+ }
+
+ if ($message->getStatus() !== MessageStatus::SCHEDULED->name) {
+ throw ValidationException::withMessages([
+ 'status' => [__('Only scheduled messages can be cancelled')],
+ ]);
+ }
+
+ $updated = $this->messageRepository->updateWhere(
+ ['status' => MessageStatus::CANCELLED->name],
+ ['id' => $messageId, 'status' => MessageStatus::SCHEDULED->name],
+ );
+
+ if ($updated === 0) {
+ throw ValidationException::withMessages([
+ 'status' => [__('This message can no longer be cancelled')],
+ ]);
+ }
+
+ return $this->messageRepository->findFirst($messageId);
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/Message/DTO/SendMessageDTO.php b/backend/app/Services/Application/Handlers/Message/DTO/SendMessageDTO.php
index 99b54adb2e..ef6eb83ce4 100644
--- a/backend/app/Services/Application/Handlers/Message/DTO/SendMessageDTO.php
+++ b/backend/app/Services/Application/Handlers/Message/DTO/SendMessageDTO.php
@@ -21,6 +21,7 @@ public function __construct(
public readonly ?int $id = null,
public readonly ?array $attendee_ids = [],
public readonly ?array $product_ids = [],
+ public readonly ?string $scheduled_at = null,
)
{
}
diff --git a/backend/app/Services/Application/Handlers/Message/GetMessageRecipientsHandler.php b/backend/app/Services/Application/Handlers/Message/GetMessageRecipientsHandler.php
new file mode 100644
index 0000000000..1dd600d5aa
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/Message/GetMessageRecipientsHandler.php
@@ -0,0 +1,39 @@
+messageRepository->findFirstWhere([
+ 'id' => $messageId,
+ 'event_id' => $eventId,
+ ]);
+
+ if ($message === null) {
+ throw new ResourceNotFoundException(__('Message not found'));
+ }
+
+ return $this->outgoingMessageRepository->paginateWhere(
+ where: [
+ 'event_id' => $eventId,
+ 'message_id' => $messageId,
+ ],
+ limit: $params->per_page,
+ );
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/Message/SendMessageHandler.php b/backend/app/Services/Application/Handlers/Message/SendMessageHandler.php
index cec0298f7a..01a7e526a6 100644
--- a/backend/app/Services/Application/Handlers/Message/SendMessageHandler.php
+++ b/backend/app/Services/Application/Handlers/Message/SendMessageHandler.php
@@ -73,9 +73,15 @@ public function handle(SendMessageDTO $messageData): MessageDomainObject
$messageData->event_id
);
- $status = $eligibilityFailure !== null
- ? MessageStatus::PENDING_REVIEW
- : MessageStatus::PROCESSING;
+ $isScheduled = $messageData->scheduled_at !== null && !$messageData->is_test;
+
+ if ($eligibilityFailure !== null) {
+ $status = MessageStatus::PENDING_REVIEW;
+ } elseif ($isScheduled) {
+ $status = MessageStatus::SCHEDULED;
+ } else {
+ $status = MessageStatus::PROCESSING;
+ }
$message = $this->messageRepository->create([
'event_id' => $messageData->event_id,
@@ -85,9 +91,10 @@ public function handle(SendMessageDTO $messageData): MessageDomainObject
'order_id' => $this->getOrderId($messageData),
'attendee_ids' => $this->getAttendeeIds($messageData)->toArray(),
'product_ids' => $this->getProductIds($messageData)->toArray(),
- 'sent_at' => Carbon::now()->toDateTimeString(),
+ 'sent_at' => $isScheduled ? null : Carbon::now()->toDateTimeString(),
'sent_by_user_id' => $messageData->sent_by_user_id,
'status' => $status->name,
+ 'scheduled_at' => $messageData->scheduled_at,
'eligibility_failures' => $eligibilityFailure?->getFailureValues(),
'send_data' => [
'is_test' => $messageData->is_test,
@@ -101,7 +108,7 @@ public function handle(SendMessageDTO $messageData): MessageDomainObject
if ($status === MessageStatus::PENDING_REVIEW) {
MessagePendingReviewJob::dispatch($message->getId(), $eligibilityFailure->getFailureValues());
- } else {
+ } elseif ($status === MessageStatus::PROCESSING) {
$updatedData = SendMessageDTO::fromArray([
'account_id' => $messageData->account_id,
'event_id' => $messageData->event_id,
diff --git a/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php b/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php
index f1937a52f3..833ac9b164 100644
--- a/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php
+++ b/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php
@@ -163,10 +163,16 @@ private function emailAttendees(
private function updateMessageStatus(SendMessageDTO $messageData, MessageStatus $status): void
{
+ $attributes = [
+ 'status' => $status->name,
+ ];
+
+ if ($status === MessageStatus::SENT) {
+ $attributes['sent_at'] = now()->toDateTimeString();
+ }
+
$this->messageRepository->updateWhere(
- attributes: [
- 'status' => $status->name,
- ],
+ attributes: $attributes,
where: [
'id' => $messageData->id,
]
diff --git a/backend/app/Services/Domain/Message/MessageDispatchService.php b/backend/app/Services/Domain/Message/MessageDispatchService.php
new file mode 100644
index 0000000000..006e666efa
--- /dev/null
+++ b/backend/app/Services/Domain/Message/MessageDispatchService.php
@@ -0,0 +1,79 @@
+getSendData();
+ $sendDataArray = is_string($sendData) ? json_decode($sendData, true) : $sendData;
+
+ if (!is_array($sendDataArray) || !isset($sendDataArray['account_id'])) {
+ Log::error('Message has invalid send_data, marking as FAILED', [
+ 'message_id' => $message->getId(),
+ ]);
+ $this->messageRepository->updateFromArray($message->getId(), [
+ 'status' => MessageStatus::FAILED->name,
+ ]);
+ return;
+ }
+
+ $updated = $this->messageRepository->updateWhere(
+ ['status' => MessageStatus::PROCESSING->name],
+ ['id' => $message->getId(), 'status' => $expectedStatus->name],
+ );
+
+ if ($updated === 0) {
+ Log::info('Message status changed before dispatch, skipping', [
+ 'message_id' => $message->getId(),
+ ]);
+ return;
+ }
+
+ try {
+ SendMessagesJob::dispatch(new SendMessageDTO(
+ account_id: $sendDataArray['account_id'],
+ event_id: $message->getEventId(),
+ subject: $message->getSubject(),
+ message: $message->getMessage(),
+ type: MessageTypeEnum::fromName($message->getType()),
+ is_test: false,
+ send_copy_to_current_user: $sendDataArray['send_copy_to_current_user'] ?? false,
+ sent_by_user_id: $message->getSentByUserId(),
+ order_id: $message->getOrderId(),
+ order_statuses: $sendDataArray['order_statuses'] ?? [],
+ id: $message->getId(),
+ attendee_ids: $message->getAttendeeIds() ?? [],
+ product_ids: $message->getProductIds() ?? [],
+ ));
+ } catch (Throwable $e) {
+ Log::error('Failed to dispatch SendMessagesJob, reverting status', [
+ 'message_id' => $message->getId(),
+ 'error' => $e->getMessage(),
+ ]);
+ $this->messageRepository->updateWhere(
+ ['status' => $expectedStatus->name],
+ ['id' => $message->getId(), 'status' => MessageStatus::PROCESSING->name],
+ );
+ throw $e;
+ }
+ }
+}
diff --git a/backend/database/migrations/2026_02_07_000000_add_scheduled_at_to_messages_table.php b/backend/database/migrations/2026_02_07_000000_add_scheduled_at_to_messages_table.php
new file mode 100644
index 0000000000..79423d7aa5
--- /dev/null
+++ b/backend/database/migrations/2026_02_07_000000_add_scheduled_at_to_messages_table.php
@@ -0,0 +1,22 @@
+dateTime('scheduled_at')->nullable()->after('sent_at');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('messages', function (Blueprint $table) {
+ $table->dropColumn('scheduled_at');
+ });
+ }
+};
diff --git a/backend/routes/api.php b/backend/routes/api.php
index c84c87fe99..1887462de2 100644
--- a/backend/routes/api.php
+++ b/backend/routes/api.php
@@ -76,6 +76,8 @@
use HiEvents\Http\Actions\EventSettings\PartialEditEventSettingsAction;
use HiEvents\Http\Actions\Images\CreateImageAction;
use HiEvents\Http\Actions\Images\DeleteImageAction;
+use HiEvents\Http\Actions\Messages\CancelMessageAction;
+use HiEvents\Http\Actions\Messages\GetMessageRecipientsAction;
use HiEvents\Http\Actions\Messages\GetMessagesAction;
use HiEvents\Http\Actions\Messages\SendMessageAction;
use HiEvents\Http\Actions\Orders\CancelOrderAction;
@@ -371,6 +373,8 @@ function (Router $router): void {
// Messages
$router->post('/events/{event_id}/messages', SendMessageAction::class);
$router->get('/events/{event_id}/messages', GetMessagesAction::class);
+ $router->post('/events/{event_id}/messages/{message_id}/cancel', CancelMessageAction::class);
+ $router->get('/events/{event_id}/messages/{message_id}/recipients', GetMessageRecipientsAction::class);
// Event Settings
$router->get('/events/{event_id}/settings', GetEventSettingsAction::class);
diff --git a/backend/tests/Unit/Jobs/Message/SendScheduledMessagesJobTest.php b/backend/tests/Unit/Jobs/Message/SendScheduledMessagesJobTest.php
new file mode 100644
index 0000000000..9f29f0bdd3
--- /dev/null
+++ b/backend/tests/Unit/Jobs/Message/SendScheduledMessagesJobTest.php
@@ -0,0 +1,100 @@
+messageRepository = m::mock(MessageRepositoryInterface::class);
+ $this->messageDispatchService = m::mock(MessageDispatchService::class);
+ }
+
+ public function testPicksUpScheduledMessagesWithPastScheduledAt(): void
+ {
+ $message = m::mock(MessageDomainObject::class);
+
+ $this->messageRepository->shouldReceive('findWhere')
+ ->once()
+ ->withArgs(function ($where) {
+ return $where['status'] === MessageStatus::SCHEDULED->name
+ && $where[0][0] === 'scheduled_at'
+ && $where[0][1] === '<=';
+ })
+ ->andReturn(new Collection([$message]));
+
+ $this->messageDispatchService->shouldReceive('dispatchMessage')
+ ->once()
+ ->with($message);
+
+ $job = new SendScheduledMessagesJob();
+ $job->handle($this->messageRepository, $this->messageDispatchService);
+ }
+
+ public function testDoesNotPickUpFutureScheduledMessages(): void
+ {
+ $this->messageRepository->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection([]));
+
+ $this->messageDispatchService->shouldNotReceive('dispatchMessage');
+
+ $job = new SendScheduledMessagesJob();
+ $job->handle($this->messageRepository, $this->messageDispatchService);
+ }
+
+ public function testDoesNotPickUpCancelledMessages(): void
+ {
+ $this->messageRepository->shouldReceive('findWhere')
+ ->once()
+ ->withArgs(function ($where) {
+ return $where['status'] === MessageStatus::SCHEDULED->name;
+ })
+ ->andReturn(new Collection([]));
+
+ $this->messageDispatchService->shouldNotReceive('dispatchMessage');
+
+ $job = new SendScheduledMessagesJob();
+ $job->handle($this->messageRepository, $this->messageDispatchService);
+ }
+
+ public function testContinuesProcessingWhenOneMessageFails(): void
+ {
+ $message1 = m::mock(MessageDomainObject::class);
+ $message1->shouldReceive('getId')->andReturn(1);
+ $message2 = m::mock(MessageDomainObject::class);
+ $message2->shouldReceive('getId')->andReturn(2);
+
+ $this->messageRepository->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection([$message1, $message2]));
+
+ $this->messageDispatchService->shouldReceive('dispatchMessage')
+ ->once()
+ ->with($message1)
+ ->andThrow(new RuntimeException('Queue down'));
+
+ $this->messageDispatchService->shouldReceive('dispatchMessage')
+ ->once()
+ ->with($message2);
+
+ $job = new SendScheduledMessagesJob();
+ $job->handle($this->messageRepository, $this->messageDispatchService);
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Message/GetMessageRecipientsHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Message/GetMessageRecipientsHandlerTest.php
new file mode 100644
index 0000000000..fdb3c532e1
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/Message/GetMessageRecipientsHandlerTest.php
@@ -0,0 +1,114 @@
+outgoingMessageRepository = m::mock(OutgoingMessageRepositoryInterface::class);
+ $this->messageRepository = m::mock(MessageRepositoryInterface::class);
+ $this->handler = new GetMessageRecipientsHandler(
+ $this->outgoingMessageRepository,
+ $this->messageRepository,
+ );
+ }
+
+ public function testHandleReturnsPaginatedRecipients(): void
+ {
+ $eventId = 10;
+ $messageId = 20;
+ $params = QueryParamsDTO::fromArray(['per_page' => 100, 'page' => 1]);
+
+ $message = m::mock(MessageDomainObject::class);
+ $this->messageRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(['id' => $messageId, 'event_id' => $eventId])
+ ->andReturn($message);
+
+ $paginator = new LengthAwarePaginator([], 0, 100);
+ $this->outgoingMessageRepository
+ ->shouldReceive('paginateWhere')
+ ->once()
+ ->with(['event_id' => $eventId, 'message_id' => $messageId], 100)
+ ->andReturn($paginator);
+
+ $result = $this->handler->handle($eventId, $messageId, $params);
+
+ $this->assertSame($paginator, $result);
+ }
+
+ public function testHandleUsesDefaultPerPageFromDto(): void
+ {
+ $eventId = 5;
+ $messageId = 15;
+ $params = QueryParamsDTO::fromArray(['page' => 1]);
+
+ $message = m::mock(MessageDomainObject::class);
+ $this->messageRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(['id' => $messageId, 'event_id' => $eventId])
+ ->andReturn($message);
+
+ $paginator = new LengthAwarePaginator([], 0, 25);
+ $this->outgoingMessageRepository
+ ->shouldReceive('paginateWhere')
+ ->once()
+ ->with(['event_id' => $eventId, 'message_id' => $messageId], 25)
+ ->andReturn($paginator);
+
+ $result = $this->handler->handle($eventId, $messageId, $params);
+
+ $this->assertSame($paginator, $result);
+ }
+
+ public function testHandleThrowsNotFoundWhenMessageDoesNotExist(): void
+ {
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->messageRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(['id' => 999, 'event_id' => 1])
+ ->andReturn(null);
+
+ $this->outgoingMessageRepository->shouldNotReceive('paginateWhere');
+
+ $params = QueryParamsDTO::fromArray(['page' => 1]);
+ $this->handler->handle(1, 999, $params);
+ }
+
+ public function testHandleThrowsNotFoundWhenMessageBelongsToDifferentEvent(): void
+ {
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->messageRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(['id' => 20, 'event_id' => 99])
+ ->andReturn(null);
+
+ $this->outgoingMessageRepository->shouldNotReceive('paginateWhere');
+
+ $params = QueryParamsDTO::fromArray(['page' => 1]);
+ $this->handler->handle(99, 20, $params);
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerScheduledTest.php b/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerScheduledTest.php
new file mode 100644
index 0000000000..e5fe760a5f
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerScheduledTest.php
@@ -0,0 +1,227 @@
+orderRepository = m::mock(OrderRepositoryInterface::class);
+ $this->attendeeRepository = m::mock(AttendeeRepositoryInterface::class);
+ $this->productRepository = m::mock(ProductRepositoryInterface::class);
+ $this->messageRepository = m::mock(MessageRepositoryInterface::class);
+ $this->accountRepository = m::mock(AccountRepositoryInterface::class);
+ $this->purifier = m::mock(HtmlPurifierService::class);
+ $this->config = m::mock(Repository::class);
+ $this->eligibilityService = m::mock(MessagingEligibilityService::class);
+
+ $this->handler = new SendMessageHandler(
+ $this->orderRepository,
+ $this->attendeeRepository,
+ $this->productRepository,
+ $this->messageRepository,
+ $this->accountRepository,
+ $this->purifier,
+ $this->config,
+ $this->eligibilityService
+ );
+ }
+
+ private function setupAccountMocks(): void
+ {
+ $account = m::mock(AccountDomainObject::class);
+ $account->shouldReceive('getAccountVerifiedAt')->andReturn(Carbon::now());
+ $account->shouldReceive('getIsManuallyVerified')->andReturn(true);
+
+ $this->accountRepository->shouldReceive('findById')->with(1)->andReturn($account);
+ $this->config->shouldReceive('get')->with('app.saas_mode_enabled')->andReturn(false);
+
+ $this->eligibilityService->shouldReceive('checkTierLimits')->andReturn(null);
+ $this->eligibilityService->shouldReceive('checkEligibility')->andReturn(null);
+
+ $this->purifier->shouldReceive('purify')->andReturn('
Test
');
+ }
+
+ private function setupRepositoryMocks(): void
+ {
+ $attendee = new AttendeeDomainObject();
+ $attendee->setId(10);
+
+ $product = new ProductDomainObject();
+ $product->setId(20);
+
+ $order = new OrderDomainObject();
+ $order->setId(5);
+
+ $this->attendeeRepository->shouldReceive('findWhereIn')->andReturn(collect([$attendee]));
+ $this->productRepository->shouldReceive('findWhereIn')->andReturn(collect([$product]));
+ $this->orderRepository->shouldReceive('findFirstWhere')->andReturn($order);
+ }
+
+ public function testFutureScheduledAtSetsScheduledStatusAndDoesNotDispatchJob(): void
+ {
+ Bus::fake();
+
+ $this->setupAccountMocks();
+ $this->setupRepositoryMocks();
+
+ $message = m::mock(MessageDomainObject::class);
+ $message->shouldReceive('getId')->andReturn(1);
+ $message->shouldReceive('getOrderId')->andReturn(5);
+ $message->shouldReceive('getAttendeeIds')->andReturn([10]);
+ $message->shouldReceive('getProductIds')->andReturn([20]);
+ $message->shouldReceive('getStatus')->andReturn(MessageStatus::SCHEDULED->name);
+
+ $this->messageRepository->shouldReceive('create')
+ ->once()
+ ->withArgs(function ($data) {
+ return $data['status'] === MessageStatus::SCHEDULED->name
+ && $data['scheduled_at'] !== null
+ && $data['sent_at'] === null;
+ })
+ ->andReturn($message);
+
+ $dto = new SendMessageDTO(
+ account_id: 1,
+ event_id: 101,
+ subject: 'Hello',
+ message: 'Test
',
+ type: MessageTypeEnum::INDIVIDUAL_ATTENDEES,
+ is_test: false,
+ send_copy_to_current_user: false,
+ sent_by_user_id: 99,
+ order_id: 5,
+ order_statuses: [],
+ attendee_ids: [10],
+ product_ids: [20],
+ scheduled_at: Carbon::now()->addHour()->toIso8601String(),
+ );
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertSame($message, $result);
+ Bus::assertNotDispatched(SendMessagesJob::class);
+ }
+
+ public function testNoScheduledAtDispatchesJobImmediately(): void
+ {
+ Bus::fake();
+
+ $this->setupAccountMocks();
+ $this->setupRepositoryMocks();
+
+ $message = m::mock(MessageDomainObject::class);
+ $message->shouldReceive('getId')->andReturn(1);
+ $message->shouldReceive('getOrderId')->andReturn(5);
+ $message->shouldReceive('getAttendeeIds')->andReturn([10]);
+ $message->shouldReceive('getProductIds')->andReturn([20]);
+ $message->shouldReceive('getStatus')->andReturn(MessageStatus::PROCESSING->name);
+
+ $this->messageRepository->shouldReceive('create')
+ ->once()
+ ->withArgs(function ($data) {
+ return $data['status'] === MessageStatus::PROCESSING->name
+ && $data['sent_at'] !== null;
+ })
+ ->andReturn($message);
+
+ $dto = new SendMessageDTO(
+ account_id: 1,
+ event_id: 101,
+ subject: 'Hello',
+ message: 'Test
',
+ type: MessageTypeEnum::INDIVIDUAL_ATTENDEES,
+ is_test: false,
+ send_copy_to_current_user: false,
+ sent_by_user_id: 99,
+ order_id: 5,
+ order_statuses: [],
+ attendee_ids: [10],
+ product_ids: [20],
+ );
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertSame($message, $result);
+ Bus::assertDispatched(SendMessagesJob::class);
+ }
+
+ public function testIsTestWithScheduledAtSendsImmediately(): void
+ {
+ Bus::fake();
+
+ $this->setupAccountMocks();
+ $this->setupRepositoryMocks();
+
+ $message = m::mock(MessageDomainObject::class);
+ $message->shouldReceive('getId')->andReturn(1);
+ $message->shouldReceive('getOrderId')->andReturn(5);
+ $message->shouldReceive('getAttendeeIds')->andReturn([10]);
+ $message->shouldReceive('getProductIds')->andReturn([20]);
+ $message->shouldReceive('getStatus')->andReturn(MessageStatus::PROCESSING->name);
+
+ $this->messageRepository->shouldReceive('create')
+ ->once()
+ ->withArgs(function ($data) {
+ return $data['status'] === MessageStatus::PROCESSING->name
+ && $data['sent_at'] !== null;
+ })
+ ->andReturn($message);
+
+ $dto = new SendMessageDTO(
+ account_id: 1,
+ event_id: 101,
+ subject: 'Hello',
+ message: 'Test
',
+ type: MessageTypeEnum::INDIVIDUAL_ATTENDEES,
+ is_test: true,
+ send_copy_to_current_user: false,
+ sent_by_user_id: 99,
+ order_id: 5,
+ order_statuses: [],
+ attendee_ids: [10],
+ product_ids: [20],
+ scheduled_at: Carbon::now()->addHour()->toIso8601String(),
+ );
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertSame($message, $result);
+ Bus::assertDispatched(SendMessagesJob::class);
+ }
+}
diff --git a/backend/vapor.yml b/backend/vapor.yml
index 99da2d69f6..b390064ee5 100644
--- a/backend/vapor.yml
+++ b/backend/vapor.yml
@@ -9,6 +9,7 @@ environments:
storage: hievents-assets-prod
runtime: docker
warm: 3
+ scheduler: true
cache: hievents-redis
database: hievents-postgres
queues:
@@ -29,6 +30,7 @@ environments:
cli-memory: 512
runtime: docker
warm: 3
+ scheduler: true
cache: hievents-redis
database: hievents-postgres
queue:
diff --git a/frontend/src/api/messages.client.ts b/frontend/src/api/messages.client.ts
index 0e1d9f4dea..19b77b89d7 100644
--- a/frontend/src/api/messages.client.ts
+++ b/frontend/src/api/messages.client.ts
@@ -1,5 +1,5 @@
import {api} from "./client";
-import {GenericPaginatedResponse, IdParam, Message, QueryFilters,} from "../types";
+import {GenericPaginatedResponse, IdParam, Message, OutgoingMessage, QueryFilters,} from "../types";
import {queryParamsHelper} from "../utilites/queryParamsHelper.ts";
import {AxiosResponse} from "axios";
@@ -13,4 +13,13 @@ export const messagesClient = {
);
return response.data;
},
+ cancel: async (eventId: IdParam, messageId: IdParam) => {
+ return await api.post(`events/${eventId}/messages/${messageId}/cancel`);
+ },
+ recipients: async (eventId: IdParam, messageId: IdParam, pagination: QueryFilters) => {
+ const response: AxiosResponse> = await api.get>(
+ `events/${eventId}/messages/${messageId}/recipients` + queryParamsHelper.buildQueryString(pagination),
+ );
+ return response.data;
+ },
}
diff --git a/frontend/src/components/common/MessageList/MessageList.module.scss b/frontend/src/components/common/MessageList/MessageList.module.scss
index b206ae3da8..0eab17474f 100644
--- a/frontend/src/components/common/MessageList/MessageList.module.scss
+++ b/frontend/src/components/common/MessageList/MessageList.module.scss
@@ -1,67 +1,102 @@
+@use "../../../styles/mixins";
-.message {
+.listContainer {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.messageItem {
display: flex;
flex-direction: row;
- margin-bottom: 20px;
- padding: 20px;
- border-radius: 5px;
- position: relative;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 14px 16px;
+ cursor: pointer;
+ border-bottom: 1px solid var(--mantine-color-gray-2);
+ transition: background-color 0.15s ease;
+ outline: none;
- .avatar {
- justify-content: center;
- align-items: center;
- margin-right: 15px;
+ &:hover {
+ background-color: var(--mantine-color-gray-0);
}
- .details {
- .status {
- position: absolute;
- top: 20px;
- right: 20px;
- font-size: .8rem;
- font-weight: 400;
- color: #9ca3af;
- }
+ &:focus-visible {
+ background-color: var(--mantine-color-gray-0);
+ box-shadow: inset 0 0 0 2px var(--mantine-color-primary-4);
+ }
- .date {
- font-size: .8rem;
- font-weight: 400;
- color: #9ca3af;
- display: flex;
- flex: 1;
- gap: 10px;
- margin-bottom: 5px;
- margin-top: 4px;
+ &.selected {
+ background-color: var(--mantine-color-primary-0);
+ border-left: 3px solid var(--mantine-color-primary-6);
+ padding-left: 13px;
+ }
- .date {
- margin-right: 5px;
- }
- }
- .subject {
- font-size: 1.1rem;
- font-weight: 600;
- margin-bottom: 10px;
- }
+ &.cancelled {
+ opacity: 0.55;
+ }
+}
- .type {
- font-size: .9rem;
- font-weight: 400;
- margin-bottom: 10px;
- }
+.itemContent {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+}
- .content {
- font-size: 1rem;
- font-weight: 200;
- margin-bottom: 1rem;
+.itemTopRow {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
- .preview {
- font-size: .9rem;
- font-weight: 400;
- color: #9ca3af;
- }
- }
- }
+.sender {
+ font-size: 0.8rem;
+ font-weight: 500;
+ color: var(--mantine-color-dark-6);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.date {
+ font-size: 0.72rem;
+ color: var(--mantine-color-gray-6);
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+.subject {
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: var(--mantine-color-dark-8);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.preview {
+ font-size: 0.78rem;
+ color: var(--mantine-color-gray-6);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 1.4;
+}
+
+.itemBottomRow {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-top: 2px;
+}
-}
\ No newline at end of file
+.typeLabel {
+ font-size: 0.7rem;
+ color: var(--mantine-color-gray-5);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
diff --git a/frontend/src/components/common/MessageList/index.tsx b/frontend/src/components/common/MessageList/index.tsx
index 88c10a41f8..73361fd17c 100644
--- a/frontend/src/components/common/MessageList/index.tsx
+++ b/frontend/src/components/common/MessageList/index.tsx
@@ -1,89 +1,99 @@
-import {Message, MessageType} from "../../../types.ts";
+import {IdParam, Message, MessageType} from "../../../types.ts";
import classes from './MessageList.module.scss';
import {relativeDate} from "../../../utilites/dates.ts";
-import {Card} from "../Card";
-import {Anchor, Avatar, Badge} from "@mantine/core";
+import {Avatar, Badge} from "@mantine/core";
import {getInitials} from "../../../utilites/helpers.ts";
-import {NoResultsSplash} from "../NoResultsSplash";
import {t} from "@lingui/macro";
-import {useState} from "react";
interface MessageListProps {
messages: Message[];
+ selectedId?: IdParam;
+ onSelect: (message: Message) => void;
}
-const SingleMessage = ({message}: { message: Message }) => {
- const [showFullMessage, setShowFullMessage] = useState(false);
+export const statusBadgeColor = (status?: string) => {
+ switch (status) {
+ case 'SENT': return 'green';
+ case 'PROCESSING': return 'orange';
+ case 'SCHEDULED': return 'blue';
+ case 'CANCELLED': return 'gray';
+ case 'FAILED': return 'red';
+ default: return 'orange';
+ }
+};
- const typeToDescription = {
+export const typeLabel = (type: MessageType) => {
+ const map: Record = {
[MessageType.OrderOwnersWithProduct]: t`Order owners with products`,
[MessageType.IndividualAttendees]: t`Individual attendees`,
[MessageType.AllAttendees]: t`All attendees`,
[MessageType.TicketHolders]: t`Ticket holders`,
[MessageType.OrderOwner]: t`Order owner`,
- }
+ };
+ return map[type] || type;
+};
- return (
-
-
-
{getInitials(message.sent_by_user?.first_name + " " + message.sent_by_user?.last_name)}
-
-
+const MessageItem = ({message, isSelected, onSelect}: {
+ message: Message;
+ isSelected: boolean;
+ onSelect: () => void;
+}) => {
+ const isCancelled = message.status === 'CANCELLED';
+ const senderName = message.sent_by_user
+ ? `${message.sent_by_user.first_name} ${message.sent_by_user.last_name}`
+ : t`Unknown`;
-
-
- {message.status}
-
-
-
-
- {relativeDate(message.sent_at as string)}
-
+ const displayTimestamp = (message.status === 'SCHEDULED' || message.status === 'CANCELLED')
+ ? (message.scheduled_at ?? message.sent_at ?? message.created_at)
+ : (message.sent_at ?? message.created_at ?? message.scheduled_at);
+ const displayDate = displayTimestamp ? relativeDate(displayTimestamp) : t`Unknown`;
+
+ return (
+
{
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ onSelect();
+ }
+ }}
+ >
+
+ {getInitials(senderName)}
+
+
+
+ {senderName}
+ {displayDate}
{message.subject}
-
- {typeToDescription[message.type]}
-
-
- {showFullMessage
- ?
- :
{message.message_preview}
}
+
{message.message_preview}
+
+
+ {message.status}
+
+ {typeLabel(message.type)}
-
setShowFullMessage(!showFullMessage)}
- >
- {showFullMessage ? t`Read less` : t`View full message`}
-
-
+
);
};
-export const MessageList = ({messages}: MessageListProps) => {
- if (messages.length === 0) {
- return
-
- {t`You haven't sent any messages yet. You can send messages to all attendees, or to specific product holders.`}
-
- >
- )}
- />
- }
+export const MessageList = ({messages, selectedId, onSelect}: MessageListProps) => {
return (
-
- {messages.map((message) => {
- return (
-
- )
- })}
+
+ {messages.map((message) => (
+ onSelect(message)}
+ />
+ ))}
- )
-}
+ );
+};
diff --git a/frontend/src/components/modals/MessageRecipientsModal/MessageRecipientsModal.module.scss b/frontend/src/components/modals/MessageRecipientsModal/MessageRecipientsModal.module.scss
new file mode 100644
index 0000000000..e99ccef654
--- /dev/null
+++ b/frontend/src/components/modals/MessageRecipientsModal/MessageRecipientsModal.module.scss
@@ -0,0 +1,55 @@
+.recipientRow {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 0;
+ border-bottom: 1px solid var(--mantine-color-gray-2);
+ gap: 12px;
+
+ &:last-child {
+ border-bottom: none;
+ }
+}
+
+.recipientEmail {
+ font-size: 0.88rem;
+ color: var(--mantine-color-dark-7);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ min-width: 0;
+ flex: 1;
+}
+
+.recipientRight {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-shrink: 0;
+}
+
+.recipientDate {
+ font-size: 0.75rem;
+ color: var(--mantine-color-gray-5);
+ white-space: nowrap;
+}
+
+.emptyState {
+ text-align: center;
+ padding: 32px 16px;
+ color: var(--mantine-color-gray-6);
+ font-size: 0.88rem;
+}
+
+.loadingState {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 40px;
+}
+
+.headerCount {
+ font-size: 0.78rem;
+ color: var(--mantine-color-gray-6);
+ margin-bottom: 12px;
+}
diff --git a/frontend/src/components/modals/MessageRecipientsModal/index.tsx b/frontend/src/components/modals/MessageRecipientsModal/index.tsx
new file mode 100644
index 0000000000..2111aac564
--- /dev/null
+++ b/frontend/src/components/modals/MessageRecipientsModal/index.tsx
@@ -0,0 +1,93 @@
+import {Modal} from "../../common/Modal";
+import {t} from "@lingui/macro";
+import {Alert, Badge, Loader, Text} from "@mantine/core";
+import {GenericModalProps, IdParam} from "../../../types.ts";
+import {useGetMessageRecipients} from "../../../queries/useGetMessageRecipients.ts";
+import {relativeDate} from "../../../utilites/dates.ts";
+import {Pagination} from "../../common/Pagination";
+import {useState} from "react";
+import classes from "./MessageRecipientsModal.module.scss";
+
+interface MessageRecipientsModalProps extends GenericModalProps {
+ eventId: IdParam;
+ messageId: IdParam;
+}
+
+const statusColor = (status: string) => {
+ switch (status?.toUpperCase()) {
+ case 'SENT':
+ return 'green';
+ case 'FAILED':
+ return 'red';
+ default:
+ return 'gray';
+ }
+};
+
+export const MessageRecipientsModal = ({onClose, eventId, messageId}: MessageRecipientsModalProps) => {
+ const [page, setPage] = useState(1);
+ const recipientsQuery = useGetMessageRecipients(eventId, messageId, {pageNumber: page, perPage: 100});
+ const recipients = recipientsQuery.data?.data;
+ const pagination = recipientsQuery.data?.meta;
+ const total = pagination?.total;
+
+ return (
+
+ {recipientsQuery.isLoading && (
+
+
+
+ )}
+
+ {!!recipientsQuery.error && (
+
+ {t`Failed to load recipients`}
+
+ )}
+
+ {!recipientsQuery.isLoading && !recipientsQuery.error && recipients && recipients.length === 0 && (
+
+ {t`No recipients found`}
+
+ )}
+
+ {!recipientsQuery.isLoading && !recipientsQuery.error && recipients && recipients.length > 0 && (
+ <>
+ {total !== undefined && (
+
+ {total} {total === 1 ? t`recipient` : t`recipients`}
+
+ )}
+ {recipients.map((recipient) => (
+
+
{recipient.recipient}
+
+
+ {recipient.status}
+
+ {recipient.created_at && (
+
+ {relativeDate(recipient.created_at)}
+
+ )}
+
+
+ ))}
+
+ {pagination && Number(pagination.last_page) > 1 && (
+
+ )}
+ >
+ )}
+
+ );
+};
diff --git a/frontend/src/components/modals/SendMessageModal/SendMessageModal.module.scss b/frontend/src/components/modals/SendMessageModal/SendMessageModal.module.scss
index a27748fb01..bbd152e554 100644
--- a/frontend/src/components/modals/SendMessageModal/SendMessageModal.module.scss
+++ b/frontend/src/components/modals/SendMessageModal/SendMessageModal.module.scss
@@ -6,10 +6,15 @@
:global(.mantine-InputWrapper-root) {
margin-bottom: 0;
}
+
+ // Remove bottom margin from the Editor's wrapper
+ > div:last-child {
+ margin-bottom: 0;
+ }
}
.footerSection {
- margin-top: 0.25rem;
+ margin-top: 0.875rem;
display: flex;
flex-direction: column;
gap: 0.875rem;
@@ -48,3 +53,132 @@
.stripeConnectButton {
margin-top: 0.75rem;
}
+
+.scheduleSection {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.sendToggle {
+ display: flex;
+ gap: 0;
+ border: 1px solid var(--mantine-color-gray-3);
+ border-radius: var(--mantine-radius-md);
+ overflow: hidden;
+ background: var(--mantine-color-gray-0);
+}
+
+.toggleOption {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ padding: 9px 12px;
+ font-size: 0.82rem;
+ font-weight: 500;
+ border: none;
+ background: transparent;
+ color: var(--mantine-color-gray-6);
+ cursor: pointer;
+ transition: all 0.15s ease;
+
+ &:hover:not(.toggleActive) {
+ color: var(--mantine-color-gray-8);
+ background: var(--mantine-color-gray-1);
+ }
+}
+
+.toggleActive {
+ background: #fff;
+ color: var(--mantine-color-dark-8);
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
+ border-radius: calc(var(--mantine-radius-md) - 1px);
+ position: relative;
+ z-index: 1;
+}
+
+.scheduleBody {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.presetChips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.presetChip {
+ display: inline-flex;
+ align-items: center;
+ padding: 5px 12px;
+ font-size: 0.78rem;
+ font-weight: 500;
+ border: 1px solid var(--mantine-color-gray-3);
+ border-radius: 999px;
+ background: #fff;
+ color: var(--mantine-color-gray-7);
+ cursor: pointer;
+ transition: all 0.15s ease;
+ white-space: nowrap;
+
+ &:hover:not(.presetChipActive) {
+ border-color: var(--mantine-color-primary-3);
+ color: var(--mantine-color-primary-6);
+ background: var(--mantine-color-primary-0);
+ }
+}
+
+.presetChipActive {
+ border-color: var(--mantine-color-primary-5);
+ background: var(--mantine-color-primary-0);
+ color: var(--mantine-color-primary-7);
+ font-weight: 600;
+}
+
+.scheduledConfirmation {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 14px;
+ background: var(--mantine-color-primary-0);
+ border: 1px solid var(--mantine-color-primary-2);
+ border-radius: var(--mantine-radius-md);
+}
+
+.scheduledConfirmationIcon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ background: var(--mantine-color-primary-1);
+ color: var(--mantine-color-primary-6);
+ flex-shrink: 0;
+}
+
+.scheduledConfirmationText {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+}
+
+.scheduledConfirmationDate {
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: var(--mantine-color-dark-7);
+}
+
+.scheduledConfirmationTime {
+ font-size: 0.78rem;
+ color: var(--mantine-color-gray-7);
+}
+
+.scheduledConfirmationTz {
+ font-size: 0.7rem;
+ color: var(--mantine-color-gray-5);
+}
diff --git a/frontend/src/components/modals/SendMessageModal/index.tsx b/frontend/src/components/modals/SendMessageModal/index.tsx
index e7bd77e58b..33153085d4 100644
--- a/frontend/src/components/modals/SendMessageModal/index.tsx
+++ b/frontend/src/components/modals/SendMessageModal/index.tsx
@@ -1,10 +1,30 @@
-import {GenericModalProps, IdParam, MessageType, ProductType} from "../../../types.ts";
+import {Event, GenericModalProps, IdParam, MessageType, ProductType} from "../../../types.ts";
import {useParams} from "react-router";
import {useGetEvent} from "../../../queries/useGetEvent.ts";
import {useGetOrder} from "../../../queries/useGetOrder.ts";
import {Modal} from "../../common/Modal";
-import {Alert, Button, Checkbox, ComboboxItemGroup, Group, LoadingOverlay, Menu, MultiSelect, Select, TextInput} from "@mantine/core";
-import {IconAlertCircle, IconCheck, IconChevronDown, IconCopy, IconInfoCircle, IconSend, IconTestPipe} from "@tabler/icons-react";
+import {
+ Alert,
+ Button,
+ Checkbox,
+ ComboboxItemGroup,
+ Group,
+ LoadingOverlay,
+ Menu,
+ MultiSelect,
+ Select,
+ TextInput
+} from "@mantine/core";
+import {
+ IconAlertCircle,
+ IconCheck,
+ IconChevronDown,
+ IconClock,
+ IconCopy,
+ IconInfoCircle,
+ IconSend,
+ IconTestPipe
+} from "@tabler/icons-react";
import {useGetMe} from "../../../queries/useGetMe.ts";
import {useForm, UseFormReturnType} from "@mantine/form";
import {useFormErrorResponseHandler} from "../../../hooks/useFormErrorResponseHandler.tsx";
@@ -13,10 +33,12 @@ import {t} from "@lingui/macro";
import {Editor} from "../../common/Editor";
import {useSendEventMessage} from "../../../mutations/useSendEventMessage.ts";
import {ProductSelector} from "../../common/ProductSelector";
-import {useEffect, useState} from "react";
+import {useEffect, useMemo, useState} from "react";
import {useGetAccount} from "../../../queries/useGetAccount.ts";
import {StripeConnectButton} from "../../common/StripeConnectButton";
import {getConfig} from "../../../utilites/config";
+import {formatDate, utcToTz} from "../../../utilites/dates.ts";
+import dayjs from "dayjs";
import classes from "./SendMessageModal.module.scss";
interface EventMessageModalProps extends GenericModalProps {
@@ -77,6 +99,31 @@ const AttendeeField = ({orderId, eventId, attendeeId, form}: {
)
}
+const CUSTOM_PRESET = 'custom';
+
+const getSchedulePresets = (event: Event) => {
+ const now = dayjs.utc();
+ const startDate = dayjs.utc(event.start_date);
+ const endDate = event.end_date ? dayjs.utc(event.end_date) : null;
+
+ const presets: { value: string; label: string; date: dayjs.Dayjs }[] = [
+ {value: '1_week_before', label: t`1 week before event`, date: startDate.subtract(1, 'week')},
+ {value: '1_day_before', label: t`1 day before event`, date: startDate.subtract(1, 'day')},
+ {value: '1_hour_before', label: t`1 hour before event`, date: startDate.subtract(1, 'hour')},
+ {value: '1_day_after_start', label: t`1 day after start date`, date: startDate.add(1, 'day')},
+ ];
+
+ if (endDate) {
+ presets.push({
+ value: '1_day_after_end',
+ label: t`1 day after end date`,
+ date: endDate.add(1, 'day'),
+ });
+ }
+
+ return presets.filter(p => p.date.isAfter(now));
+};
+
export const SendMessageModal = (props: EventMessageModalProps) => {
const {onClose, orderId, productId, messageType, attendeeId} = props;
const {eventId} = useParams();
@@ -90,6 +137,15 @@ export const SendMessageModal = (props: EventMessageModalProps) => {
const formIsDisabled = !isAccountVerified || accountRequiresManualVerification;
const supportEmail = getConfig('VITE_PLATFORM_SUPPORT_EMAIL');
const [tierLimitError, setTierLimitError] = useState(null);
+ const [isScheduled, setIsScheduled] = useState(false);
+ const [selectedPreset, setSelectedPreset] = useState(null);
+
+ const presets = useMemo(() => event ? getSchedulePresets(event) : [], [event]);
+
+ const resolvedPresetDate = useMemo(() => {
+ if (!selectedPreset || selectedPreset === CUSTOM_PRESET) return null;
+ return presets.find(p => p.value === selectedPreset)?.date ?? null;
+ }, [selectedPreset, presets]);
const sendMessageMutation = useSendEventMessage();
@@ -106,20 +162,38 @@ export const SendMessageModal = (props: EventMessageModalProps) => {
type: 'EVENT',
acknowledgement: false,
order_statuses: ['COMPLETED'],
+ scheduled_at: '',
},
validate: {
acknowledgement: (value) => value === true ? null : t`You must acknowledge that this email is not promotional`,
+ scheduled_at: (value) => {
+ if (!isScheduled) return null;
+ if (selectedPreset && selectedPreset !== CUSTOM_PRESET) return null;
+ if (!value) return t`The scheduled time is required`;
+ if (event && dayjs.tz(value, event.timezone).isBefore(dayjs.utc())) return t`The scheduled time must be in the future`;
+ return null;
+ },
}
});
const handleSend = (values: any) => {
setTierLimitError(null);
+ const submitData = {...values};
+ if (isScheduled) {
+ if (selectedPreset && selectedPreset !== CUSTOM_PRESET && resolvedPresetDate) {
+ submitData.scheduled_at = resolvedPresetDate.toISOString();
+ } else if (submitData.scheduled_at && event) {
+ submitData.scheduled_at = dayjs.tz(submitData.scheduled_at, event.timezone).utc().toISOString();
+ }
+ } else {
+ delete submitData.scheduled_at;
+ }
sendMessageMutation.mutate({
eventId: eventId,
- messageData: values,
+ messageData: submitData,
}, {
onSuccess: () => {
- showSuccess(t`Message Sent`);
+ showSuccess(isScheduled ? t`Message Scheduled` : t`Message Sent`);
form.reset();
onClose();
},
@@ -157,7 +231,8 @@ export const SendMessageModal = (props: EventMessageModalProps) => {
+
+
+
+
+
+ {isScheduled && (
+
+
+ {presets.map(p => (
+
+ ))}
+
+
+ {selectedPreset === CUSTOM_PRESET && (
+
+ )}
+ {resolvedPresetDate && event && (
+
+
+
+
+
+
+ {formatDate(resolvedPresetDate.toISOString(), 'dddd, MMMM D, YYYY', event.timezone)}
+
+
+ {formatDate(resolvedPresetDate.toISOString(), 'h:mm A', event.timezone)}
+ {' '}{event.timezone}
+
+
+
+ )}
+
+ )}
+
+
{
className={classes.sendButton}
loading={sendMessageMutation.isPending}
type={'submit'}
- leftSection={}
+ leftSection={isScheduled ? : }
disabled={!form.values.acknowledgement || !isAccountVerified || accountRequiresManualVerification}
>
- {form.values.is_test ? t`Send Test` : t`Send Message`}
+ {isScheduled ? t`Schedule Message` : (form.values.is_test ? t`Send Test` : t`Send Message`)}