diff --git a/backend/app/DomainObjects/AccountDomainObject.php b/backend/app/DomainObjects/AccountDomainObject.php
index 55aa50cd93..51920ed3d6 100644
--- a/backend/app/DomainObjects/AccountDomainObject.php
+++ b/backend/app/DomainObjects/AccountDomainObject.php
@@ -15,6 +15,8 @@ class AccountDomainObject extends Generated\AccountDomainObjectAbstract
private ?AccountVatSettingDomainObject $accountVatSetting = null;
+ private ?AccountMessagingTierDomainObject $messagingTier = null;
+
public function getApplicationFee(): AccountApplicationFeeDTO
{
/** @var AccountConfigurationDomainObject $applicationFee */
@@ -56,6 +58,16 @@ public function setAccountVatSetting(AccountVatSettingDomainObject $accountVatSe
$this->accountVatSetting = $accountVatSetting;
}
+ public function getMessagingTier(): ?AccountMessagingTierDomainObject
+ {
+ return $this->messagingTier;
+ }
+
+ public function setMessagingTier(AccountMessagingTierDomainObject $messagingTier): void
+ {
+ $this->messagingTier = $messagingTier;
+ }
+
/**
* Get the primary active Stripe platform for this account
* Returns the platform with setup completed, preferring the most recent
diff --git a/backend/app/DomainObjects/AccountMessagingTierDomainObject.php b/backend/app/DomainObjects/AccountMessagingTierDomainObject.php
new file mode 100644
index 0000000000..9f01a2584a
--- /dev/null
+++ b/backend/app/DomainObjects/AccountMessagingTierDomainObject.php
@@ -0,0 +1,7 @@
+ __('You have reached your daily message limit. Please try again later or contact support to increase your limits.'),
+ self::RECIPIENT_LIMIT_EXCEEDED => __('The number of recipients exceeds your account limit. Please contact support to increase your limits.'),
+ self::LINKS_NOT_ALLOWED => __('Your account tier does not allow links in messages. Please contact support to enable this feature.'),
+ };
+ }
+}
diff --git a/backend/app/DomainObjects/Generated/AccountDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/AccountDomainObjectAbstract.php
index ee2b9e4e13..d270bc32cf 100644
--- a/backend/app/DomainObjects/Generated/AccountDomainObjectAbstract.php
+++ b/backend/app/DomainObjects/Generated/AccountDomainObjectAbstract.php
@@ -12,6 +12,7 @@ abstract class AccountDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr
final public const PLURAL_NAME = 'accounts';
final public const ID = 'id';
final public const ACCOUNT_CONFIGURATION_ID = 'account_configuration_id';
+ final public const ACCOUNT_MESSAGING_TIER_ID = 'account_messaging_tier_id';
final public const CURRENCY_CODE = 'currency_code';
final public const TIMEZONE = 'timezone';
final public const CREATED_AT = 'created_at';
@@ -29,6 +30,7 @@ abstract class AccountDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr
protected int $id;
protected ?int $account_configuration_id = null;
+ protected ?int $account_messaging_tier_id = null;
protected string $currency_code = 'USD';
protected ?string $timezone = null;
protected ?string $created_at = null;
@@ -49,6 +51,7 @@ public function toArray(): array
return [
'id' => $this->id ?? null,
'account_configuration_id' => $this->account_configuration_id ?? null,
+ 'account_messaging_tier_id' => $this->account_messaging_tier_id ?? null,
'currency_code' => $this->currency_code ?? null,
'timezone' => $this->timezone ?? null,
'created_at' => $this->created_at ?? null,
@@ -88,6 +91,17 @@ public function getAccountConfigurationId(): ?int
return $this->account_configuration_id;
}
+ public function setAccountMessagingTierId(?int $account_messaging_tier_id): self
+ {
+ $this->account_messaging_tier_id = $account_messaging_tier_id;
+ return $this;
+ }
+
+ public function getAccountMessagingTierId(): ?int
+ {
+ return $this->account_messaging_tier_id;
+ }
+
public function setCurrencyCode(string $currency_code): self
{
$this->currency_code = $currency_code;
diff --git a/backend/app/DomainObjects/Generated/AccountMessagingTierDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/AccountMessagingTierDomainObjectAbstract.php
new file mode 100644
index 0000000000..1c54c1d266
--- /dev/null
+++ b/backend/app/DomainObjects/Generated/AccountMessagingTierDomainObjectAbstract.php
@@ -0,0 +1,132 @@
+ $this->id ?? null,
+ 'name' => $this->name ?? null,
+ 'max_messages_per_24h' => $this->max_messages_per_24h ?? null,
+ 'max_recipients_per_message' => $this->max_recipients_per_message ?? null,
+ 'links_allowed' => $this->links_allowed ?? null,
+ 'created_at' => $this->created_at ?? null,
+ 'updated_at' => $this->updated_at ?? null,
+ 'deleted_at' => $this->deleted_at ?? null,
+ ];
+ }
+
+ public function setId(int $id): self
+ {
+ $this->id = $id;
+ return $this;
+ }
+
+ public function getId(): int
+ {
+ return $this->id;
+ }
+
+ public function setName(string $name): self
+ {
+ $this->name = $name;
+ return $this;
+ }
+
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ public function setMaxMessagesPer24h(int $max_messages_per_24h): self
+ {
+ $this->max_messages_per_24h = $max_messages_per_24h;
+ return $this;
+ }
+
+ public function getMaxMessagesPer24h(): int
+ {
+ return $this->max_messages_per_24h;
+ }
+
+ public function setMaxRecipientsPerMessage(int $max_recipients_per_message): self
+ {
+ $this->max_recipients_per_message = $max_recipients_per_message;
+ return $this;
+ }
+
+ public function getMaxRecipientsPerMessage(): int
+ {
+ return $this->max_recipients_per_message;
+ }
+
+ public function setLinksAllowed(bool $links_allowed): self
+ {
+ $this->links_allowed = $links_allowed;
+ return $this;
+ }
+
+ public function getLinksAllowed(): bool
+ {
+ return $this->links_allowed;
+ }
+
+ public function setCreatedAt(?string $created_at): self
+ {
+ $this->created_at = $created_at;
+ return $this;
+ }
+
+ public function getCreatedAt(): ?string
+ {
+ return $this->created_at;
+ }
+
+ public function setUpdatedAt(?string $updated_at): self
+ {
+ $this->updated_at = $updated_at;
+ return $this;
+ }
+
+ public function getUpdatedAt(): ?string
+ {
+ return $this->updated_at;
+ }
+
+ public function setDeletedAt(?string $deleted_at): self
+ {
+ $this->deleted_at = $deleted_at;
+ return $this;
+ }
+
+ public function getDeletedAt(): ?string
+ {
+ return $this->deleted_at;
+ }
+}
diff --git a/backend/app/DomainObjects/Generated/MessageDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/MessageDomainObjectAbstract.php
index acb847efb0..813b4f0594 100644
--- a/backend/app/DomainObjects/Generated/MessageDomainObjectAbstract.php
+++ b/backend/app/DomainObjects/Generated/MessageDomainObjectAbstract.php
@@ -26,6 +26,7 @@ abstract class MessageDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr
final public const CREATED_AT = 'created_at';
final public const UPDATED_AT = 'updated_at';
final public const DELETED_AT = 'deleted_at';
+ final public const ELIGIBILITY_FAILURES = 'eligibility_failures';
protected int $id;
protected int $event_id;
@@ -43,6 +44,7 @@ abstract class MessageDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr
protected string $created_at;
protected ?string $updated_at = null;
protected ?string $deleted_at = null;
+ protected array|string|null $eligibility_failures = null;
public function toArray(): array
{
@@ -63,6 +65,7 @@ public function toArray(): array
'created_at' => $this->created_at ?? null,
'updated_at' => $this->updated_at ?? null,
'deleted_at' => $this->deleted_at ?? null,
+ 'eligibility_failures' => $this->eligibility_failures ?? null,
];
}
@@ -241,4 +244,15 @@ public function getDeletedAt(): ?string
{
return $this->deleted_at;
}
+
+ public function setEligibilityFailures(array|string|null $eligibility_failures): self
+ {
+ $this->eligibility_failures = $eligibility_failures;
+ return $this;
+ }
+
+ public function getEligibilityFailures(): array|string|null
+ {
+ return $this->eligibility_failures;
+ }
}
diff --git a/backend/app/DomainObjects/Status/MessageStatus.php b/backend/app/DomainObjects/Status/MessageStatus.php
index 1ad7c7e7cb..223b35243a 100644
--- a/backend/app/DomainObjects/Status/MessageStatus.php
+++ b/backend/app/DomainObjects/Status/MessageStatus.php
@@ -8,6 +8,7 @@ enum MessageStatus
{
use BaseEnum;
+ case PENDING_REVIEW;
case PROCESSING;
case SENT;
case FAILED;
diff --git a/backend/app/Exceptions/MessagingTierLimitExceededException.php b/backend/app/Exceptions/MessagingTierLimitExceededException.php
new file mode 100644
index 0000000000..9c9cbac697
--- /dev/null
+++ b/backend/app/Exceptions/MessagingTierLimitExceededException.php
@@ -0,0 +1,22 @@
+violation->getFirstViolationMessage(), $code, $previous);
+ }
+
+ public function getViolation(): MessagingTierViolationDTO
+ {
+ return $this->violation;
+ }
+}
diff --git a/backend/app/Http/Actions/Admin/Accounts/UpdateAccountMessagingTierAction.php b/backend/app/Http/Actions/Admin/Accounts/UpdateAccountMessagingTierAction.php
new file mode 100644
index 0000000000..b8f5567c50
--- /dev/null
+++ b/backend/app/Http/Actions/Admin/Accounts/UpdateAccountMessagingTierAction.php
@@ -0,0 +1,37 @@
+minimumAllowedRole(Role::SUPERADMIN);
+
+ $validated = $request->validate([
+ 'messaging_tier_id' => 'required|integer|exists:account_messaging_tiers,id',
+ ]);
+
+ $this->handler->handle($accountId, $validated['messaging_tier_id']);
+
+ $account = $this->getAccountHandler->handle($accountId);
+
+ return $this->jsonResponse(new AdminAccountDetailResource($account), wrapInData: true);
+ }
+}
diff --git a/backend/app/Http/Actions/Admin/GetMessagingTiersAction.php b/backend/app/Http/Actions/Admin/GetMessagingTiersAction.php
new file mode 100644
index 0000000000..7838e67f6b
--- /dev/null
+++ b/backend/app/Http/Actions/Admin/GetMessagingTiersAction.php
@@ -0,0 +1,31 @@
+minimumAllowedRole(Role::SUPERADMIN);
+
+ $tiers = $this->messagingTierRepository->all();
+
+ return $this->resourceResponse(
+ resource: AccountMessagingTierResource::class,
+ data: $tiers
+ );
+ }
+}
diff --git a/backend/app/Http/Actions/Admin/Messages/ApproveMessageAction.php b/backend/app/Http/Actions/Admin/Messages/ApproveMessageAction.php
new file mode 100644
index 0000000000..1ecec56d9c
--- /dev/null
+++ b/backend/app/Http/Actions/Admin/Messages/ApproveMessageAction.php
@@ -0,0 +1,29 @@
+minimumAllowedRole(Role::SUPERADMIN);
+
+ $this->handler->handle($messageId);
+
+ return $this->jsonResponse([
+ 'message' => __('Message approved and queued for sending'),
+ ]);
+ }
+}
diff --git a/backend/app/Http/Actions/Messages/SendMessageAction.php b/backend/app/Http/Actions/Messages/SendMessageAction.php
index 42c61ee866..949a19795d 100644
--- a/backend/app/Http/Actions/Messages/SendMessageAction.php
+++ b/backend/app/Http/Actions/Messages/SendMessageAction.php
@@ -4,6 +4,7 @@
use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\Exceptions\AccountNotVerifiedException;
+use HiEvents\Exceptions\MessagingTierLimitExceededException;
use HiEvents\Http\Actions\BaseAction;
use HiEvents\Http\Request\Message\SendMessageRequest;
use HiEvents\Resources\Message\MessageResource;
@@ -44,6 +45,8 @@ public function __invoke(SendMessageRequest $request, int $eventId): JsonRespons
]));
} catch (AccountNotVerifiedException $e) {
return $this->errorResponse($e->getMessage(), Response::HTTP_UNAUTHORIZED);
+ } catch (MessagingTierLimitExceededException $e) {
+ return $this->errorResponse($e->getMessage(), Response::HTTP_TOO_MANY_REQUESTS);
}
return $this->resourceResponse(MessageResource::class, $message);
diff --git a/backend/app/Http/Resources/Admin/AccountMessagingTierResource.php b/backend/app/Http/Resources/Admin/AccountMessagingTierResource.php
new file mode 100644
index 0000000000..64d67dcc07
--- /dev/null
+++ b/backend/app/Http/Resources/Admin/AccountMessagingTierResource.php
@@ -0,0 +1,26 @@
+ $this->getId(),
+ 'name' => $this->getName(),
+ 'max_messages_per_24h' => $this->getMaxMessagesPer24h(),
+ 'max_recipients_per_message' => $this->getMaxRecipientsPerMessage(),
+ 'links_allowed' => $this->getLinksAllowed(),
+ ];
+ }
+}
diff --git a/backend/app/Http/Resources/Admin/AdminMessageResource.php b/backend/app/Http/Resources/Admin/AdminMessageResource.php
index 415ef5065d..c5200f1dfd 100644
--- a/backend/app/Http/Resources/Admin/AdminMessageResource.php
+++ b/backend/app/Http/Resources/Admin/AdminMessageResource.php
@@ -24,6 +24,7 @@ public function toArray(Request $request): array
'sent_by' => trim(($this->sent_by_first_name ?? '') . ' ' . ($this->sent_by_last_name ?? '')),
'sent_at' => $this->sent_at,
'created_at' => $this->created_at,
+ 'eligibility_failures' => $this->eligibility_failures,
];
}
}
diff --git a/backend/app/Jobs/Message/MessagePendingReviewJob.php b/backend/app/Jobs/Message/MessagePendingReviewJob.php
new file mode 100644
index 0000000000..fcd5e39650
--- /dev/null
+++ b/backend/app/Jobs/Message/MessagePendingReviewJob.php
@@ -0,0 +1,56 @@
+findById($this->messageId);
+
+ /** @var EventDomainObject $event */
+ $event = $eventRepository->findById($message->getEventId());
+
+ $account = $accountRepository->findByEventId($event->getId());
+
+ $supportEmail = $config->get('app.platform_support_email');
+
+ if ($supportEmail) {
+ $mailer->to($supportEmail)->send(
+ new MessagePendingReviewMail($message, $event, $account, $this->failures)
+ );
+ }
+ }
+}
diff --git a/backend/app/Mail/Admin/MessagePendingReviewMail.php b/backend/app/Mail/Admin/MessagePendingReviewMail.php
new file mode 100644
index 0000000000..55ae5fe1a5
--- /dev/null
+++ b/backend/app/Mail/Admin/MessagePendingReviewMail.php
@@ -0,0 +1,48 @@
+ $this->message->getSubject()
+ ]),
+ );
+ }
+
+ public function content(): Content
+ {
+ return new Content(
+ markdown: 'emails.admin.message-pending-review',
+ with: [
+ 'message' => $this->message,
+ 'event' => $this->event,
+ 'account' => $this->account,
+ 'failures' => $this->failures,
+ 'reviewUrl' => config('app.frontend_url') . '/admin/messages?status=PENDING_REVIEW',
+ ]
+ );
+ }
+}
diff --git a/backend/app/Models/Account.php b/backend/app/Models/Account.php
index 1becf59b96..9d470392bd 100644
--- a/backend/app/Models/Account.php
+++ b/backend/app/Models/Account.php
@@ -51,4 +51,9 @@ public function account_vat_setting(): HasOne
{
return $this->hasOne(AccountVatSetting::class);
}
+
+ public function messagingTier(): BelongsTo
+ {
+ return $this->belongsTo(AccountMessagingTier::class, 'account_messaging_tier_id');
+ }
}
diff --git a/backend/app/Models/AccountMessagingTier.php b/backend/app/Models/AccountMessagingTier.php
new file mode 100644
index 0000000000..6454b4a013
--- /dev/null
+++ b/backend/app/Models/AccountMessagingTier.php
@@ -0,0 +1,35 @@
+hasMany(Account::class);
+ }
+
+ protected function getFillableFields(): array
+ {
+ return [
+ 'name',
+ 'max_broadcasts_per_24h',
+ 'max_recipients_per_broadcast',
+ 'links_allowed',
+ ];
+ }
+
+ protected function getCastMap(): array
+ {
+ return [
+ 'links_allowed' => 'boolean',
+ ];
+ }
+}
diff --git a/backend/app/Models/Message.php b/backend/app/Models/Message.php
index 3680e2a19b..6f29ec7bff 100644
--- a/backend/app/Models/Message.php
+++ b/backend/app/Models/Message.php
@@ -26,6 +26,7 @@ protected function getCastMap(): array
'attendee_ids' => 'array',
'product_ids' => 'array',
'send_data' => 'array',
+ 'eligibility_failures' => 'array',
];
}
}
diff --git a/backend/app/Providers/RepositoryServiceProvider.php b/backend/app/Providers/RepositoryServiceProvider.php
index 1dd82a1011..712839ee35 100644
--- a/backend/app/Providers/RepositoryServiceProvider.php
+++ b/backend/app/Providers/RepositoryServiceProvider.php
@@ -6,6 +6,7 @@
use HiEvents\Repository\Eloquent\AccountAttributionRepository;
use HiEvents\Repository\Eloquent\AccountConfigurationRepository;
+use HiEvents\Repository\Eloquent\AccountMessagingTierRepository;
use HiEvents\Repository\Eloquent\AccountRepository;
use HiEvents\Repository\Eloquent\AccountStripePlatformRepository;
use HiEvents\Repository\Eloquent\AccountUserRepository;
@@ -51,6 +52,7 @@
use HiEvents\Repository\Eloquent\WebhookRepository;
use HiEvents\Repository\Interfaces\AccountAttributionRepositoryInterface;
use HiEvents\Repository\Interfaces\AccountConfigurationRepositoryInterface;
+use HiEvents\Repository\Interfaces\AccountMessagingTierRepositoryInterface;
use HiEvents\Repository\Interfaces\AccountRepositoryInterface;
use HiEvents\Repository\Interfaces\AccountStripePlatformRepositoryInterface;
use HiEvents\Repository\Interfaces\AccountUserRepositoryInterface;
@@ -147,6 +149,7 @@ class RepositoryServiceProvider extends ServiceProvider
AccountStripePlatformRepositoryInterface::class => AccountStripePlatformRepository::class,
AccountVatSettingRepositoryInterface::class => AccountVatSettingRepository::class,
TicketLookupTokenRepositoryInterface::class => TicketLookupTokenRepository::class,
+ AccountMessagingTierRepositoryInterface::class => AccountMessagingTierRepository::class,
];
public function register(): void
diff --git a/backend/app/Repository/Eloquent/AccountMessagingTierRepository.php b/backend/app/Repository/Eloquent/AccountMessagingTierRepository.php
new file mode 100644
index 0000000000..3af0a344bd
--- /dev/null
+++ b/backend/app/Repository/Eloquent/AccountMessagingTierRepository.php
@@ -0,0 +1,22 @@
+model
->select('accounts.*')
->withCount(['events', 'users'])
- ->with(['users' => function ($query) {
- $query->select('users.id', 'users.first_name', 'users.last_name', 'users.email')
- ->withPivot('role');
- }]);
+ ->with([
+ 'users' => function ($query) {
+ $query->select('users.id', 'users.first_name', 'users.last_name', 'users.email')
+ ->withPivot('role');
+ },
+ 'messagingTier',
+ ]);
if ($search) {
$query->where(function ($q) use ($search) {
@@ -63,6 +66,7 @@ public function getAccountWithDetails(int $accountId): Account
->with([
'configuration',
'account_vat_setting',
+ 'messagingTier',
'users' => function ($query) {
$query->select('users.id', 'users.first_name', 'users.last_name', 'users.email')
->withPivot('role');
diff --git a/backend/app/Repository/Eloquent/MessageRepository.php b/backend/app/Repository/Eloquent/MessageRepository.php
index d9d82c9198..5325e8385b 100644
--- a/backend/app/Repository/Eloquent/MessageRepository.php
+++ b/backend/app/Repository/Eloquent/MessageRepository.php
@@ -2,8 +2,10 @@
namespace HiEvents\Repository\Eloquent;
+use Carbon\Carbon;
use HiEvents\DomainObjects\Generated\MessageDomainObjectAbstract;
use HiEvents\DomainObjects\MessageDomainObject;
+use HiEvents\DomainObjects\Status\MessageStatus;
use HiEvents\Http\DTO\QueryParamsDTO;
use HiEvents\Models\Message;
use HiEvents\Repository\Interfaces\MessageRepositoryInterface;
@@ -48,4 +50,21 @@ public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAware
);
}
+ public function countMessagesInLast24Hours(int $accountId): int
+ {
+ $count = $this->model
+ ->join('events', 'messages.event_id', '=', 'events.id')
+ ->where('events.account_id', $accountId)
+ ->where('messages.created_at', '>=', Carbon::now()->subHours(24))
+ ->whereIn('messages.status', [
+ MessageStatus::PROCESSING->name,
+ MessageStatus::SENT->name,
+ MessageStatus::PENDING_REVIEW->name,
+ ])
+ ->count();
+
+ $this->resetModel();
+
+ return $count;
+ }
}
diff --git a/backend/app/Repository/Eloquent/OrderRepository.php b/backend/app/Repository/Eloquent/OrderRepository.php
index 535cd8deee..eb4071cad6 100644
--- a/backend/app/Repository/Eloquent/OrderRepository.php
+++ b/backend/app/Repository/Eloquent/OrderRepository.php
@@ -8,6 +8,7 @@
use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
+use HiEvents\DomainObjects\Status\OrderPaymentStatus;
use HiEvents\DomainObjects\Status\OrderStatus;
use HiEvents\Http\DTO\QueryParamsDTO;
use HiEvents\Models\Order;
@@ -165,6 +166,21 @@ public function findOrdersAssociatedWithProducts(int $eventId, array $productIds
);
}
+ public function countOrdersAssociatedWithProducts(int $eventId, array $productIds, array $orderStatuses): int
+ {
+ $count = $this->model
+ ->whereHas('order_items', static function (Builder $query) use ($productIds) {
+ $query->whereIn('product_id', $productIds);
+ })
+ ->whereIn('status', $orderStatuses)
+ ->where('event_id', $eventId)
+ ->count();
+
+ $this->resetModel();
+
+ return $count;
+ }
+
public function getAllOrdersForAdmin(
?string $search = null,
int $perPage = 20,
@@ -201,4 +217,19 @@ public function getAllOrdersForAdmin(
return $this->paginate($perPage);
}
+
+ public function hasCompletedPaidOrderForAccount(int $accountId): bool
+ {
+ $exists = $this->model
+ ->join('events', 'orders.event_id', '=', 'events.id')
+ ->join('stripe_payments', 'orders.id', '=', 'stripe_payments.order_id')
+ ->where('events.account_id', $accountId)
+ ->where('orders.payment_status', OrderPaymentStatus::PAYMENT_RECEIVED->name)
+ ->whereNotNull('stripe_payments.payment_intent_id')
+ ->exists();
+
+ $this->resetModel();
+
+ return $exists;
+ }
}
diff --git a/backend/app/Repository/Interfaces/AccountMessagingTierRepositoryInterface.php b/backend/app/Repository/Interfaces/AccountMessagingTierRepositoryInterface.php
new file mode 100644
index 0000000000..e2d175c9fe
--- /dev/null
+++ b/backend/app/Repository/Interfaces/AccountMessagingTierRepositoryInterface.php
@@ -0,0 +1,14 @@
+
+ */
+interface AccountMessagingTierRepositoryInterface extends RepositoryInterface
+{
+}
diff --git a/backend/app/Repository/Interfaces/MessageRepositoryInterface.php b/backend/app/Repository/Interfaces/MessageRepositoryInterface.php
index 280680c094..4c10b5398f 100644
--- a/backend/app/Repository/Interfaces/MessageRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/MessageRepositoryInterface.php
@@ -13,4 +13,6 @@
interface MessageRepositoryInterface extends RepositoryInterface
{
public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator;
+
+ public function countMessagesInLast24Hours(int $accountId): int;
}
diff --git a/backend/app/Repository/Interfaces/OrderRepositoryInterface.php b/backend/app/Repository/Interfaces/OrderRepositoryInterface.php
index a1dd38d0d5..524b6500c9 100644
--- a/backend/app/Repository/Interfaces/OrderRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/OrderRepositoryInterface.php
@@ -30,10 +30,14 @@ public function findByShortId(string $orderShortId): ?OrderDomainObject;
public function findOrdersAssociatedWithProducts(int $eventId, array $productIds, array $orderStatuses): Collection;
+ public function countOrdersAssociatedWithProducts(int $eventId, array $productIds, array $orderStatuses): int;
+
public function getAllOrdersForAdmin(
?string $search = null,
int $perPage = 20,
?string $sortBy = 'created_at',
?string $sortDirection = 'desc'
): LengthAwarePaginator;
+
+ public function hasCompletedPaidOrderForAccount(int $accountId): bool;
}
diff --git a/backend/app/Resources/Account/AdminAccountDetailResource.php b/backend/app/Resources/Account/AdminAccountDetailResource.php
index d457899b71..6e0b8cb9f4 100644
--- a/backend/app/Resources/Account/AdminAccountDetailResource.php
+++ b/backend/app/Resources/Account/AdminAccountDetailResource.php
@@ -2,6 +2,8 @@
namespace HiEvents\Resources\Account;
+use HiEvents\DomainObjects\AccountMessagingTierDomainObject;
+use HiEvents\Http\Resources\Admin\AccountMessagingTierResource;
use HiEvents\Models\Account;
use HiEvents\Resources\BaseResource;
use Illuminate\Http\Request;
@@ -31,9 +33,9 @@ public function toArray(Request $request): array
'name' => $configuration->name,
'is_system_default' => $configuration->is_system_default,
'application_fees' => $configuration->application_fees ?? [
- 'percentage' => 0,
- 'fixed' => 0,
- ],
+ 'percentage' => 0,
+ 'fixed' => 0,
+ ],
] : null,
'vat_setting' => $vatSetting ? [
'id' => $vatSetting->id,
@@ -54,6 +56,11 @@ public function toArray(Request $request): array
'role' => $user->pivot->role,
];
}),
+ 'messaging_tier' => $this->resource->messagingTier
+ ? new AccountMessagingTierResource(
+ AccountMessagingTierDomainObject::hydrateFromModel($this->resource->messagingTier)
+ )
+ : null,
];
}
}
diff --git a/backend/app/Resources/Account/AdminAccountResource.php b/backend/app/Resources/Account/AdminAccountResource.php
index c2063bc537..8b3117ed2c 100644
--- a/backend/app/Resources/Account/AdminAccountResource.php
+++ b/backend/app/Resources/Account/AdminAccountResource.php
@@ -27,6 +27,10 @@ public function toArray(Request $request): array
'role' => $user->pivot->role,
];
}),
+ 'messaging_tier' => $this->resource->messagingTier ? [
+ 'id' => $this->resource->messagingTier->id,
+ 'name' => $this->resource->messagingTier->name,
+ ] : null,
];
}
}
diff --git a/backend/app/Services/Application/Handlers/Account/CreateAccountHandler.php b/backend/app/Services/Application/Handlers/Account/CreateAccountHandler.php
index aa7420aabe..bc5d89e350 100644
--- a/backend/app/Services/Application/Handlers/Account/CreateAccountHandler.php
+++ b/backend/app/Services/Application/Handlers/Account/CreateAccountHandler.php
@@ -66,6 +66,7 @@ public function handle(CreateAccountDTO $accountData): AccountDomainObject
'short_id' => IdHelper::shortId(IdHelper::ACCOUNT_PREFIX),
'account_verified_at' => $isSaasMode ? null : now()->toDateTimeString(),
'account_configuration_id' => $this->getAccountConfigurationId($accountData),
+ 'account_messaging_tier_id' => $this->getDefaultMessagingTierId(),
]);
$user = $this->getExistingUser($accountData) ?? $this->userRepository->create([
@@ -258,4 +259,10 @@ private function isInternalReferrer(?string $referrer): bool
return $appHost === $referrerHost;
}
+
+ private function getDefaultMessagingTierId(): int
+ {
+ // Self-hosted instances get Premium tier, SaaS gets Untrusted
+ return $this->config->get('app.is_hi_events') ? 1 : 3;
+ }
}
diff --git a/backend/app/Services/Application/Handlers/Admin/ApproveMessageHandler.php b/backend/app/Services/Application/Handlers/Admin/ApproveMessageHandler.php
new file mode 100644
index 0000000000..128c1477ca
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/Admin/ApproveMessageHandler.php
@@ -0,0 +1,72 @@
+databaseManager->transaction(function () use ($messageId) {
+ return $this->approveMessage($messageId);
+ });
+ }
+
+ private function approveMessage(int $messageId): MessageDomainObject
+ {
+ $message = $this->messageRepository->findFirst($messageId);
+
+ if ($message === null) {
+ throw new ResourceNotFoundException(__('Message not found'));
+ }
+
+ if ($message->getStatus() !== MessageStatus::PENDING_REVIEW->name) {
+ throw ValidationException::withMessages([
+ 'status' => [__('Message must be in pending review status to be approved')],
+ ]);
+ }
+
+ $updatedMessage = $this->messageRepository->updateFromArray($messageId, [
+ 'status' => MessageStatus::PROCESSING->name,
+ ]);
+
+ $sendData = $message->getSendData();
+ $sendDataArray = is_string($sendData) ? json_decode($sendData, true) : $sendData;
+
+ 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() ?? [],
+ ));
+
+ return $updatedMessage;
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/Admin/UpdateAccountMessagingTierHandler.php b/backend/app/Services/Application/Handlers/Admin/UpdateAccountMessagingTierHandler.php
new file mode 100644
index 0000000000..eb79878051
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/Admin/UpdateAccountMessagingTierHandler.php
@@ -0,0 +1,23 @@
+accountRepository->updateFromArray($accountId, [
+ 'account_messaging_tier_id' => $tierId,
+ ]);
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/Message/DTO/SendMessageDTO.php b/backend/app/Services/Application/Handlers/Message/DTO/SendMessageDTO.php
index 3296a0bd77..99b54adb2e 100644
--- a/backend/app/Services/Application/Handlers/Message/DTO/SendMessageDTO.php
+++ b/backend/app/Services/Application/Handlers/Message/DTO/SendMessageDTO.php
@@ -16,8 +16,8 @@ public function __construct(
public readonly bool $is_test,
public readonly bool $send_copy_to_current_user,
public readonly int $sent_by_user_id,
+ public readonly ?int $order_id = null,
public readonly ?array $order_statuses = [],
- public readonly ?int $order_id,
public readonly ?int $id = null,
public readonly ?array $attendee_ids = [],
public readonly ?array $product_ids = [],
diff --git a/backend/app/Services/Application/Handlers/Message/SendMessageHandler.php b/backend/app/Services/Application/Handlers/Message/SendMessageHandler.php
index da3179a491..cec0298f7a 100644
--- a/backend/app/Services/Application/Handlers/Message/SendMessageHandler.php
+++ b/backend/app/Services/Application/Handlers/Message/SendMessageHandler.php
@@ -3,16 +3,20 @@
namespace HiEvents\Services\Application\Handlers\Message;
use Carbon\Carbon;
+use HiEvents\DomainObjects\Enums\MessageTypeEnum;
use HiEvents\DomainObjects\MessageDomainObject;
use HiEvents\DomainObjects\Status\MessageStatus;
use HiEvents\Exceptions\AccountNotVerifiedException;
+use HiEvents\Exceptions\MessagingTierLimitExceededException;
use HiEvents\Jobs\Event\SendMessagesJob;
+use HiEvents\Jobs\Message\MessagePendingReviewJob;
use HiEvents\Repository\Interfaces\AccountRepositoryInterface;
use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface;
use HiEvents\Repository\Interfaces\MessageRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductRepositoryInterface;
use HiEvents\Services\Application\Handlers\Message\DTO\SendMessageDTO;
+use HiEvents\Services\Domain\Message\MessagingEligibilityService;
use HiEvents\Services\Infrastructure\HtmlPurifier\HtmlPurifierService;
use Illuminate\Config\Repository;
use Illuminate\Support\Collection;
@@ -20,19 +24,21 @@
class SendMessageHandler
{
public function __construct(
- private readonly OrderRepositoryInterface $orderRepository,
- private readonly AttendeeRepositoryInterface $attendeeRepository,
- private readonly ProductRepositoryInterface $productRepository,
- private readonly MessageRepositoryInterface $messageRepository,
- private readonly AccountRepositoryInterface $accountRepository,
- private readonly HtmlPurifierService $purifier,
- private readonly Repository $config
+ private readonly OrderRepositoryInterface $orderRepository,
+ private readonly AttendeeRepositoryInterface $attendeeRepository,
+ private readonly ProductRepositoryInterface $productRepository,
+ private readonly MessageRepositoryInterface $messageRepository,
+ private readonly AccountRepositoryInterface $accountRepository,
+ private readonly HtmlPurifierService $purifier,
+ private readonly Repository $config,
+ private readonly MessagingEligibilityService $eligibilityService,
)
{
}
/**
* @throws AccountNotVerifiedException
+ * @throws MessagingTierLimitExceededException
*/
public function handle(SendMessageDTO $messageData): MessageDomainObject
{
@@ -51,6 +57,26 @@ public function handle(SendMessageDTO $messageData): MessageDomainObject
);
}
+ $recipientCount = $this->estimateRecipientCount($messageData);
+ $tierViolation = $this->eligibilityService->checkTierLimits(
+ $messageData->account_id,
+ $recipientCount,
+ $messageData->message
+ );
+
+ if ($tierViolation !== null) {
+ throw new MessagingTierLimitExceededException($tierViolation);
+ }
+
+ $eligibilityFailure = $this->eligibilityService->checkEligibility(
+ $messageData->account_id,
+ $messageData->event_id
+ );
+
+ $status = $eligibilityFailure !== null
+ ? MessageStatus::PENDING_REVIEW
+ : MessageStatus::PROCESSING;
+
$message = $this->messageRepository->create([
'event_id' => $messageData->event_id,
'subject' => $messageData->subject,
@@ -61,35 +87,63 @@ public function handle(SendMessageDTO $messageData): MessageDomainObject
'product_ids' => $this->getProductIds($messageData)->toArray(),
'sent_at' => Carbon::now()->toDateTimeString(),
'sent_by_user_id' => $messageData->sent_by_user_id,
- 'status' => MessageStatus::PROCESSING->name,
+ 'status' => $status->name,
+ 'eligibility_failures' => $eligibilityFailure?->getFailureValues(),
'send_data' => [
'is_test' => $messageData->is_test,
'send_copy_to_current_user' => $messageData->send_copy_to_current_user,
'order_statuses' => $messageData->order_statuses,
+ 'account_id' => $messageData->account_id,
+ 'attendee_ids' => $messageData->attendee_ids,
+ 'product_ids' => $messageData->product_ids,
],
]);
- $updatedData = SendMessageDTO::fromArray([
- 'id' => $message->getId(),
- 'event_id' => $messageData->event_id,
- 'subject' => $messageData->subject,
- 'message' => $this->purifier->purify($messageData->message),
- 'type' => $messageData->type,
- 'is_test' => $messageData->is_test,
- 'order_id' => $message->getOrderId(),
- 'attendee_ids' => $message->getAttendeeIds(),
- 'product_ids' => $message->getProductIds(),
- 'send_copy_to_current_user' => $messageData->send_copy_to_current_user,
- 'sent_by_user_id' => $messageData->sent_by_user_id,
- 'account_id' => $messageData->account_id,
- 'order_statuses' => $messageData->order_statuses,
- ]);
+ if ($status === MessageStatus::PENDING_REVIEW) {
+ MessagePendingReviewJob::dispatch($message->getId(), $eligibilityFailure->getFailureValues());
+ } else {
+ $updatedData = SendMessageDTO::fromArray([
+ 'account_id' => $messageData->account_id,
+ 'event_id' => $messageData->event_id,
+ 'subject' => $messageData->subject,
+ 'message' => $this->purifier->purify($messageData->message),
+ 'type' => $messageData->type,
+ 'is_test' => $messageData->is_test,
+ 'send_copy_to_current_user' => $messageData->send_copy_to_current_user,
+ 'sent_by_user_id' => $messageData->sent_by_user_id,
+ 'order_id' => $message->getOrderId(),
+ 'order_statuses' => $messageData->order_statuses,
+ 'id' => $message->getId(),
+ 'attendee_ids' => $message->getAttendeeIds(),
+ 'product_ids' => $message->getProductIds(),
+ ]);
- SendMessagesJob::dispatch($updatedData);
+ SendMessagesJob::dispatch($updatedData);
+ }
return $message;
}
+ private function estimateRecipientCount(SendMessageDTO $messageData): int
+ {
+ return match ($messageData->type) {
+ MessageTypeEnum::INDIVIDUAL_ATTENDEES => count($messageData->attendee_ids ?? []),
+ MessageTypeEnum::ORDER_OWNER => 1,
+ MessageTypeEnum::ALL_ATTENDEES => $this->attendeeRepository->countWhere([
+ 'event_id' => $messageData->event_id,
+ ]),
+ MessageTypeEnum::TICKET_HOLDERS => $this->attendeeRepository->countWhere([
+ 'event_id' => $messageData->event_id,
+ ['product_id', 'in', $messageData->product_ids ?? []],
+ ]),
+ MessageTypeEnum::ORDER_OWNERS_WITH_PRODUCT => $this->orderRepository->countOrdersAssociatedWithProducts(
+ eventId: $messageData->event_id,
+ productIds: $messageData->product_ids ?? [],
+ orderStatuses: $messageData->order_statuses ?? ['COMPLETED'],
+ ),
+ };
+ }
+
private function getAttendeeIds(SendMessageDTO $messageData): Collection
{
$attendees = $this->attendeeRepository->findWhereIn(
@@ -116,7 +170,7 @@ private function getProductIds(SendMessageDTO $messageData): Collection
columns: ['id']
);
- return $products->map(fn($attendee) => $attendee->getId());
+ return $products->map(fn($product) => $product->getId());
}
private function getOrderId(SendMessageDTO $messageData): ?int
diff --git a/backend/app/Services/Domain/Message/DTO/MessagingEligibilityFailureDTO.php b/backend/app/Services/Domain/Message/DTO/MessagingEligibilityFailureDTO.php
new file mode 100644
index 0000000000..1a55467674
--- /dev/null
+++ b/backend/app/Services/Domain/Message/DTO/MessagingEligibilityFailureDTO.php
@@ -0,0 +1,29 @@
+ $failure->value, $this->failures);
+ }
+}
diff --git a/backend/app/Services/Domain/Message/DTO/MessagingTierViolationDTO.php b/backend/app/Services/Domain/Message/DTO/MessagingTierViolationDTO.php
new file mode 100644
index 0000000000..9929e1095a
--- /dev/null
+++ b/backend/app/Services/Domain/Message/DTO/MessagingTierViolationDTO.php
@@ -0,0 +1,26 @@
+violations[0]->getMessage();
+ }
+}
diff --git a/backend/app/Services/Domain/Message/MessagingEligibilityService.php b/backend/app/Services/Domain/Message/MessagingEligibilityService.php
new file mode 100644
index 0000000000..afdfa4a530
--- /dev/null
+++ b/backend/app/Services/Domain/Message/MessagingEligibilityService.php
@@ -0,0 +1,127 @@
+accountRepository
+ ->loadRelation(AccountStripePlatformDomainObject::class)
+ ->findById($accountId);
+
+ if (!$account->isStripeSetupComplete()) {
+ $failures[] = MessagingEligibilityFailureEnum::STRIPE_NOT_CONNECTED;
+ }
+
+ if (!$this->hasPaidOrder($accountId)) {
+ $failures[] = MessagingEligibilityFailureEnum::NO_PAID_ORDERS;
+ }
+
+ $event = $this->eventRepository->findById($eventId);
+ if ($this->isEventTooNew($event->getCreatedAt())) {
+ $failures[] = MessagingEligibilityFailureEnum::EVENT_TOO_NEW;
+ }
+
+ if (empty($failures)) {
+ return null;
+ }
+
+ return new MessagingEligibilityFailureDTO(
+ accountId: $accountId,
+ eventId: $eventId,
+ failures: $failures,
+ );
+ }
+
+ public function checkTierLimits(int $accountId, int $recipientCount, string $messageContent): ?MessagingTierViolationDTO
+ {
+ $violations = [];
+
+ $account = $this->accountRepository->findById($accountId);
+ $tier = $this->getAccountMessagingTier($account->getAccountMessagingTierId());
+
+ $messagesInLast24h = $this->messageRepository->countMessagesInLast24Hours($accountId);
+ if ($messagesInLast24h >= $tier->getMaxMessagesPer24h()) {
+ // $violations[] = MessagingTierViolationEnum::MESSAGE_LIMIT_EXCEEDED;
+ }
+
+ if ($recipientCount > $tier->getMaxRecipientsPerMessage()) {
+ // $violations[] = MessagingTierViolationEnum::RECIPIENT_LIMIT_EXCEEDED;
+ }
+
+ if (!$tier->getLinksAllowed() && $this->containsLinks($messageContent)) {
+ $violations[] = MessagingTierViolationEnum::LINKS_NOT_ALLOWED;
+ }
+
+ if (empty($violations)) {
+ return null;
+ }
+
+ return new MessagingTierViolationDTO(
+ accountId: $accountId,
+ tierName: $tier->getName(),
+ violations: $violations,
+ );
+ }
+
+ private function getAccountMessagingTier(?int $tierId): AccountMessagingTierDomainObject
+ {
+ if ($tierId !== null) {
+ $tier = $this->accountMessagingTierRepository->findFirst($tierId);
+ if ($tier !== null) {
+ return $tier;
+ }
+ }
+
+ return $this->accountMessagingTierRepository->findFirstWhere([
+ 'name' => self::UNTRUSTED_TIER_NAME,
+ ]);
+ }
+
+ private function hasPaidOrder(int $accountId): bool
+ {
+ return $this->orderRepository->hasCompletedPaidOrderForAccount($accountId);
+ }
+
+ private function isEventTooNew(string $createdAt): bool
+ {
+ $eventCreatedAt = Carbon::parse($createdAt);
+ $twentyFourHoursAgo = Carbon::now()->subHours(24);
+
+ return $eventCreatedAt->isAfter($twentyFourHoursAgo);
+ }
+
+ private function containsLinks(string $content): bool
+ {
+ $urlPattern = '/https?:\/\/[^\s<>"\']+|href\s*=\s*["\'][^"\']+["\']/i';
+
+ return (bool) preg_match($urlPattern, $content);
+ }
+}
diff --git a/backend/database/migrations/2025_12_30_095747_create_account_messaging_tiers_table.php b/backend/database/migrations/2025_12_30_095747_create_account_messaging_tiers_table.php
new file mode 100644
index 0000000000..0cccc8a98e
--- /dev/null
+++ b/backend/database/migrations/2025_12_30_095747_create_account_messaging_tiers_table.php
@@ -0,0 +1,54 @@
+id();
+ $table->string('name', 50)->unique();
+ $table->integer('max_messages_per_24h');
+ $table->integer('max_recipients_per_message');
+ $table->boolean('links_allowed')->default(false);
+ $table->timestamps();
+ $table->softDeletes();
+ });
+
+ DB::table('account_messaging_tiers')->insert([
+ [
+ 'name' => 'Untrusted',
+ 'max_messages_per_24h' => 3,
+ 'max_recipients_per_message' => 100,
+ 'links_allowed' => false,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ [
+ 'name' => 'Trusted',
+ 'max_messages_per_24h' => 10,
+ 'max_recipients_per_message' => 5000,
+ 'links_allowed' => true,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ [
+ 'name' => 'Premium',
+ 'max_messages_per_24h' => 50,
+ 'max_recipients_per_message' => 50000,
+ 'links_allowed' => true,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ ]);
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('account_messaging_tiers');
+ }
+};
diff --git a/backend/database/migrations/2025_12_30_095748_add_eligibility_failures_to_messages_table.php b/backend/database/migrations/2025_12_30_095748_add_eligibility_failures_to_messages_table.php
new file mode 100644
index 0000000000..66e8c37b2d
--- /dev/null
+++ b/backend/database/migrations/2025_12_30_095748_add_eligibility_failures_to_messages_table.php
@@ -0,0 +1,22 @@
+json('eligibility_failures')->nullable()->after('send_data');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('messages', function (Blueprint $table) {
+ $table->dropColumn('eligibility_failures');
+ });
+ }
+};
diff --git a/backend/database/migrations/2025_12_30_095749_add_messaging_tier_to_accounts_table.php b/backend/database/migrations/2025_12_30_095749_add_messaging_tier_to_accounts_table.php
new file mode 100644
index 0000000000..e228b1cd85
--- /dev/null
+++ b/backend/database/migrations/2025_12_30_095749_add_messaging_tier_to_accounts_table.php
@@ -0,0 +1,45 @@
+foreignId('account_messaging_tier_id')
+ ->nullable()
+ ->constrained('account_messaging_tiers')
+ ->nullOnDelete();
+ });
+
+ if (!config('app.is_hi_events')) {
+ // Self-hosted: set all accounts to Premium tier
+ DB::table('accounts')
+ ->whereNull('account_messaging_tier_id')
+ ->update(['account_messaging_tier_id' => self::TIER_PREMIUM]);
+ } else {
+ DB::table('accounts')
+ ->whereNull('account_messaging_tier_id')
+ ->where('is_manually_verified', true)
+ ->update(['account_messaging_tier_id' => self::TIER_PREMIUM]);
+
+ DB::table('accounts')
+ ->whereNull('account_messaging_tier_id')
+ ->update(['account_messaging_tier_id' => self::TIER_UNTRUSTED]);
+ }
+ }
+
+ public function down(): void
+ {
+ Schema::table('accounts', function (Blueprint $table) {
+ $table->dropConstrainedForeignId('account_messaging_tier_id');
+ });
+ }
+};
diff --git a/backend/lang/de.json b/backend/lang/de.json
index 570305c789..a15dffc2ea 100644
--- a/backend/lang/de.json
+++ b/backend/lang/de.json
@@ -632,5 +632,35 @@
"Click the button below to view your tickets and order details.": "",
"View My Tickets": "",
"This link will expire in 24 hours.": "",
- "If you did not request this, please ignore this email.": ""
+ "If you did not request this, please ignore this email.": "",
+ "You have reached your daily message limit. Please try again later or contact support to increase your limits.": "",
+ "The number of recipients exceeds your account limit. Please contact support to increase your limits.": "",
+ "Your account tier does not allow links in messages. Please contact support to enable this feature.": "",
+ "Deleted :count failed jobs": "",
+ "Failed job not found": "",
+ "Queued :count jobs for retry": "",
+ "Job queued for retry": "",
+ "Message approved and queued for sending": "",
+ "Promo code not found": "",
+ "Question not found": "",
+ "[Action Required] Message Pending Review - :subject": "",
+ "Message not found": "",
+ "Message must be in pending review status to be approved": "",
+ "The :attribute must be a valid URL.": "",
+ "The :attribute must use http or https protocol.": "",
+ "The :attribute cannot point to localhost or internal addresses.": "",
+ "The :attribute cannot use reserved domain names.": "",
+ "The :attribute cannot point to cloud metadata endpoints.": "",
+ "The :attribute cannot point to private or internal IP addresses.": "",
+ "A message has been flagged for review due to eligibility check failures.": "",
+ "Message Details": "",
+ "Subject": "",
+ "Account": "",
+ "Event": "",
+ "Message ID": "",
+ "Eligibility Failures": "",
+ "Stripe payment account not connected": "",
+ "No completed paid orders on this account": "",
+ "Event was created less than 24 hours ago": "",
+ "Review Message": ""
}
\ No newline at end of file
diff --git a/backend/lang/es.json b/backend/lang/es.json
index a590631386..450cb3be08 100644
--- a/backend/lang/es.json
+++ b/backend/lang/es.json
@@ -632,5 +632,35 @@
"Click the button below to view your tickets and order details.": "",
"View My Tickets": "",
"This link will expire in 24 hours.": "",
- "If you did not request this, please ignore this email.": ""
+ "If you did not request this, please ignore this email.": "",
+ "You have reached your daily message limit. Please try again later or contact support to increase your limits.": "",
+ "The number of recipients exceeds your account limit. Please contact support to increase your limits.": "",
+ "Your account tier does not allow links in messages. Please contact support to enable this feature.": "",
+ "Deleted :count failed jobs": "",
+ "Failed job not found": "",
+ "Queued :count jobs for retry": "",
+ "Job queued for retry": "",
+ "Message approved and queued for sending": "",
+ "Promo code not found": "",
+ "Question not found": "",
+ "[Action Required] Message Pending Review - :subject": "",
+ "Message not found": "",
+ "Message must be in pending review status to be approved": "",
+ "The :attribute must be a valid URL.": "",
+ "The :attribute must use http or https protocol.": "",
+ "The :attribute cannot point to localhost or internal addresses.": "",
+ "The :attribute cannot use reserved domain names.": "",
+ "The :attribute cannot point to cloud metadata endpoints.": "",
+ "The :attribute cannot point to private or internal IP addresses.": "",
+ "A message has been flagged for review due to eligibility check failures.": "",
+ "Message Details": "",
+ "Subject": "",
+ "Account": "",
+ "Event": "",
+ "Message ID": "",
+ "Eligibility Failures": "",
+ "Stripe payment account not connected": "",
+ "No completed paid orders on this account": "",
+ "Event was created less than 24 hours ago": "",
+ "Review Message": ""
}
\ No newline at end of file
diff --git a/backend/lang/fr.json b/backend/lang/fr.json
index 3bbe1e7237..e5f1482357 100644
--- a/backend/lang/fr.json
+++ b/backend/lang/fr.json
@@ -632,5 +632,35 @@
"Click the button below to view your tickets and order details.": "",
"View My Tickets": "",
"This link will expire in 24 hours.": "",
- "If you did not request this, please ignore this email.": ""
+ "If you did not request this, please ignore this email.": "",
+ "You have reached your daily message limit. Please try again later or contact support to increase your limits.": "",
+ "The number of recipients exceeds your account limit. Please contact support to increase your limits.": "",
+ "Your account tier does not allow links in messages. Please contact support to enable this feature.": "",
+ "Deleted :count failed jobs": "",
+ "Failed job not found": "",
+ "Queued :count jobs for retry": "",
+ "Job queued for retry": "",
+ "Message approved and queued for sending": "",
+ "Promo code not found": "",
+ "Question not found": "",
+ "[Action Required] Message Pending Review - :subject": "",
+ "Message not found": "",
+ "Message must be in pending review status to be approved": "",
+ "The :attribute must be a valid URL.": "",
+ "The :attribute must use http or https protocol.": "",
+ "The :attribute cannot point to localhost or internal addresses.": "",
+ "The :attribute cannot use reserved domain names.": "",
+ "The :attribute cannot point to cloud metadata endpoints.": "",
+ "The :attribute cannot point to private or internal IP addresses.": "",
+ "A message has been flagged for review due to eligibility check failures.": "",
+ "Message Details": "",
+ "Subject": "",
+ "Account": "",
+ "Event": "",
+ "Message ID": "",
+ "Eligibility Failures": "",
+ "Stripe payment account not connected": "",
+ "No completed paid orders on this account": "",
+ "Event was created less than 24 hours ago": "",
+ "Review Message": ""
}
\ No newline at end of file
diff --git a/backend/lang/hu.json b/backend/lang/hu.json
index 23abed4f72..197198a615 100644
--- a/backend/lang/hu.json
+++ b/backend/lang/hu.json
@@ -632,5 +632,35 @@
"View My Tickets": "",
"This link will expire in 24 hours.": "",
"If you did not request this, please ignore this email.": "",
- "Your email confirmation code is:": ""
+ "Your email confirmation code is:": "",
+ "You have reached your daily message limit. Please try again later or contact support to increase your limits.": "",
+ "The number of recipients exceeds your account limit. Please contact support to increase your limits.": "",
+ "Your account tier does not allow links in messages. Please contact support to enable this feature.": "",
+ "Deleted :count failed jobs": "",
+ "Failed job not found": "",
+ "Queued :count jobs for retry": "",
+ "Job queued for retry": "",
+ "Message approved and queued for sending": "",
+ "Promo code not found": "",
+ "Question not found": "",
+ "[Action Required] Message Pending Review - :subject": "",
+ "Message not found": "",
+ "Message must be in pending review status to be approved": "",
+ "The :attribute must be a valid URL.": "",
+ "The :attribute must use http or https protocol.": "",
+ "The :attribute cannot point to localhost or internal addresses.": "",
+ "The :attribute cannot use reserved domain names.": "",
+ "The :attribute cannot point to cloud metadata endpoints.": "",
+ "The :attribute cannot point to private or internal IP addresses.": "",
+ "A message has been flagged for review due to eligibility check failures.": "",
+ "Message Details": "",
+ "Subject": "",
+ "Account": "",
+ "Event": "",
+ "Message ID": "",
+ "Eligibility Failures": "",
+ "Stripe payment account not connected": "",
+ "No completed paid orders on this account": "",
+ "Event was created less than 24 hours ago": "",
+ "Review Message": ""
}
\ No newline at end of file
diff --git a/backend/lang/it.json b/backend/lang/it.json
index e5dc419a2d..69a5ba13cd 100644
--- a/backend/lang/it.json
+++ b/backend/lang/it.json
@@ -633,5 +633,35 @@
"Click the button below to view your tickets and order details.": "",
"View My Tickets": "",
"This link will expire in 24 hours.": "",
- "If you did not request this, please ignore this email.": ""
+ "If you did not request this, please ignore this email.": "",
+ "You have reached your daily message limit. Please try again later or contact support to increase your limits.": "",
+ "The number of recipients exceeds your account limit. Please contact support to increase your limits.": "",
+ "Your account tier does not allow links in messages. Please contact support to enable this feature.": "",
+ "Deleted :count failed jobs": "",
+ "Failed job not found": "",
+ "Queued :count jobs for retry": "",
+ "Job queued for retry": "",
+ "Message approved and queued for sending": "",
+ "Promo code not found": "",
+ "Question not found": "",
+ "[Action Required] Message Pending Review - :subject": "",
+ "Message not found": "",
+ "Message must be in pending review status to be approved": "",
+ "The :attribute must be a valid URL.": "",
+ "The :attribute must use http or https protocol.": "",
+ "The :attribute cannot point to localhost or internal addresses.": "",
+ "The :attribute cannot use reserved domain names.": "",
+ "The :attribute cannot point to cloud metadata endpoints.": "",
+ "The :attribute cannot point to private or internal IP addresses.": "",
+ "A message has been flagged for review due to eligibility check failures.": "",
+ "Message Details": "",
+ "Subject": "",
+ "Account": "",
+ "Event": "",
+ "Message ID": "",
+ "Eligibility Failures": "",
+ "Stripe payment account not connected": "",
+ "No completed paid orders on this account": "",
+ "Event was created less than 24 hours ago": "",
+ "Review Message": ""
}
\ No newline at end of file
diff --git a/backend/lang/nl.json b/backend/lang/nl.json
index c95bb89481..13961122a2 100644
--- a/backend/lang/nl.json
+++ b/backend/lang/nl.json
@@ -632,5 +632,35 @@
"Click the button below to view your tickets and order details.": "",
"View My Tickets": "",
"This link will expire in 24 hours.": "",
- "If you did not request this, please ignore this email.": ""
+ "If you did not request this, please ignore this email.": "",
+ "You have reached your daily message limit. Please try again later or contact support to increase your limits.": "",
+ "The number of recipients exceeds your account limit. Please contact support to increase your limits.": "",
+ "Your account tier does not allow links in messages. Please contact support to enable this feature.": "",
+ "Deleted :count failed jobs": "",
+ "Failed job not found": "",
+ "Queued :count jobs for retry": "",
+ "Job queued for retry": "",
+ "Message approved and queued for sending": "",
+ "Promo code not found": "",
+ "Question not found": "",
+ "[Action Required] Message Pending Review - :subject": "",
+ "Message not found": "",
+ "Message must be in pending review status to be approved": "",
+ "The :attribute must be a valid URL.": "",
+ "The :attribute must use http or https protocol.": "",
+ "The :attribute cannot point to localhost or internal addresses.": "",
+ "The :attribute cannot use reserved domain names.": "",
+ "The :attribute cannot point to cloud metadata endpoints.": "",
+ "The :attribute cannot point to private or internal IP addresses.": "",
+ "A message has been flagged for review due to eligibility check failures.": "",
+ "Message Details": "",
+ "Subject": "",
+ "Account": "",
+ "Event": "",
+ "Message ID": "",
+ "Eligibility Failures": "",
+ "Stripe payment account not connected": "",
+ "No completed paid orders on this account": "",
+ "Event was created less than 24 hours ago": "",
+ "Review Message": ""
}
\ No newline at end of file
diff --git a/backend/lang/pt-br.json b/backend/lang/pt-br.json
index 904e46f3b3..a8a633ca50 100644
--- a/backend/lang/pt-br.json
+++ b/backend/lang/pt-br.json
@@ -632,5 +632,35 @@
"Click the button below to view your tickets and order details.": "",
"View My Tickets": "",
"This link will expire in 24 hours.": "",
- "If you did not request this, please ignore this email.": ""
+ "If you did not request this, please ignore this email.": "",
+ "You have reached your daily message limit. Please try again later or contact support to increase your limits.": "",
+ "The number of recipients exceeds your account limit. Please contact support to increase your limits.": "",
+ "Your account tier does not allow links in messages. Please contact support to enable this feature.": "",
+ "Deleted :count failed jobs": "",
+ "Failed job not found": "",
+ "Queued :count jobs for retry": "",
+ "Job queued for retry": "",
+ "Message approved and queued for sending": "",
+ "Promo code not found": "",
+ "Question not found": "",
+ "[Action Required] Message Pending Review - :subject": "",
+ "Message not found": "",
+ "Message must be in pending review status to be approved": "",
+ "The :attribute must be a valid URL.": "",
+ "The :attribute must use http or https protocol.": "",
+ "The :attribute cannot point to localhost or internal addresses.": "",
+ "The :attribute cannot use reserved domain names.": "",
+ "The :attribute cannot point to cloud metadata endpoints.": "",
+ "The :attribute cannot point to private or internal IP addresses.": "",
+ "A message has been flagged for review due to eligibility check failures.": "",
+ "Message Details": "",
+ "Subject": "",
+ "Account": "",
+ "Event": "",
+ "Message ID": "",
+ "Eligibility Failures": "",
+ "Stripe payment account not connected": "",
+ "No completed paid orders on this account": "",
+ "Event was created less than 24 hours ago": "",
+ "Review Message": ""
}
\ No newline at end of file
diff --git a/backend/lang/pt.json b/backend/lang/pt.json
index baa75d700c..d0a5cf2216 100644
--- a/backend/lang/pt.json
+++ b/backend/lang/pt.json
@@ -632,5 +632,35 @@
"Click the button below to view your tickets and order details.": "",
"View My Tickets": "",
"This link will expire in 24 hours.": "",
- "If you did not request this, please ignore this email.": ""
+ "If you did not request this, please ignore this email.": "",
+ "You have reached your daily message limit. Please try again later or contact support to increase your limits.": "",
+ "The number of recipients exceeds your account limit. Please contact support to increase your limits.": "",
+ "Your account tier does not allow links in messages. Please contact support to enable this feature.": "",
+ "Deleted :count failed jobs": "",
+ "Failed job not found": "",
+ "Queued :count jobs for retry": "",
+ "Job queued for retry": "",
+ "Message approved and queued for sending": "",
+ "Promo code not found": "",
+ "Question not found": "",
+ "[Action Required] Message Pending Review - :subject": "",
+ "Message not found": "",
+ "Message must be in pending review status to be approved": "",
+ "The :attribute must be a valid URL.": "",
+ "The :attribute must use http or https protocol.": "",
+ "The :attribute cannot point to localhost or internal addresses.": "",
+ "The :attribute cannot use reserved domain names.": "",
+ "The :attribute cannot point to cloud metadata endpoints.": "",
+ "The :attribute cannot point to private or internal IP addresses.": "",
+ "A message has been flagged for review due to eligibility check failures.": "",
+ "Message Details": "",
+ "Subject": "",
+ "Account": "",
+ "Event": "",
+ "Message ID": "",
+ "Eligibility Failures": "",
+ "Stripe payment account not connected": "",
+ "No completed paid orders on this account": "",
+ "Event was created less than 24 hours ago": "",
+ "Review Message": ""
}
\ No newline at end of file
diff --git a/backend/lang/ru.json b/backend/lang/ru.json
index f195e19513..6d57f5424b 100644
--- a/backend/lang/ru.json
+++ b/backend/lang/ru.json
@@ -647,5 +647,35 @@
"Click the button below to view your tickets and order details.": "",
"View My Tickets": "",
"This link will expire in 24 hours.": "",
- "If you did not request this, please ignore this email.": ""
+ "If you did not request this, please ignore this email.": "",
+ "You have reached your daily message limit. Please try again later or contact support to increase your limits.": "",
+ "The number of recipients exceeds your account limit. Please contact support to increase your limits.": "",
+ "Your account tier does not allow links in messages. Please contact support to enable this feature.": "",
+ "Deleted :count failed jobs": "",
+ "Failed job not found": "",
+ "Queued :count jobs for retry": "",
+ "Job queued for retry": "",
+ "Message approved and queued for sending": "",
+ "Promo code not found": "",
+ "Question not found": "",
+ "[Action Required] Message Pending Review - :subject": "",
+ "Message not found": "",
+ "Message must be in pending review status to be approved": "",
+ "The :attribute must be a valid URL.": "",
+ "The :attribute must use http or https protocol.": "",
+ "The :attribute cannot point to localhost or internal addresses.": "",
+ "The :attribute cannot use reserved domain names.": "",
+ "The :attribute cannot point to cloud metadata endpoints.": "",
+ "The :attribute cannot point to private or internal IP addresses.": "",
+ "A message has been flagged for review due to eligibility check failures.": "",
+ "Message Details": "",
+ "Subject": "",
+ "Account": "",
+ "Event": "",
+ "Message ID": "",
+ "Eligibility Failures": "",
+ "Stripe payment account not connected": "",
+ "No completed paid orders on this account": "",
+ "Event was created less than 24 hours ago": "",
+ "Review Message": ""
}
\ No newline at end of file
diff --git a/backend/lang/tr.json b/backend/lang/tr.json
index ece00667e3..801437f842 100644
--- a/backend/lang/tr.json
+++ b/backend/lang/tr.json
@@ -647,5 +647,35 @@
"Click the button below to view your tickets and order details.": "",
"View My Tickets": "",
"This link will expire in 24 hours.": "",
- "If you did not request this, please ignore this email.": ""
+ "If you did not request this, please ignore this email.": "",
+ "You have reached your daily message limit. Please try again later or contact support to increase your limits.": "",
+ "The number of recipients exceeds your account limit. Please contact support to increase your limits.": "",
+ "Your account tier does not allow links in messages. Please contact support to enable this feature.": "",
+ "Deleted :count failed jobs": "",
+ "Failed job not found": "",
+ "Queued :count jobs for retry": "",
+ "Job queued for retry": "",
+ "Message approved and queued for sending": "",
+ "Promo code not found": "",
+ "Question not found": "",
+ "[Action Required] Message Pending Review - :subject": "",
+ "Message not found": "",
+ "Message must be in pending review status to be approved": "",
+ "The :attribute must be a valid URL.": "",
+ "The :attribute must use http or https protocol.": "",
+ "The :attribute cannot point to localhost or internal addresses.": "",
+ "The :attribute cannot use reserved domain names.": "",
+ "The :attribute cannot point to cloud metadata endpoints.": "",
+ "The :attribute cannot point to private or internal IP addresses.": "",
+ "A message has been flagged for review due to eligibility check failures.": "",
+ "Message Details": "",
+ "Subject": "",
+ "Account": "",
+ "Event": "",
+ "Message ID": "",
+ "Eligibility Failures": "",
+ "Stripe payment account not connected": "",
+ "No completed paid orders on this account": "",
+ "Event was created less than 24 hours ago": "",
+ "Review Message": ""
}
\ No newline at end of file
diff --git a/backend/lang/vi.json b/backend/lang/vi.json
index 4e0c7e331d..f1e6282fa0 100644
--- a/backend/lang/vi.json
+++ b/backend/lang/vi.json
@@ -596,5 +596,35 @@
"Click the button below to view your tickets and order details.": "",
"View My Tickets": "",
"This link will expire in 24 hours.": "",
- "If you did not request this, please ignore this email.": ""
+ "If you did not request this, please ignore this email.": "",
+ "You have reached your daily message limit. Please try again later or contact support to increase your limits.": "",
+ "The number of recipients exceeds your account limit. Please contact support to increase your limits.": "",
+ "Your account tier does not allow links in messages. Please contact support to enable this feature.": "",
+ "Deleted :count failed jobs": "",
+ "Failed job not found": "",
+ "Queued :count jobs for retry": "",
+ "Job queued for retry": "",
+ "Message approved and queued for sending": "",
+ "Promo code not found": "",
+ "Question not found": "",
+ "[Action Required] Message Pending Review - :subject": "",
+ "Message not found": "",
+ "Message must be in pending review status to be approved": "",
+ "The :attribute must be a valid URL.": "",
+ "The :attribute must use http or https protocol.": "",
+ "The :attribute cannot point to localhost or internal addresses.": "",
+ "The :attribute cannot use reserved domain names.": "",
+ "The :attribute cannot point to cloud metadata endpoints.": "",
+ "The :attribute cannot point to private or internal IP addresses.": "",
+ "A message has been flagged for review due to eligibility check failures.": "",
+ "Message Details": "",
+ "Subject": "",
+ "Account": "",
+ "Event": "",
+ "Message ID": "",
+ "Eligibility Failures": "",
+ "Stripe payment account not connected": "",
+ "No completed paid orders on this account": "",
+ "Event was created less than 24 hours ago": "",
+ "Review Message": ""
}
\ No newline at end of file
diff --git a/backend/lang/zh-cn.json b/backend/lang/zh-cn.json
index 25033d3e4f..3d68375abf 100644
--- a/backend/lang/zh-cn.json
+++ b/backend/lang/zh-cn.json
@@ -632,5 +632,35 @@
"Click the button below to view your tickets and order details.": "",
"View My Tickets": "",
"This link will expire in 24 hours.": "",
- "If you did not request this, please ignore this email.": ""
+ "If you did not request this, please ignore this email.": "",
+ "You have reached your daily message limit. Please try again later or contact support to increase your limits.": "",
+ "The number of recipients exceeds your account limit. Please contact support to increase your limits.": "",
+ "Your account tier does not allow links in messages. Please contact support to enable this feature.": "",
+ "Deleted :count failed jobs": "",
+ "Failed job not found": "",
+ "Queued :count jobs for retry": "",
+ "Job queued for retry": "",
+ "Message approved and queued for sending": "",
+ "Promo code not found": "",
+ "Question not found": "",
+ "[Action Required] Message Pending Review - :subject": "",
+ "Message not found": "",
+ "Message must be in pending review status to be approved": "",
+ "The :attribute must be a valid URL.": "",
+ "The :attribute must use http or https protocol.": "",
+ "The :attribute cannot point to localhost or internal addresses.": "",
+ "The :attribute cannot use reserved domain names.": "",
+ "The :attribute cannot point to cloud metadata endpoints.": "",
+ "The :attribute cannot point to private or internal IP addresses.": "",
+ "A message has been flagged for review due to eligibility check failures.": "",
+ "Message Details": "",
+ "Subject": "",
+ "Account": "",
+ "Event": "",
+ "Message ID": "",
+ "Eligibility Failures": "",
+ "Stripe payment account not connected": "",
+ "No completed paid orders on this account": "",
+ "Event was created less than 24 hours ago": "",
+ "Review Message": ""
}
\ No newline at end of file
diff --git a/backend/lang/zh-hk.json b/backend/lang/zh-hk.json
index 9a25b6a777..41bff342e0 100644
--- a/backend/lang/zh-hk.json
+++ b/backend/lang/zh-hk.json
@@ -632,5 +632,35 @@
"Click the button below to view your tickets and order details.": "",
"View My Tickets": "",
"This link will expire in 24 hours.": "",
- "If you did not request this, please ignore this email.": ""
+ "If you did not request this, please ignore this email.": "",
+ "You have reached your daily message limit. Please try again later or contact support to increase your limits.": "",
+ "The number of recipients exceeds your account limit. Please contact support to increase your limits.": "",
+ "Your account tier does not allow links in messages. Please contact support to enable this feature.": "",
+ "Deleted :count failed jobs": "",
+ "Failed job not found": "",
+ "Queued :count jobs for retry": "",
+ "Job queued for retry": "",
+ "Message approved and queued for sending": "",
+ "Promo code not found": "",
+ "Question not found": "",
+ "[Action Required] Message Pending Review - :subject": "",
+ "Message not found": "",
+ "Message must be in pending review status to be approved": "",
+ "The :attribute must be a valid URL.": "",
+ "The :attribute must use http or https protocol.": "",
+ "The :attribute cannot point to localhost or internal addresses.": "",
+ "The :attribute cannot use reserved domain names.": "",
+ "The :attribute cannot point to cloud metadata endpoints.": "",
+ "The :attribute cannot point to private or internal IP addresses.": "",
+ "A message has been flagged for review due to eligibility check failures.": "",
+ "Message Details": "",
+ "Subject": "",
+ "Account": "",
+ "Event": "",
+ "Message ID": "",
+ "Eligibility Failures": "",
+ "Stripe payment account not connected": "",
+ "No completed paid orders on this account": "",
+ "Event was created less than 24 hours ago": "",
+ "Review Message": ""
}
\ No newline at end of file
diff --git a/backend/resources/views/emails/admin/message-pending-review.blade.php b/backend/resources/views/emails/admin/message-pending-review.blade.php
new file mode 100644
index 0000000000..b8ddad0696
--- /dev/null
+++ b/backend/resources/views/emails/admin/message-pending-review.blade.php
@@ -0,0 +1,39 @@
+@php /** @var \HiEvents\DomainObjects\MessageDomainObject $message */ @endphp
+@php /** @var \HiEvents\DomainObjects\EventDomainObject $event */ @endphp
+@php /** @var \HiEvents\DomainObjects\AccountDomainObject $account */ @endphp
+@php /** @var array $failures */ @endphp
+@php /** @var string $reviewUrl */ @endphp
+
+
Test
')->andReturn('Test
'); $attendee = new AttendeeDomainObject(); @@ -160,6 +168,7 @@ public function testHandleCreatesMessageAndDispatchesJob(): void $message->shouldReceive('getOrderId')->andReturn(5); $message->shouldReceive('getAttendeeIds')->andReturn([10]); $message->shouldReceive('getProductIds')->andReturn([20]); + $message->shouldReceive('getStatus')->andReturn('PROCESSING'); $this->messageRepository->shouldReceive('create')->andReturn($message); diff --git a/frontend/src/api/admin.client.ts b/frontend/src/api/admin.client.ts index d69d396229..e419b4e4f0 100644 --- a/frontend/src/api/admin.client.ts +++ b/frontend/src/api/admin.client.ts @@ -20,6 +20,14 @@ export interface AdminAccountUser { role: string; } +export interface AccountMessagingTier { + id: number; + name: string; + max_messages_per_24h: number; + max_recipients_per_message: number; + links_allowed: boolean; +} + export interface AdminAccount { id: IdParam; name: string; @@ -30,6 +38,10 @@ export interface AdminAccount { events_count: number; users_count: number; users: AdminAccountUser[]; + messaging_tier?: { + id: number; + name: string; + }; } export interface AccountConfiguration { @@ -79,6 +91,7 @@ export interface AccountVatSetting { export interface AdminAccountDetail extends AdminAccount { configuration?: AccountConfiguration; vat_setting?: AccountVatSetting; + messaging_tier?: AccountMessagingTier; } @@ -306,6 +319,7 @@ export interface AdminMessage { sent_by: string; sent_at: string | null; created_at: string; + eligibility_failures?: string[]; } export interface GetAllAdminMessagesParams { @@ -531,4 +545,21 @@ export const adminClient = { }); return response.data; }, + + approveMessage: async (messageId: IdParam) => { + const response = await api.post(`admin/messages/${messageId}/approve`); + return response.data; + }, + + updateAccountMessagingTier: async (accountId: IdParam, tierId: number) => { + const response = await api.put(`admin/accounts/${accountId}/messaging-tier`, { + messaging_tier_id: tierId + }); + return response.data; + }, + + getMessagingTiers: async (): Promise