diff --git a/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php b/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php index 995a4f3..ea021c9 100644 --- a/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php +++ b/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php @@ -2,6 +2,7 @@ namespace App\Actions\Notifications; +use App\Enums\DiscordAllowedMentionType; use App\Enums\NotificationChannel; use App\Enums\NotificationEventType; use App\Http\Resources\NotificationActionResource; @@ -22,6 +23,7 @@ class BuildProjectNotificationSettingsPayload return [ 'channelOptions' => NotificationChannel::options(), 'eventTypeOptions' => NotificationEventType::options(), + 'discordAllowedMentionOptions' => DiscordAllowedMentionType::options(), 'notificationActions' => NotificationActionResource::collection( $this->listProjectNotificationActions->handle($project), )->resolve(), diff --git a/app/Actions/Notifications/DeliverNotificationAction.php b/app/Actions/Notifications/DeliverNotificationAction.php index 8cdaef5..37bbf75 100644 --- a/app/Actions/Notifications/DeliverNotificationAction.php +++ b/app/Actions/Notifications/DeliverNotificationAction.php @@ -2,6 +2,7 @@ namespace App\Actions\Notifications; +use App\Enums\DiscordAllowedMentionType; use App\Enums\NotificationChannel; use App\Enums\NotificationDeliveryStatus; use App\Models\NotificationAction; @@ -280,11 +281,53 @@ class DeliverNotificationAction implements ShouldQueue ->post($notificationAction->delivery_url, [ 'content' => $message, 'allowed_mentions' => [ - 'parse' => [], + 'parse' => $this->discordAllowedMentions($notificationAction), ], ]); } + /** + * @return list + */ + private function discordAllowedMentions(NotificationAction $notificationAction): array + { + $configuredAllowedMentions = data_get( + $notificationAction->channel_configuration, + 'allowed_mentions', + [], + ); + + if (! is_array($configuredAllowedMentions)) { + return []; + } + + $allowedMentions = []; + + foreach ($configuredAllowedMentions as $allowedMention) { + if (! is_string($allowedMention)) { + continue; + } + + $allowedMention = Str::lower(trim($allowedMention)); + + if ($allowedMention === '') { + continue; + } + + if (! in_array($allowedMention, DiscordAllowedMentionType::values(), true)) { + continue; + } + + if (in_array($allowedMention, $allowedMentions, true)) { + continue; + } + + $allowedMentions[] = $allowedMention; + } + + return $allowedMentions; + } + private function sendWebhookRequest(NotificationAction $notificationAction): Response { return Http::acceptJson() diff --git a/app/Enums/DiscordAllowedMentionType.php b/app/Enums/DiscordAllowedMentionType.php new file mode 100644 index 0000000..50ea956 --- /dev/null +++ b/app/Enums/DiscordAllowedMentionType.php @@ -0,0 +1,45 @@ + '@everyone and @here', + self::Roles => 'Role mentions (@role)', + self::Users => 'User mentions (@username)', + }; + } + + /** + * @return array + */ + public static function options(): array + { + return array_map( + fn (self $eventType): array => [ + 'label' => $eventType->label(), + 'value' => $eventType->value, + ], + self::cases(), + ); + } + + /** + * @param array|null $discordAllowedMentionTypes + * @return array + */ + public static function values(?array $discordAllowedMentionTypes = null): array + { + return array_map( + fn (self $discordAllowedMentionType): string => $discordAllowedMentionType->value, + $discordAllowedMentionTypes ?? self::cases(), + ); + } +} diff --git a/app/Enums/NotificationChannel.php b/app/Enums/NotificationChannel.php index a869ce7..902f647 100644 --- a/app/Enums/NotificationChannel.php +++ b/app/Enums/NotificationChannel.php @@ -28,4 +28,16 @@ enum NotificationChannel: string self::cases(), ); } + + /** + * @param array|null $notificationChannels + * @return array + */ + public static function values(?array $notificationChannels = null): array + { + return array_map( + fn (self $notificationChannel): string => $notificationChannel->value, + $notificationChannels ?? self::cases(), + ); + } } diff --git a/app/Enums/NotificationEventType.php b/app/Enums/NotificationEventType.php index c9e120b..a97e52d 100644 --- a/app/Enums/NotificationEventType.php +++ b/app/Enums/NotificationEventType.php @@ -26,4 +26,16 @@ enum NotificationEventType: string self::cases(), ); } + + /** + * @param array|null $notificationEventTypes + * @return array + */ + public static function values(?array $notificationEventTypes = null): array + { + return array_map( + fn (self $notificationEventType): string => $notificationEventType->value, + $notificationEventTypes ?? self::cases(), + ); + } } diff --git a/app/Http/Requests/StoreProjectNotificationActionRequest.php b/app/Http/Requests/StoreProjectNotificationActionRequest.php index ed7f636..7c050dd 100644 --- a/app/Http/Requests/StoreProjectNotificationActionRequest.php +++ b/app/Http/Requests/StoreProjectNotificationActionRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests; +use App\Enums\DiscordAllowedMentionType; use App\Enums\NotificationChannel; use App\Enums\NotificationEventType; use Illuminate\Contracts\Validation\ValidationRule; @@ -25,47 +26,20 @@ class StoreProjectNotificationActionRequest extends FormRequest */ public function rules(): array { + $messageTemplateRules = $this->input('channel') === NotificationChannel::Discord->value + ? ['required', 'string', 'max:2000'] + : ['nullable', 'string', 'max:2000']; + return [ 'name' => ['required', 'string', 'max:255'], - 'event_type' => ['required', 'string', Rule::in($this->eventTypes())], - 'channel' => ['required', 'string', Rule::in($this->channels())], + 'event_type' => ['required', 'string', Rule::in(NotificationEventType::values())], + 'channel' => ['required', 'string', Rule::in(NotificationChannel::values())], + 'channel_configuration' => ['nullable', 'array'], + 'channel_configuration.allowed_mentions' => ['nullable', 'array'], + 'channel_configuration.allowed_mentions.*' => ['nullable', 'string', Rule::in(DiscordAllowedMentionType::values())], 'enabled' => ['required', 'boolean'], 'delivery_url' => ['required', 'string', 'max:2048', 'url'], - 'message_template' => $this->messageTemplateRules(), + 'message_template' => $messageTemplateRules, ]; } - - /** - * @return array - */ - private function channels(): array - { - return array_map( - static fn (NotificationChannel $channel): string => $channel->value, - NotificationChannel::cases(), - ); - } - - /** - * @return array - */ - private function eventTypes(): array - { - return array_map( - static fn (NotificationEventType $eventType): string => $eventType->value, - NotificationEventType::cases(), - ); - } - - /** - * @return array - */ - private function messageTemplateRules(): array - { - if ($this->input('channel') === NotificationChannel::Discord->value) { - return ['required', 'string', 'max:2000']; - } - - return ['nullable', 'string', 'max:2000']; - } } diff --git a/app/Http/Requests/UpdateProjectNotificationActionRequest.php b/app/Http/Requests/UpdateProjectNotificationActionRequest.php index 0e281bf..36fd39b 100644 --- a/app/Http/Requests/UpdateProjectNotificationActionRequest.php +++ b/app/Http/Requests/UpdateProjectNotificationActionRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests; +use App\Enums\DiscordAllowedMentionType; use App\Enums\NotificationChannel; use App\Enums\NotificationEventType; use Illuminate\Contracts\Validation\ValidationRule; @@ -25,47 +26,20 @@ class UpdateProjectNotificationActionRequest extends FormRequest */ public function rules(): array { + $messageTemplateRules = $this->input('channel') === NotificationChannel::Discord->value + ? ['required', 'string', 'max:2000'] + : ['nullable', 'string', 'max:2000']; + return [ 'name' => ['required', 'string', 'max:255'], - 'event_type' => ['required', 'string', Rule::in($this->eventTypes())], - 'channel' => ['required', 'string', Rule::in($this->channels())], + 'event_type' => ['required', 'string', Rule::in(NotificationEventType::values())], + 'channel' => ['required', 'string', Rule::in(NotificationChannel::values())], + 'channel_configuration' => ['nullable', 'array'], + 'channel_configuration.allowed_mentions' => ['nullable', 'array'], + 'channel_configuration.allowed_mentions.*' => ['nullable', 'string', Rule::in(DiscordAllowedMentionType::values())], 'enabled' => ['required', 'boolean'], 'delivery_url' => ['nullable', 'string', 'max:2048', 'url'], - 'message_template' => $this->messageTemplateRules(), + 'message_template' => $messageTemplateRules, ]; } - - /** - * @return array - */ - private function channels(): array - { - return array_map( - static fn (NotificationChannel $channel): string => $channel->value, - NotificationChannel::cases(), - ); - } - - /** - * @return array - */ - private function eventTypes(): array - { - return array_map( - static fn (NotificationEventType $eventType): string => $eventType->value, - NotificationEventType::cases(), - ); - } - - /** - * @return array - */ - private function messageTemplateRules(): array - { - if ($this->input('channel') === NotificationChannel::Discord->value) { - return ['required', 'string', 'max:2000']; - } - - return ['nullable', 'string', 'max:2000']; - } } diff --git a/resources/js/pages/projects/settings/Notifications.vue b/resources/js/pages/projects/settings/Notifications.vue index 45fba9c..d415a48 100644 --- a/resources/js/pages/projects/settings/Notifications.vue +++ b/resources/js/pages/projects/settings/Notifications.vue @@ -67,6 +67,41 @@ props.notificationActions.forEach((notificationAction) => { const selectedChannel = (notificationAction: NotificationActionModel): string => editChannelState[notificationAction.id] ?? notificationAction.channel; +const notificationActionAllowedMentions = ( + notificationAction: NotificationActionModel, +): string[] => { + const allowedMentions = + notificationAction.channel_configuration['allowed_mentions']; + + if (!Array.isArray(allowedMentions)) { + return []; + } + + return allowedMentions + .filter( + (allowedMention): allowedMention is string => + typeof allowedMention === 'string', + ) + .map((allowedMention) => allowedMention.trim().toLowerCase()) + .filter((allowedMention) => allowedMention !== ''); +}; + +const allowedMentionsError = ( + errors: Record, +): string | undefined => { + const generalError = errors['channel_configuration.allowed_mentions']; + + if (generalError !== undefined) { + return generalError; + } + + const itemErrorKey = Object.keys(errors).find((key) => + key.startsWith('channel_configuration.allowed_mentions.'), + ); + + return itemErrorKey === undefined ? undefined : errors[itemErrorKey]; +}; + const notificationActionTimestamp = ( notificationAction: NotificationActionModel, ): string => { @@ -151,8 +186,8 @@ const notificationActionTimestamp = ( Create notification - Add a project-scoped notification action for - the supported ticket status change event. + Add a project-scoped notification action for the + supported ticket status change event. @@ -210,7 +245,9 @@ const notificationActionTimestamp = (
-
+ +
+ +
+ +
+

+ {{ + createChannelState.value === + 'discord' + ? 'Pick which Discord mention types are allowed. Leave all unchecked to keep mentions fully disabled.' + : 'Allowed mentions are only used when the channel is Discord.' + }} +

+
@@ -393,7 +480,11 @@ const notificationActionTimestamp = (
- {{ notificationActionTimestamp(notificationAction) }} + {{ + notificationActionTimestamp( + notificationAction, + ) + }} @@ -405,25 +496,19 @@ const notificationActionTimestamp = ( [project.id, notificationAction.id], ) " - :error-bag=" - `updateNotificationAction-${notificationAction.id}` - " + :error-bag="`updateNotificationAction-${notificationAction.id}`" class="space-y-4" v-slot="{ errors, processing }" >
+ +
+ +
+

+ {{ + selectedChannel( + notificationAction, + ) === 'discord' + ? 'Pick which Discord mention types are allowed. Leave all unchecked to keep mentions fully disabled.' + : 'Allowed mentions are only used when the channel is Discord.' + }} +

+ +
+