Merge branch 'main' into koda/ticket-create-notification-action
linter / quality (pull_request) Successful in 1m19s
tests / ci (8.4) (pull_request) Successful in 1m37s

# Conflicts:
#	resources/js/pages/projects/settings/Notifications.vue
This commit is contained in:
2026-04-25 20:35:38 +02:00
11 changed files with 372 additions and 124 deletions
@@ -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(),
@@ -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<string>
*/
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()
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Enums;
enum DiscordAllowedMentionType: string
{
case Everyone = 'everyone';
case Roles = 'roles';
case Users = 'users';
public function label(): string
{
return match ($this) {
self::Everyone => '@everyone and @here',
self::Roles => 'Role mentions (@role)',
self::Users => 'User mentions (@username)',
};
}
/**
* @return array<int, array{label: string, value: string}>
*/
public static function options(): array
{
return array_map(
fn (self $eventType): array => [
'label' => $eventType->label(),
'value' => $eventType->value,
],
self::cases(),
);
}
/**
* @param array<int, self>|null $discordAllowedMentionTypes
* @return array<int, string>
*/
public static function values(?array $discordAllowedMentionTypes = null): array
{
return array_map(
fn (self $discordAllowedMentionType): string => $discordAllowedMentionType->value,
$discordAllowedMentionTypes ?? self::cases(),
);
}
}
+12
View File
@@ -28,4 +28,16 @@ enum NotificationChannel: string
self::cases(),
);
}
/**
* @param array<int, self>|null $notificationChannels
* @return array<int, string>
*/
public static function values(?array $notificationChannels = null): array
{
return array_map(
fn (self $notificationChannel): string => $notificationChannel->value,
$notificationChannels ?? self::cases(),
);
}
}
+12
View File
@@ -28,4 +28,16 @@ enum NotificationEventType: string
self::cases(),
);
}
/**
* @param array<int, self>|null $notificationEventTypes
* @return array<int, string>
*/
public static function values(?array $notificationEventTypes = null): array
{
return array_map(
fn (self $notificationEventType): string => $notificationEventType->value,
$notificationEventTypes ?? self::cases(),
);
}
}
@@ -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<int, string>
*/
private function channels(): array
{
return array_map(
static fn (NotificationChannel $channel): string => $channel->value,
NotificationChannel::cases(),
);
}
/**
* @return array<int, string>
*/
private function eventTypes(): array
{
return array_map(
static fn (NotificationEventType $eventType): string => $eventType->value,
NotificationEventType::cases(),
);
}
/**
* @return array<int, ValidationRule|string>
*/
private function messageTemplateRules(): array
{
if ($this->input('channel') === NotificationChannel::Discord->value) {
return ['required', 'string', 'max:2000'];
}
return ['nullable', 'string', 'max:2000'];
}
}
@@ -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<int, string>
*/
private function channels(): array
{
return array_map(
static fn (NotificationChannel $channel): string => $channel->value,
NotificationChannel::cases(),
);
}
/**
* @return array<int, string>
*/
private function eventTypes(): array
{
return array_map(
static fn (NotificationEventType $eventType): string => $eventType->value,
NotificationEventType::cases(),
);
}
/**
* @return array<int, ValidationRule|string>
*/
private function messageTemplateRules(): array
{
if ($this->input('channel') === NotificationChannel::Discord->value) {
return ['required', 'string', 'max:2000'];
}
return ['nullable', 'string', 'max:2000'];
}
}
@@ -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, string>,
): 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 => {
@@ -210,7 +245,9 @@ const notificationActionTimestamp = (
</div>
<div class="grid gap-2">
<Label for="create-notification-channel">
<Label
for="create-notification-channel"
>
Channel
</Label>
<select
@@ -232,7 +269,9 @@ const notificationActionTimestamp = (
</div>
<div class="grid gap-2">
<Label for="create-notification-delivery-url">
<Label
for="create-notification-delivery-url"
>
Delivery URL
</Label>
<Input
@@ -246,7 +285,55 @@ const notificationActionTimestamp = (
never rendered back to the browser after
save.
</p>
<InputError :message="errors.delivery_url" />
<InputError
:message="errors.delivery_url"
/>
</div>
<div class="grid gap-2">
<Label>Allowed mentions</Label>
<div
:class="[
'grid gap-2',
createChannelState.value !==
'discord'
? 'hidden'
: '',
]"
>
<label
v-for="allowedMentionOption in discordAllowedMentionOptions"
:key="`create-allowed-mention-${allowedMentionOption.value}`"
class="border-border flex items-center gap-2 rounded-lg border px-3 py-2"
>
<input
type="checkbox"
name="channel_configuration[allowed_mentions][]"
:value="
allowedMentionOption.value
"
:disabled="
createChannelState.value !==
'discord'
"
class="border-input text-primary focus:ring-ring size-4 rounded"
/>
<span>
{{ allowedMentionOption.label }}
</span>
</label>
</div>
<p class="text-muted-foreground text-sm">
{{
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.'
}}
</p>
<InputError
:message="allowedMentionsError(errors)"
/>
</div>
<div class="grid gap-2">
@@ -393,7 +480,11 @@ const notificationActionTimestamp = (
</Badge>
</div>
<CardDescription>
{{ notificationActionTimestamp(notificationAction) }}
{{
notificationActionTimestamp(
notificationAction,
)
}}
</CardDescription>
</div>
</CardHeader>
@@ -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 }"
>
<div class="grid gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label
:for="
`notification-name-${notificationAction.id}`
"
:for="`notification-name-${notificationAction.id}`"
>
Name
</Label>
<Input
:id="
`notification-name-${notificationAction.id}`
"
:id="`notification-name-${notificationAction.id}`"
name="name"
:default-value="
notificationAction.name
@@ -434,16 +519,12 @@ const notificationActionTimestamp = (
<div class="grid gap-2">
<Label
:for="
`notification-delivery-url-${notificationAction.id}`
"
:for="`notification-delivery-url-${notificationAction.id}`"
>
Delivery URL
</Label>
<Input
:id="
`notification-delivery-url-${notificationAction.id}`
"
:id="`notification-delivery-url-${notificationAction.id}`"
name="delivery_url"
type="url"
:placeholder="
@@ -468,16 +549,12 @@ const notificationActionTimestamp = (
<div class="grid gap-2">
<Label
:for="
`notification-event-type-${notificationAction.id}`
"
:for="`notification-event-type-${notificationAction.id}`"
>
Event type
</Label>
<select
:id="
`notification-event-type-${notificationAction.id}`
"
:id="`notification-event-type-${notificationAction.id}`"
name="event_type"
:default-value="
notificationAction.event_type
@@ -499,16 +576,12 @@ const notificationActionTimestamp = (
<div class="grid gap-2">
<Label
:for="
`notification-channel-${notificationAction.id}`
"
:for="`notification-channel-${notificationAction.id}`"
>
Channel
</Label>
<select
:id="
`notification-channel-${notificationAction.id}`
"
:id="`notification-channel-${notificationAction.id}`"
name="channel"
v-model="
editChannelState[
@@ -529,18 +602,70 @@ const notificationActionTimestamp = (
</div>
</div>
<div class="grid gap-2">
<Label>Allowed mentions</Label>
<div
:class="[
'grid gap-2',
selectedChannel(
notificationAction,
) !== 'discord'
? 'hidden'
: '',
]"
>
<label
v-for="allowedMentionOption in discordAllowedMentionOptions"
:key="`notification-allowed-mention-${notificationAction.id}-${allowedMentionOption.value}`"
class="border-border flex items-center gap-2 rounded-lg border px-3 py-2"
>
<input
type="checkbox"
name="channel_configuration[allowed_mentions][]"
:value="
allowedMentionOption.value
"
:default-checked="
notificationActionAllowedMentions(
notificationAction,
).includes(
allowedMentionOption.value,
)
"
:disabled="
selectedChannel(
notificationAction,
) !== 'discord'
"
class="border-input text-primary focus:ring-ring size-4 rounded"
/>
<span>
{{ allowedMentionOption.label }}
</span>
</label>
</div>
<p class="text-muted-foreground text-sm">
{{
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.'
}}
</p>
<InputError
:message="allowedMentionsError(errors)"
/>
</div>
<div class="grid gap-2">
<Label
:for="
`notification-message-template-${notificationAction.id}`
"
:for="`notification-message-template-${notificationAction.id}`"
>
Discord message template
</Label>
<textarea
:id="
`notification-message-template-${notificationAction.id}`
"
:id="`notification-message-template-${notificationAction.id}`"
name="message_template"
rows="4"
:value="
@@ -548,13 +673,15 @@ const notificationActionTimestamp = (
''
"
:disabled="
selectedChannel(notificationAction) !==
'discord'
selectedChannel(
notificationAction,
) !== 'discord'
"
:class="[
'border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-h-28 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]',
selectedChannel(notificationAction) !==
'discord'
selectedChannel(
notificationAction,
) !== 'discord'
? 'hidden'
: 'block',
]"
@@ -562,8 +689,9 @@ const notificationActionTimestamp = (
/>
<p class="text-muted-foreground text-sm">
{{
selectedChannel(notificationAction) ===
'discord'
selectedChannel(
notificationAction,
) === 'discord'
? 'Available for Discord actions only. Use the fixed variables listed in the sidebar.'
: 'Discord message templates are only used when the channel is Discord.'
}}
@@ -578,9 +706,7 @@ const notificationActionTimestamp = (
>
<div class="grid gap-2">
<Label
:for="
`notification-enabled-${notificationAction.id}`
"
:for="`notification-enabled-${notificationAction.id}`"
class="flex items-center gap-3"
>
<input
@@ -589,9 +715,7 @@ const notificationActionTimestamp = (
value="0"
/>
<input
:id="
`notification-enabled-${notificationAction.id}`
"
:id="`notification-enabled-${notificationAction.id}`"
type="checkbox"
name="enabled"
value="1"
+1
View File
@@ -109,6 +109,7 @@ export type ProjectRepositorySettingsPageProps = {
export type ProjectNotificationSettingsPageProps = {
channelOptions: NotificationActionOptionModel[];
eventTypeOptions: NotificationActionOptionModel[];
discordAllowedMentionOptions: NotificationActionOptionModel[];
notificationActions: NotificationActionModel[];
project: ProjectModel;
status?: string | null;
@@ -106,6 +106,44 @@ class DeliverNotificationActionTest extends TestCase
$this->assertNotNull($deliveryLog->delivered_at);
}
/**
* Ensure Discord deliveries include configured allowed mention parse values.
*/
public function test_discord_delivery_includes_configured_allowed_mentions(): void
{
Http::preventStrayRequests();
Http::fake([
'https://discord.com/api/webhooks/123456/discord-alerts' => Http::response('', 204),
]);
$notificationAction = NotificationAction::factory()->discord()->create([
'delivery_url' => 'https://discord.com/api/webhooks/123456/discord-alerts',
'channel_configuration' => [
'allowed_mentions' => ['users', 'roles', 'users', 'EVERYONE', 'invalid'],
],
'message_template' => '$ticket_key moved to $status_to',
]);
$deliveryLog = $this->createPendingDeliveryLog($notificationAction);
(new DeliverNotificationAction(
notificationActionId: $notificationAction->id,
payload: $this->payload(),
notificationDeliveryLogId: $deliveryLog->id,
))->handle();
Http::assertSentCount(1);
Http::assertSent(function (Request $request): bool {
return $request->method() === 'POST'
&& $request->url() === 'https://discord.com/api/webhooks/123456/discord-alerts'
&& $request->data() === [
'content' => 'CORE-42 moved to Doing',
'allowed_mentions' => [
'parse' => ['users', 'roles', 'everyone'],
],
];
});
}
/**
* Ensure retryable webhook responses release the job and update the delivery log.
*/
@@ -29,6 +29,9 @@ class ProjectNotificationSettingsControllerTest extends TestCase
->for($project)
->create([
'name' => 'Discord alerts',
'channel_configuration' => [
'allowed_mentions' => ['users', 'roles'],
],
]);
$this->syncProjectPermissions($project, $user, ['edit']);
@@ -41,6 +44,8 @@ class ProjectNotificationSettingsControllerTest extends TestCase
->where('project.id', $project->id)
->where('notificationActions.0.name', 'Discord alerts')
->where('notificationActions.0.channel', NotificationChannel::Discord->value)
->where('notificationActions.0.channel_configuration.allowed_mentions.0', 'users')
->where('notificationActions.0.channel_configuration.allowed_mentions.1', 'roles')
->where('notificationActions.0.event_type', NotificationEventType::TicketStatusChanged->value)
->where('notificationActions.0.enabled', true)
->where('notificationActions.0.has_delivery_url', true)
@@ -84,6 +89,9 @@ class ProjectNotificationSettingsControllerTest extends TestCase
'name' => 'Discord alerts',
'event_type' => NotificationEventType::TicketStatusChanged->value,
'channel' => NotificationChannel::Discord->value,
'channel_configuration' => [
'allowed_mentions' => ['users', 'roles'],
],
'enabled' => '1',
'delivery_url' => 'https://discord.com/api/webhooks/123456/discord-alerts',
'message_template' => '$ticket_key moved from $status_from to $status_to by $actor_name: $url',
@@ -96,6 +104,10 @@ class ProjectNotificationSettingsControllerTest extends TestCase
$this->assertSame($project->id, $notificationAction->project_id);
$this->assertSame(NotificationChannel::Discord, $notificationAction->channel);
$this->assertSame(NotificationEventType::TicketStatusChanged, $notificationAction->event_type);
$this->assertSame(
['allowed_mentions' => ['users', 'roles']],
$notificationAction->channel_configuration,
);
$this->assertTrue($notificationAction->enabled);
$this->assertSame(
'https://discord.com/api/webhooks/123456/discord-alerts',
@@ -125,6 +137,9 @@ class ProjectNotificationSettingsControllerTest extends TestCase
'name' => 'Ops escalation alerts',
'event_type' => NotificationEventType::TicketStatusChanged->value,
'channel' => NotificationChannel::Discord->value,
'channel_configuration' => [
'allowed_mentions' => ['users', 'everyone'],
],
'enabled' => '1',
'delivery_url' => '',
'message_template' => '$ticket_key moved to $status_to: $url',
@@ -139,6 +154,10 @@ class ProjectNotificationSettingsControllerTest extends TestCase
'https://discord.com/api/webhooks/123456/original-secret',
$notificationAction->delivery_url,
);
$this->assertSame(
['allowed_mentions' => ['users', 'everyone']],
$notificationAction->channel_configuration,
);
$this->assertSame('$ticket_key moved to $status_to: $url', $notificationAction->message_template);
}
@@ -258,6 +277,9 @@ class ProjectNotificationSettingsControllerTest extends TestCase
'channel' => NotificationChannel::Discord->value,
'enabled' => 'definitely-not-a-boolean',
'delivery_url' => 'not-a-url',
'channel_configuration' => [
'allowed_mentions' => ['not-valid'],
],
'message_template' => '',
])
->assertRedirect(route('projects.settings.notifications', $project))
@@ -266,6 +288,7 @@ class ProjectNotificationSettingsControllerTest extends TestCase
'event_type',
'enabled',
'delivery_url',
'channel_configuration.allowed_mentions.0',
'message_template',
]);
}