From b80c7ab99d4623bfed588694da7f96086f9a94d8 Mon Sep 17 00:00:00 2001 From: Dave Earley Date: Tue, 30 Dec 2025 17:40:30 +0000 Subject: [PATCH 1/2] Feature: Messaging tiers with eligibility checks and admin approval workflow (#986) --- .../app/DomainObjects/AccountDomainObject.php | 12 ++ .../AccountMessagingTierDomainObject.php | 7 + .../Enums/MessagingEligibilityFailureEnum.php | 10 ++ .../Enums/MessagingTierViolationEnum.php | 19 +++ .../Generated/AccountDomainObjectAbstract.php | 14 ++ ...countMessagingTierDomainObjectAbstract.php | 132 ++++++++++++++++++ .../Generated/MessageDomainObjectAbstract.php | 14 ++ .../DomainObjects/Status/MessageStatus.php | 1 + .../MessagingTierLimitExceededException.php | 22 +++ .../UpdateAccountMessagingTierAction.php | 37 +++++ .../Actions/Admin/GetMessagingTiersAction.php | 31 ++++ .../Admin/Messages/ApproveMessageAction.php | 29 ++++ .../Actions/Messages/SendMessageAction.php | 3 + .../Admin/AccountMessagingTierResource.php | 26 ++++ .../Resources/Admin/AdminMessageResource.php | 1 + .../Jobs/Message/MessagePendingReviewJob.php | 56 ++++++++ .../Mail/Admin/MessagePendingReviewMail.php | 48 +++++++ backend/app/Models/Account.php | 5 + backend/app/Models/AccountMessagingTier.php | 35 +++++ backend/app/Models/Message.php | 1 + .../Providers/RepositoryServiceProvider.php | 3 + .../AccountMessagingTierRepository.php | 22 +++ .../Repository/Eloquent/AccountRepository.php | 12 +- .../Repository/Eloquent/MessageRepository.php | 19 +++ .../Repository/Eloquent/OrderRepository.php | 31 ++++ ...ccountMessagingTierRepositoryInterface.php | 14 ++ .../Interfaces/MessageRepositoryInterface.php | 2 + .../Interfaces/OrderRepositoryInterface.php | 4 + .../Account/AdminAccountDetailResource.php | 13 +- .../Account/AdminAccountResource.php | 4 + .../Handlers/Admin/ApproveMessageHandler.php | 72 ++++++++++ .../UpdateAccountMessagingTierHandler.php | 23 +++ .../Handlers/Message/DTO/SendMessageDTO.php | 2 +- .../Handlers/Message/SendMessageHandler.php | 104 ++++++++++---- .../DTO/MessagingEligibilityFailureDTO.php | 29 ++++ .../Message/DTO/MessagingTierViolationDTO.php | 26 ++++ .../Message/MessagingEligibilityService.php | 127 +++++++++++++++++ ...7_create_account_messaging_tiers_table.php | 54 +++++++ ...eligibility_failures_to_messages_table.php | 22 +++ ...9_add_messaging_tier_to_accounts_table.php | 45 ++++++ backend/lang/de.json | 32 ++++- backend/lang/es.json | 32 ++++- backend/lang/fr.json | 32 ++++- backend/lang/hu.json | 32 ++++- backend/lang/it.json | 32 ++++- backend/lang/nl.json | 32 ++++- backend/lang/pt-br.json | 32 ++++- backend/lang/pt.json | 32 ++++- backend/lang/ru.json | 32 ++++- backend/lang/tr.json | 32 ++++- backend/lang/vi.json | 32 ++++- backend/lang/zh-cn.json | 32 ++++- backend/lang/zh-hk.json | 32 ++++- .../admin/message-pending-review.blade.php | 39 ++++++ backend/routes/api.php | 8 ++ .../Message/SendMessageHandlerTest.php | 11 +- frontend/src/api/admin.client.ts | 31 ++++ .../common/AdminAccountsTable/index.tsx | 22 ++- .../modals/SendMessageModal/index.tsx | 46 +++++- .../admin/Accounts/AccountDetail/index.tsx | 81 +++++++++++ .../routes/admin/Messages/index.tsx | 76 ++++++++-- frontend/src/mutations/useApproveMessage.ts | 15 ++ .../useUpdateAccountMessagingTier.ts | 19 +++ frontend/src/queries/useGetAdminAccount.ts | 4 +- frontend/src/queries/useGetAllAccounts.ts | 4 +- frontend/src/queries/useGetMessagingTiers.ts | 11 ++ 66 files changed, 1849 insertions(+), 65 deletions(-) create mode 100644 backend/app/DomainObjects/AccountMessagingTierDomainObject.php create mode 100644 backend/app/DomainObjects/Enums/MessagingEligibilityFailureEnum.php create mode 100644 backend/app/DomainObjects/Enums/MessagingTierViolationEnum.php create mode 100644 backend/app/DomainObjects/Generated/AccountMessagingTierDomainObjectAbstract.php create mode 100644 backend/app/Exceptions/MessagingTierLimitExceededException.php create mode 100644 backend/app/Http/Actions/Admin/Accounts/UpdateAccountMessagingTierAction.php create mode 100644 backend/app/Http/Actions/Admin/GetMessagingTiersAction.php create mode 100644 backend/app/Http/Actions/Admin/Messages/ApproveMessageAction.php create mode 100644 backend/app/Http/Resources/Admin/AccountMessagingTierResource.php create mode 100644 backend/app/Jobs/Message/MessagePendingReviewJob.php create mode 100644 backend/app/Mail/Admin/MessagePendingReviewMail.php create mode 100644 backend/app/Models/AccountMessagingTier.php create mode 100644 backend/app/Repository/Eloquent/AccountMessagingTierRepository.php create mode 100644 backend/app/Repository/Interfaces/AccountMessagingTierRepositoryInterface.php create mode 100644 backend/app/Services/Application/Handlers/Admin/ApproveMessageHandler.php create mode 100644 backend/app/Services/Application/Handlers/Admin/UpdateAccountMessagingTierHandler.php create mode 100644 backend/app/Services/Domain/Message/DTO/MessagingEligibilityFailureDTO.php create mode 100644 backend/app/Services/Domain/Message/DTO/MessagingTierViolationDTO.php create mode 100644 backend/app/Services/Domain/Message/MessagingEligibilityService.php create mode 100644 backend/database/migrations/2025_12_30_095747_create_account_messaging_tiers_table.php create mode 100644 backend/database/migrations/2025_12_30_095748_add_eligibility_failures_to_messages_table.php create mode 100644 backend/database/migrations/2025_12_30_095749_add_messaging_tier_to_accounts_table.php create mode 100644 backend/resources/views/emails/admin/message-pending-review.blade.php create mode 100644 frontend/src/mutations/useApproveMessage.ts create mode 100644 frontend/src/mutations/useUpdateAccountMessagingTier.ts create mode 100644 frontend/src/queries/useGetMessagingTiers.ts 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/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 + + +{{ __('A message has been flagged for review due to eligibility check failures.') }} + +## {{ __('Message Details') }} + +**{{ __('Subject') }}:** {{ $message->getSubject() }} + +**{{ __('Account') }}:** {{ $account->getName() }} (ID: {{ $account->getId() }}) + +**{{ __('Event') }}:** {{ $event->getTitle() }} (ID: {{ $event->getId() }}) + +**{{ __('Message ID') }}:** {{ $message->getId() }} + +## {{ __('Eligibility Failures') }} + +@foreach($failures as $failure) +@php + $failureLabels = [ + 'stripe_not_connected' => __('Stripe payment account not connected'), + 'no_paid_orders' => __('No completed paid orders on this account'), + 'event_too_new' => __('Event was created less than 24 hours ago'), + ]; +@endphp +- {{ $failureLabels[$failure] ?? $failure }} +@endforeach + + +{{ __('Review Message') }} + + +{{ __('Thank you') }} + + diff --git a/backend/routes/api.php b/backend/routes/api.php index d5c8dff407..c84c87fe99 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -176,7 +176,10 @@ use HiEvents\Http\Actions\Admin\FailedJobs\GetAllFailedJobsAction; use HiEvents\Http\Actions\Admin\FailedJobs\RetryAllFailedJobsAction; use HiEvents\Http\Actions\Admin\FailedJobs\RetryFailedJobAction; +use HiEvents\Http\Actions\Admin\Messages\ApproveMessageAction; use HiEvents\Http\Actions\Admin\Messages\GetAllMessagesAction as GetAllAdminMessagesAction; +use HiEvents\Http\Actions\Admin\GetMessagingTiersAction; +use HiEvents\Http\Actions\Admin\Accounts\UpdateAccountMessagingTierAction; use HiEvents\Http\Actions\Admin\Orders\GetAllOrdersAction; use HiEvents\Http\Actions\Admin\Attribution\GetUtmAttributionStatsAction; use HiEvents\Http\Actions\Admin\Stats\GetAdminDashboardDataAction; @@ -434,6 +437,11 @@ function (Router $router): void { // Messages $router->get('/messages', GetAllAdminMessagesAction::class); + $router->post('/messages/{message_id}/approve', ApproveMessageAction::class); + + // Messaging Tiers + $router->get('/messaging-tiers', GetMessagingTiersAction::class); + $router->put('/accounts/{account_id}/messaging-tier', UpdateAccountMessagingTierAction::class); } ); diff --git a/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerTest.php index 6205ec9b59..8376eb8381 100644 --- a/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerTest.php @@ -18,6 +18,7 @@ use HiEvents\Repository\Interfaces\ProductRepositoryInterface; use HiEvents\Services\Application\Handlers\Message\DTO\SendMessageDTO; use HiEvents\Services\Application\Handlers\Message\SendMessageHandler; +use HiEvents\Services\Domain\Message\MessagingEligibilityService; use HiEvents\Services\Infrastructure\HtmlPurifier\HtmlPurifierService; use Illuminate\Config\Repository; use Illuminate\Support\Facades\Bus; @@ -33,6 +34,7 @@ class SendMessageHandlerTest extends TestCase private AccountRepositoryInterface $accountRepository; private HtmlPurifierService $purifier; private Repository $config; + private MessagingEligibilityService $eligibilityService; private SendMessageHandler $handler; @@ -47,6 +49,7 @@ protected function setUp(): void $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, @@ -55,7 +58,8 @@ protected function setUp(): void $this->messageRepository, $this->accountRepository, $this->purifier, - $this->config + $this->config, + $this->eligibilityService ); } @@ -140,6 +144,10 @@ public function testHandleCreatesMessageAndDispatchesJob(): void $this->accountRepository->shouldReceive('findById')->with(1)->andReturn($account); $this->config->shouldReceive('get')->with('app.saas_mode_enabled')->andReturn(false); + // Mock eligibility checks to pass (return null = no violations) + $this->eligibilityService->shouldReceive('checkTierLimits')->andReturn(null); + $this->eligibilityService->shouldReceive('checkEligibility')->andReturn(null); + $this->purifier->shouldReceive('purify')->with('

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> => { + const response = await api.get>('admin/messaging-tiers'); + return response.data; + }, }; diff --git a/frontend/src/components/common/AdminAccountsTable/index.tsx b/frontend/src/components/common/AdminAccountsTable/index.tsx index 1b358a9a9a..ccfa3daf02 100644 --- a/frontend/src/components/common/AdminAccountsTable/index.tsx +++ b/frontend/src/components/common/AdminAccountsTable/index.tsx @@ -1,7 +1,7 @@ import {Badge, Button, Stack, Text} from "@mantine/core"; import {t} from "@lingui/macro"; import {AdminAccount} from "../../../api/admin.client"; -import {IconCalendar, IconWorld, IconBuildingBank, IconUsers, IconEye} from "@tabler/icons-react"; +import {IconCalendar, IconWorld, IconBuildingBank, IconUsers, IconEye, IconMessage} from "@tabler/icons-react"; import classes from "./AdminAccountsTable.module.scss"; import {IdParam} from "../../../types"; import {useNavigate} from "react-router"; @@ -46,6 +46,14 @@ const AdminAccountsTable = ({accounts, onImpersonate, isLoading}: AdminAccountsT return role !== 'SUPERADMIN'; }; + const getTierBadgeColor = (tierName?: string) => { + if (!tierName) return 'gray'; + const name = tierName.toLowerCase(); + if (name.includes('premium')) return 'green'; + if (name.includes('trusted')) return 'blue'; + return 'gray'; + }; + return (
@@ -82,6 +90,18 @@ const AdminAccountsTable = ({accounts, onImpersonate, isLoading}: AdminAccountsT {account.users_count}
+
+ + + {t`Messaging Tier`} + + {account.messaging_tier?.name || t`Untrusted`} + + +
{account.users && account.users.length > 0 && ( diff --git a/frontend/src/components/modals/SendMessageModal/index.tsx b/frontend/src/components/modals/SendMessageModal/index.tsx index 01bdace2df..e7bd77e58b 100644 --- a/frontend/src/components/modals/SendMessageModal/index.tsx +++ b/frontend/src/components/modals/SendMessageModal/index.tsx @@ -4,7 +4,7 @@ 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, IconSend, IconTestPipe} from "@tabler/icons-react"; +import {IconAlertCircle, IconCheck, IconChevronDown, 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,9 +13,10 @@ import {t} from "@lingui/macro"; import {Editor} from "../../common/Editor"; import {useSendEventMessage} from "../../../mutations/useSendEventMessage.ts"; import {ProductSelector} from "../../common/ProductSelector"; -import {useEffect} from "react"; +import {useEffect, useState} from "react"; import {useGetAccount} from "../../../queries/useGetAccount.ts"; import {StripeConnectButton} from "../../common/StripeConnectButton"; +import {getConfig} from "../../../utilites/config"; import classes from "./SendMessageModal.module.scss"; interface EventMessageModalProps extends GenericModalProps { @@ -87,6 +88,8 @@ export const SendMessageModal = (props: EventMessageModalProps) => { const isAccountVerified = isAccountFetched && account?.is_account_email_confirmed; const accountRequiresManualVerification = isAccountFetched && account?.requires_manual_verification; const formIsDisabled = !isAccountVerified || accountRequiresManualVerification; + const supportEmail = getConfig('VITE_PLATFORM_SUPPORT_EMAIL'); + const [tierLimitError, setTierLimitError] = useState(null); const sendMessageMutation = useSendEventMessage(); @@ -110,6 +113,7 @@ export const SendMessageModal = (props: EventMessageModalProps) => { }); const handleSend = (values: any) => { + setTierLimitError(null); sendMessageMutation.mutate({ eventId: eventId, messageData: values, @@ -119,7 +123,14 @@ export const SendMessageModal = (props: EventMessageModalProps) => { form.reset(); onClose(); }, - onError: (error: any) => errorHandler(form, error) + onError: (error: any) => { + if (error?.response?.status === 429) { + const message = error?.response?.data?.message || t`You have reached your messaging limit.`; + setTierLimitError(message); + } else { + errorHandler(form, error); + } + } }); } @@ -162,6 +173,35 @@ export const SendMessageModal = (props: EventMessageModalProps) => { )} + {tierLimitError && ( + } + mb="md" + > + {tierLimitError} + {supportEmail && ( + <> + {' '}{t`To increase your limits, contact us at`}{' '} + {supportEmail} + + )} + + )} + + {!formIsDisabled && !tierLimitError && supportEmail && ( + } + mb="md" + > + {t`Your account has messaging limits. To increase your limits, contact us at`}{' '} + {supportEmail} + + )} + {!formIsDisabled && (
diff --git a/frontend/src/components/routes/admin/Accounts/AccountDetail/index.tsx b/frontend/src/components/routes/admin/Accounts/AccountDetail/index.tsx index 1a4cd05d43..e72277d89b 100644 --- a/frontend/src/components/routes/admin/Accounts/AccountDetail/index.tsx +++ b/frontend/src/components/routes/admin/Accounts/AccountDetail/index.tsx @@ -3,7 +3,9 @@ import {t} from "@lingui/macro"; import {useParams, useNavigate} from "react-router"; import {useGetAdminAccount} from "../../../../../queries/useGetAdminAccount"; import {useGetAllConfigurations} from "../../../../../queries/useGetAllConfigurations"; +import {useGetMessagingTiers} from "../../../../../queries/useGetMessagingTiers"; import {useAssignConfiguration} from "../../../../../mutations/useAssignConfiguration"; +import {useUpdateAccountMessagingTier} from "../../../../../mutations/useUpdateAccountMessagingTier"; import {IconArrowLeft, IconCalendar, IconWorld, IconEdit, IconBuildingBank, IconUsers} from "@tabler/icons-react"; import {useState} from "react"; import {EditAccountVatSettingsModal} from "../../../../modals/EditAccountVatSettingsModal"; @@ -15,11 +17,14 @@ const AccountDetail = () => { const navigate = useNavigate(); const {data: accountData, isLoading} = useGetAdminAccount(accountId); const {data: configurationsData} = useGetAllConfigurations(); + const {data: messagingTiersData} = useGetMessagingTiers(); const assignConfigMutation = useAssignConfiguration(accountId!); + const updateTierMutation = useUpdateAccountMessagingTier(accountId!); const [showVatModal, setShowVatModal] = useState(false); const account = accountData?.data; const configurations = configurationsData?.data || []; + const messagingTiers = messagingTiersData?.data || []; const formatDate = (dateString?: string) => { if (!dateString) return '-'; @@ -39,6 +44,29 @@ const AccountDetail = () => { ); }; + const handleMessagingTierChange = (value: string | null) => { + if (!value) return; + + updateTierMutation.mutate(parseInt(value, 10), { + onSuccess: () => showSuccess(t`Messaging tier updated successfully`), + onError: () => showError(t`Failed to update messaging tier`), + }); + }; + + const getTierBadgeColor = (tierName?: string) => { + if (!tierName) return 'gray'; + const name = tierName.toLowerCase(); + if (name.includes('premium')) return 'green'; + if (name.includes('trusted')) return 'blue'; + return 'gray'; + }; + + const getSelectedTierDetails = () => { + const selectedTierId = account?.messaging_tier?.id; + if (!selectedTierId) return null; + return messagingTiers.find(tier => tier.id === selectedTierId); + }; + if (isLoading) { return ( @@ -65,6 +93,13 @@ const AccountDetail = () => { label: config.is_system_default ? `${config.name} (${t`Default`})` : config.name, })); + const tierOptions = messagingTiers.map((tier) => ({ + value: String(tier.id), + label: tier.name, + })); + + const selectedTierDetails = getSelectedTierDetails(); + return ( <> @@ -172,6 +207,52 @@ const AccountDetail = () => { + + + + {t`Messaging Tier`} + {account.messaging_tier && ( + + {account.messaging_tier.name} + + )} + + +