Add notification filters

This commit is contained in:
2026-04-24 20:02:22 +00:00
parent 5f74ddd964
commit c54df44ebe
24 changed files with 1288 additions and 7 deletions
@@ -4,6 +4,8 @@ namespace App\Actions\Notifications;
use App\Enums\NotificationChannel; use App\Enums\NotificationChannel;
use App\Enums\NotificationEventType; use App\Enums\NotificationEventType;
use App\Enums\NotificationFilterAttribute;
use App\Enums\NotificationFilterOperator;
use App\Http\Resources\NotificationActionResource; use App\Http\Resources\NotificationActionResource;
use App\Http\Resources\ProjectResource; use App\Http\Resources\ProjectResource;
use App\Models\Project; use App\Models\Project;
@@ -22,6 +24,8 @@ class BuildProjectNotificationSettingsPayload
return [ return [
'channelOptions' => NotificationChannel::options(), 'channelOptions' => NotificationChannel::options(),
'eventTypeOptions' => NotificationEventType::options(), 'eventTypeOptions' => NotificationEventType::options(),
'filterAttributeOptions' => NotificationFilterAttribute::options(),
'filterOperatorOptions' => NotificationFilterOperator::options(),
'notificationActions' => NotificationActionResource::collection( 'notificationActions' => NotificationActionResource::collection(
$this->listProjectNotificationActions->handle($project), $this->listProjectNotificationActions->handle($project),
)->resolve(), )->resolve(),
@@ -0,0 +1,208 @@
<?php
namespace App\Actions\Notifications;
use App\Enums\NotificationFilterOperator;
use Illuminate\Support\Arr;
class MatchNotificationFilters
{
/**
* @param array<int, array{attribute?: mixed, operator?: mixed, value?: mixed}> $filters
*/
public function handle(array $filters, TicketStatusChangedPayload $payload): bool
{
if ($filters === []) {
return true;
}
$payloadContext = $this->payloadContext($payload);
foreach ($filters as $filter) {
if (! $this->matchesFilter($filter, $payloadContext)) {
return false;
}
}
return true;
}
/**
* @param array{attribute?: mixed, operator?: mixed, value?: mixed} $filter
* @param array<string, mixed> $payloadContext
*/
private function matchesFilter(array $filter, array $payloadContext): bool
{
$attribute = trim((string) Arr::get($filter, 'attribute', ''));
if ($attribute === '') {
return false;
}
$operator = NotificationFilterOperator::tryFrom((string) Arr::get($filter, 'operator', ''));
if ($operator === null) {
return false;
}
$actualValue = data_get($payloadContext, $attribute);
return match ($operator) {
NotificationFilterOperator::Equals => $this->matchesEquals($actualValue, Arr::get($filter, 'value')),
NotificationFilterOperator::NotEquals => $this->matchesNotEquals($actualValue, Arr::get($filter, 'value')),
NotificationFilterOperator::Exists => $actualValue !== null,
NotificationFilterOperator::NotExists => $actualValue === null,
NotificationFilterOperator::In => $this->matchesIn($actualValue, Arr::get($filter, 'value')),
};
}
/**
* @param array<int, string>|string|null $expectedValue
*/
private function matchesEquals(mixed $actualValue, array|string|null $expectedValue): bool
{
if ($actualValue === null) {
return false;
}
$normalizedExpectedValue = $this->normalizeComparable($expectedValue);
if ($normalizedExpectedValue === null) {
return false;
}
return $this->normalizeComparable($actualValue) === $normalizedExpectedValue;
}
/**
* @param array<int, string>|string|null $expectedValue
*/
private function matchesNotEquals(mixed $actualValue, array|string|null $expectedValue): bool
{
$normalizedExpectedValue = $this->normalizeComparable($expectedValue);
if ($normalizedExpectedValue === null) {
return false;
}
if ($actualValue === null) {
return true;
}
return $this->normalizeComparable($actualValue) !== $normalizedExpectedValue;
}
/**
* @param array<int, string>|string|null $expectedValues
*/
private function matchesIn(mixed $actualValue, array|string|null $expectedValues): bool
{
if ($actualValue === null) {
return false;
}
$normalizedActualValue = $this->normalizeComparable($actualValue);
if ($normalizedActualValue === null) {
return false;
}
$normalizedExpectedValues = $this->normalizeComparableList($expectedValues);
if ($normalizedExpectedValues === []) {
return false;
}
return in_array($normalizedActualValue, $normalizedExpectedValues, true);
}
/**
* @return array<int, string>
*/
private function normalizeComparableList(array|string|null $value): array
{
if (is_array($value)) {
$normalized = [];
foreach ($value as $candidate) {
$normalizedCandidate = $this->normalizeComparable($candidate);
if ($normalizedCandidate !== null) {
$normalized[] = $normalizedCandidate;
}
}
return array_values(array_unique($normalized));
}
if (! is_string($value)) {
return [];
}
$chunks = preg_split('/[,\r\n]+/', $value) ?: [];
$normalized = [];
foreach ($chunks as $chunk) {
$normalizedChunk = $this->normalizeComparable($chunk);
if ($normalizedChunk !== null) {
$normalized[] = $normalizedChunk;
}
}
return array_values(array_unique($normalized));
}
private function normalizeComparable(mixed $value): ?string
{
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if (is_int($value) || is_float($value)) {
return trim((string) $value);
}
if (! is_string($value)) {
return null;
}
$trimmedValue = trim($value);
return $trimmedValue === '' ? null : $trimmedValue;
}
/**
* @return array<string, mixed>
*/
private function payloadContext(TicketStatusChangedPayload $payload): array
{
return [
'project' => [
'id' => $payload->projectId,
'key' => $payload->projectKey,
'name' => $payload->projectName,
],
'ticket' => [
'id' => $payload->ticketId,
'key' => $payload->ticketKey,
'title' => $payload->ticketTitle,
'url' => $payload->ticketUrl,
],
'actor' => [
'id' => $payload->actorId,
'name' => $payload->actorName,
],
'status' => [
'from' => [
'id' => $payload->fromStatusId,
'label' => $payload->fromStatusLabel,
],
'to' => [
'id' => $payload->toStatusId,
'label' => $payload->toStatusLabel,
],
],
];
}
}
@@ -0,0 +1,107 @@
<?php
namespace App\Actions\Notifications;
use App\Enums\NotificationFilterOperator;
use Illuminate\Support\Arr;
class NormalizeNotificationFilters
{
/**
* @return array<int, array{attribute: string, operator: string, value: array<int, string>|string|null}>
*/
public function handle(mixed $filters): array
{
if (! is_array($filters)) {
return [];
}
$normalizedFilters = [];
foreach ($filters as $filter) {
if (! is_array($filter)) {
continue;
}
$attribute = trim((string) Arr::get($filter, 'attribute', ''));
$operator = NotificationFilterOperator::tryFrom((string) Arr::get($filter, 'operator', ''));
if ($attribute === '' || $operator === null) {
continue;
}
$normalizedFilters[] = [
'attribute' => $attribute,
'operator' => $operator->value,
'value' => $this->normalizeValue($operator, Arr::get($filter, 'value')),
];
}
return $normalizedFilters;
}
/**
* @return array<int, string>|string|null
*/
private function normalizeValue(NotificationFilterOperator $operator, mixed $value): array|string|null
{
return match ($operator) {
NotificationFilterOperator::Equals,
NotificationFilterOperator::NotEquals => $this->normalizeScalarValue($value),
NotificationFilterOperator::Exists,
NotificationFilterOperator::NotExists => null,
NotificationFilterOperator::In => $this->normalizeListValue($value),
};
}
private function normalizeScalarValue(mixed $value): ?string
{
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if (is_int($value) || is_float($value)) {
return trim((string) $value);
}
if (! is_string($value)) {
return null;
}
$trimmedValue = trim($value);
return $trimmedValue === '' ? null : $trimmedValue;
}
/**
* @return array<int, string>
*/
private function normalizeListValue(mixed $value): array
{
$normalizedValues = [];
if (is_string($value)) {
$chunks = preg_split('/[,\r\n]+/', $value) ?: [];
foreach ($chunks as $chunk) {
$trimmedValue = trim($chunk);
if ($trimmedValue !== '') {
$normalizedValues[] = $trimmedValue;
}
}
}
if (is_array($value)) {
foreach ($value as $candidate) {
$normalizedCandidate = $this->normalizeScalarValue($candidate);
if ($normalizedCandidate !== null) {
$normalizedValues[] = $normalizedCandidate;
}
}
}
return array_values(array_unique($normalizedValues));
}
}
@@ -9,14 +9,22 @@ use App\Models\NotificationDeliveryLog;
class QueueTicketStatusChangedNotificationDeliveries class QueueTicketStatusChangedNotificationDeliveries
{ {
public function __construct(
private readonly MatchNotificationFilters $matchNotificationFilters,
) {}
public function handle(TicketStatusChanged|TicketCreated $event): void public function handle(TicketStatusChanged|TicketCreated $event): void
{ {
NotificationAction::query() NotificationAction::query()
->enabled() ->enabled()
->where('project_id', $event->payload->projectId) ->where('project_id', $event->payload->projectId)
->where('event_type', $event->payload->eventType->value) ->where('event_type', $event->payload->eventType->value)
->get(['channel', 'id']) ->get(['channel', 'filters', 'id'])
->each(function (NotificationAction $notificationAction) use ($event): void { ->each(function (NotificationAction $notificationAction) use ($event): void {
if (! $this->matchNotificationFilters->handle($notificationAction->filters ?? [], $event->payload)) {
return;
}
$notificationDeliveryLogId = null; $notificationDeliveryLogId = null;
if (in_array($notificationAction->channel, [NotificationChannel::Discord, NotificationChannel::Webhook], true)) { if (in_array($notificationAction->channel, [NotificationChannel::Discord, NotificationChannel::Webhook], true)) {
@@ -8,6 +8,10 @@ use Illuminate\Support\Arr;
class StoreProjectNotificationAction class StoreProjectNotificationAction
{ {
public function __construct(
private readonly NormalizeNotificationFilters $normalizeNotificationFilters,
) {}
/** /**
* @param array{ * @param array{
* channel: mixed, * channel: mixed,
@@ -15,6 +19,7 @@ class StoreProjectNotificationAction
* delivery_url: string, * delivery_url: string,
* enabled: bool, * enabled: bool,
* event_type: mixed, * event_type: mixed,
* filters?: mixed,
* message_template?: string|null, * message_template?: string|null,
* name: string * name: string
* } $validated * } $validated
@@ -27,6 +32,7 @@ class StoreProjectNotificationAction
'delivery_url' => trim((string) Arr::get($validated, 'delivery_url')), 'delivery_url' => trim((string) Arr::get($validated, 'delivery_url')),
'enabled' => (bool) Arr::get($validated, 'enabled'), 'enabled' => (bool) Arr::get($validated, 'enabled'),
'event_type' => Arr::get($validated, 'event_type'), 'event_type' => Arr::get($validated, 'event_type'),
'filters' => $this->normalizeNotificationFilters->handle(Arr::get($validated, 'filters')),
'message_template' => $this->normalizeMessageTemplate(Arr::get($validated, 'message_template')), 'message_template' => $this->normalizeMessageTemplate(Arr::get($validated, 'message_template')),
'name' => trim((string) Arr::get($validated, 'name')), 'name' => trim((string) Arr::get($validated, 'name')),
]); ]);
@@ -8,6 +8,10 @@ use Illuminate\Support\Arr;
class UpdateProjectNotificationAction class UpdateProjectNotificationAction
{ {
public function __construct(
private readonly NormalizeNotificationFilters $normalizeNotificationFilters,
) {}
/** /**
* @param array{ * @param array{
* channel?: mixed, * channel?: mixed,
@@ -15,6 +19,7 @@ class UpdateProjectNotificationAction
* delivery_url?: string|null, * delivery_url?: string|null,
* enabled?: bool, * enabled?: bool,
* event_type?: mixed, * event_type?: mixed,
* filters?: mixed,
* message_template?: string|null, * message_template?: string|null,
* name?: string * name?: string
* } $validated * } $validated
@@ -54,6 +59,12 @@ class UpdateProjectNotificationAction
); );
} }
if (array_key_exists('filters', $validated)) {
$attributes['filters'] = $this->normalizeNotificationFilters->handle(
Arr::get($validated, 'filters'),
);
}
$deliveryUrl = Arr::get($validated, 'delivery_url'); $deliveryUrl = Arr::get($validated, 'delivery_url');
if (is_string($deliveryUrl) && trim($deliveryUrl) !== '') { if (is_string($deliveryUrl) && trim($deliveryUrl) !== '') {
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\Enums;
enum NotificationFilterAttribute: string
{
case ProjectId = 'project.id';
case ProjectKey = 'project.key';
case ProjectName = 'project.name';
case TicketId = 'ticket.id';
case TicketKey = 'ticket.key';
case TicketTitle = 'ticket.title';
case TicketUrl = 'ticket.url';
case ActorId = 'actor.id';
case ActorName = 'actor.name';
case StatusFromId = 'status.from.id';
case StatusFromLabel = 'status.from.label';
case StatusToId = 'status.to.id';
case StatusToLabel = 'status.to.label';
public function label(): string
{
return match ($this) {
self::ProjectId => 'Project ID',
self::ProjectKey => 'Project key',
self::ProjectName => 'Project name',
self::TicketId => 'Ticket ID',
self::TicketKey => 'Ticket key',
self::TicketTitle => 'Ticket title',
self::TicketUrl => 'Ticket URL',
self::ActorId => 'Actor ID',
self::ActorName => 'Actor name',
self::StatusFromId => 'From status ID',
self::StatusFromLabel => 'From status label',
self::StatusToId => 'To status ID',
self::StatusToLabel => 'To status label',
};
}
/**
* @return array<int, array{label: string, value: string}>
*/
public static function options(): array
{
return array_map(
static fn (self $attribute): array => [
'label' => $attribute->label(),
'value' => $attribute->value,
],
self::cases(),
);
}
/**
* @return array<int, string>
*/
public static function values(): array
{
return array_map(
static fn (self $attribute): string => $attribute->value,
self::cases(),
);
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Enums;
enum NotificationFilterOperator: string
{
case Equals = 'equals';
case NotEquals = 'not_equals';
case Exists = 'exists';
case NotExists = 'not_exists';
case In = 'in';
public function label(): string
{
return match ($this) {
self::Equals => 'Equals',
self::NotEquals => 'Not equals',
self::Exists => 'Exists',
self::NotExists => 'Not exists',
self::In => 'In list',
};
}
/**
* @return array<int, array{label: string, value: string}>
*/
public static function options(): array
{
return array_map(
static fn (self $operator): array => [
'label' => $operator->label(),
'value' => $operator->value,
],
self::cases(),
);
}
/**
* @return array<int, string>
*/
public static function values(): array
{
return array_map(
static fn (self $operator): string => $operator->value,
self::cases(),
);
}
}
@@ -0,0 +1,124 @@
<?php
namespace App\Http\Requests\Concerns;
use App\Actions\Notifications\NormalizeNotificationFilters;
use App\Enums\NotificationFilterAttribute;
use App\Enums\NotificationFilterOperator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
trait InteractsWithNotificationFilters
{
protected function prepareNotificationFiltersForValidation(): void
{
$rawFilters = $this->input('filters');
if (! is_string($rawFilters)) {
return;
}
$decodedFilters = json_decode($rawFilters, true);
if (json_last_error() === JSON_ERROR_NONE) {
$this->merge([
'filters' => $decodedFilters,
]);
}
}
/**
* @return array<string, mixed>
*/
protected function notificationFilterRules(): array
{
return [
'filters' => ['nullable', 'array', 'max:25'],
'filters.*' => ['required', 'array'],
'filters.*.attribute' => ['required', 'string', Rule::in(NotificationFilterAttribute::values())],
'filters.*.operator' => ['required', 'string', Rule::in(NotificationFilterOperator::values())],
'filters.*.value' => ['nullable'],
];
}
protected function withNotificationFilterValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
$filters = $this->input('filters');
if (! is_array($filters)) {
return;
}
$normalizer = app(NormalizeNotificationFilters::class);
foreach ($filters as $index => $filter) {
if (! is_array($filter)) {
continue;
}
$operator = NotificationFilterOperator::tryFrom((string) ($filter['operator'] ?? null));
if ($operator === null) {
continue;
}
$normalizedValue = $normalizer->handle([$filter])[0]['value'] ?? null;
if (in_array($operator, [NotificationFilterOperator::Equals, NotificationFilterOperator::NotEquals], true)
&& ! is_string($normalizedValue)
) {
$validator->errors()->add(
"filters.{$index}.value",
'A non-empty value is required for equals and not equals operators.',
);
}
if ($operator === NotificationFilterOperator::In
&& (! is_array($normalizedValue) || $normalizedValue === [])
) {
$validator->errors()->add(
"filters.{$index}.value",
'The in operator requires at least one value.',
);
}
if (in_array($operator, [NotificationFilterOperator::Exists, NotificationFilterOperator::NotExists], true)
&& $this->hasProvidedFilterValue($filter['value'] ?? null)
) {
$validator->errors()->add(
"filters.{$index}.value",
'The exists and not exists operators do not accept a value.',
);
}
}
});
}
private function hasProvidedFilterValue(mixed $value): bool
{
if ($value === null) {
return false;
}
if (is_string($value)) {
return trim($value) !== '';
}
if (is_array($value)) {
foreach ($value as $candidate) {
if (is_string($candidate) && trim($candidate) !== '') {
return true;
}
if (! is_string($candidate) && $candidate !== null) {
return true;
}
}
return false;
}
return true;
}
}
@@ -4,12 +4,16 @@ namespace App\Http\Requests;
use App\Enums\NotificationChannel; use App\Enums\NotificationChannel;
use App\Enums\NotificationEventType; use App\Enums\NotificationEventType;
use App\Http\Requests\Concerns\InteractsWithNotificationFilters;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class StoreProjectNotificationActionRequest extends FormRequest class StoreProjectNotificationActionRequest extends FormRequest
{ {
use InteractsWithNotificationFilters;
/** /**
* Determine if the user is authorized to make this request. * Determine if the user is authorized to make this request.
*/ */
@@ -32,9 +36,20 @@ class StoreProjectNotificationActionRequest extends FormRequest
'enabled' => ['required', 'boolean'], 'enabled' => ['required', 'boolean'],
'delivery_url' => ['required', 'string', 'max:2048', 'url'], 'delivery_url' => ['required', 'string', 'max:2048', 'url'],
'message_template' => $this->messageTemplateRules(), 'message_template' => $this->messageTemplateRules(),
...$this->notificationFilterRules(),
]; ];
} }
protected function prepareForValidation(): void
{
$this->prepareNotificationFiltersForValidation();
}
public function withValidator(Validator $validator): void
{
$this->withNotificationFilterValidator($validator);
}
/** /**
* @return array<int, string> * @return array<int, string>
*/ */
@@ -4,12 +4,16 @@ namespace App\Http\Requests;
use App\Enums\NotificationChannel; use App\Enums\NotificationChannel;
use App\Enums\NotificationEventType; use App\Enums\NotificationEventType;
use App\Http\Requests\Concerns\InteractsWithNotificationFilters;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class UpdateProjectNotificationActionRequest extends FormRequest class UpdateProjectNotificationActionRequest extends FormRequest
{ {
use InteractsWithNotificationFilters;
/** /**
* Determine if the user is authorized to make this request. * Determine if the user is authorized to make this request.
*/ */
@@ -32,9 +36,20 @@ class UpdateProjectNotificationActionRequest extends FormRequest
'enabled' => ['required', 'boolean'], 'enabled' => ['required', 'boolean'],
'delivery_url' => ['nullable', 'string', 'max:2048', 'url'], 'delivery_url' => ['nullable', 'string', 'max:2048', 'url'],
'message_template' => $this->messageTemplateRules(), 'message_template' => $this->messageTemplateRules(),
...$this->notificationFilterRules(),
]; ];
} }
protected function prepareForValidation(): void
{
$this->prepareNotificationFiltersForValidation();
}
public function withValidator(Validator $validator): void
{
$this->withNotificationFilterValidator($validator);
}
/** /**
* @return array<int, string> * @return array<int, string>
*/ */
@@ -25,6 +25,7 @@ class NotificationActionResource extends JsonResource
'channel' => $this->channel->value, 'channel' => $this->channel->value,
'channel_label' => $this->channel->label(), 'channel_label' => $this->channel->label(),
'channel_configuration' => $this->channel_configuration ?? [], 'channel_configuration' => $this->channel_configuration ?? [],
'filters' => $this->filters ?? [],
'has_delivery_url' => $this->resource instanceof NotificationAction && filled($this->delivery_url), 'has_delivery_url' => $this->resource instanceof NotificationAction && filled($this->delivery_url),
'message_template' => $this->message_template, 'message_template' => $this->message_template,
'created_at' => $this->created_at?->toISOString(), 'created_at' => $this->created_at?->toISOString(),
+2
View File
@@ -19,6 +19,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'delivery_url', 'delivery_url',
'enabled', 'enabled',
'event_type', 'event_type',
'filters',
'message_template', 'message_template',
'name', 'name',
'project_id', 'project_id',
@@ -40,6 +41,7 @@ class NotificationAction extends Model
'delivery_url' => 'encrypted', 'delivery_url' => 'encrypted',
'enabled' => 'boolean', 'enabled' => 'boolean',
'event_type' => NotificationEventType::class, 'event_type' => NotificationEventType::class,
'filters' => 'array',
]; ];
} }
@@ -26,6 +26,7 @@ class NotificationActionFactory extends Factory
'delivery_url' => 'https://hooks.'.fake()->domainName().'/kanbangit/'.fake()->uuid(), 'delivery_url' => 'https://hooks.'.fake()->domainName().'/kanbangit/'.fake()->uuid(),
'enabled' => true, 'enabled' => true,
'event_type' => NotificationEventType::TicketStatusChanged, 'event_type' => NotificationEventType::TicketStatusChanged,
'filters' => [],
'message_template' => null, 'message_template' => null,
'name' => fake()->unique()->sentence(3), 'name' => fake()->unique()->sentence(3),
'project_id' => Project::factory(), 'project_id' => Project::factory(),
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('notification_actions', function (Blueprint $table): void {
$table->json('filters')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('notification_actions', function (Blueprint $table): void {
$table->dropColumn('filters');
});
}
};
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3'; import { Form, Head, Link } from '@inertiajs/vue3';
import { reactive } from 'vue'; import { computed, reactive } from 'vue';
import ProjectNotificationSettingsController from '@/actions/App/Http/Controllers/ProjectNotificationSettingsController'; import ProjectNotificationSettingsController from '@/actions/App/Http/Controllers/ProjectNotificationSettingsController';
import Heading from '@/components/Heading.vue'; import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
@@ -28,18 +28,45 @@ import type {
BreadcrumbItem, BreadcrumbItem,
ProjectNotificationSettingsPageProps, ProjectNotificationSettingsPageProps,
} from '@/types'; } from '@/types';
import type { NotificationActionFilterModel } from '@/types/models/notification-action-filter-model';
import type { NotificationActionModel } from '@/types/models/notification-action-model'; import type { NotificationActionModel } from '@/types/models/notification-action-model';
type NotificationFilterFormState = {
attribute: string;
id: string;
operator: string;
value: string;
};
type NotificationFilterPayload = {
attribute: string;
operator: string;
value: string | string[] | null;
};
const props = defineProps<ProjectNotificationSettingsPageProps>(); const props = defineProps<ProjectNotificationSettingsPageProps>();
const defaultCreateChannel = const defaultCreateChannel =
props.channelOptions.find((option) => option.value === 'webhook')?.value ?? props.channelOptions.find((option) => option.value === 'webhook')?.value ??
props.channelOptions[0]?.value ?? props.channelOptions[0]?.value ??
''; '';
const defaultFilterAttribute = props.filterAttributeOptions[0]?.value ?? '';
const defaultFilterOperator =
props.filterOperatorOptions.find((option) => option.value === 'equals')
?.value ??
props.filterOperatorOptions[0]?.value ??
'';
const createChannelState = reactive<{ value: string }>({ const createChannelState = reactive<{ value: string }>({
value: defaultCreateChannel, value: defaultCreateChannel,
}); });
const createFilterState = reactive<{ filters: NotificationFilterFormState[] }>({
filters: [],
});
const editChannelState = reactive<Record<number, string>>({}); const editChannelState = reactive<Record<number, string>>({});
const editFilterState = reactive<Record<number, NotificationFilterFormState[]>>(
{},
);
const timestampFormatter = new Intl.DateTimeFormat(undefined, { const timestampFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium', dateStyle: 'medium',
timeStyle: 'short', timeStyle: 'short',
@@ -60,8 +87,106 @@ const breadcrumbs: BreadcrumbItem[] = [
}, },
]; ];
let filterStateCounter = 0;
const nextFilterStateId = (): string => `notification-filter-${filterStateCounter++}`;
const parseInFilterValues = (value: string): string[] =>
value
.split(/[\n,]/)
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
const supportsFilterValue = (operator: string): boolean =>
!['exists', 'not_exists'].includes(operator);
const supportsArrayFilterValue = (operator: string): boolean => operator === 'in';
const createFilter = (): NotificationFilterFormState => ({
attribute: defaultFilterAttribute,
id: nextFilterStateId(),
operator: defaultFilterOperator,
value: '',
});
const toFilterFormState = (
filter: NotificationActionFilterModel,
): NotificationFilterFormState => ({
attribute: filter.attribute,
id: nextFilterStateId(),
operator: filter.operator,
value: Array.isArray(filter.value)
? filter.value.join(', ')
: (filter.value ?? ''),
});
const serializeFilters = (
filters: NotificationFilterFormState[],
): NotificationFilterPayload[] =>
filters.map((filter) => ({
attribute: filter.attribute,
operator: filter.operator,
value: supportsArrayFilterValue(filter.operator)
? parseInFilterValues(filter.value)
: supportsFilterValue(filter.operator)
? filter.value.trim()
: null,
}));
const notificationFilterError = (
errors: Record<string, string>,
): string | undefined => {
if (errors.filters) {
return errors.filters;
}
const nestedErrorKey = Object.keys(errors).find((key) =>
key.startsWith('filters.'),
);
return nestedErrorKey === undefined ? undefined : errors[nestedErrorKey];
};
const addCreateFilter = (): void => {
createFilterState.filters.push(createFilter());
};
const removeCreateFilter = (index: number): void => {
createFilterState.filters.splice(index, 1);
};
const resetCreateFilters = (): void => {
createFilterState.filters = [];
};
const editFilters = (notificationActionId: number): NotificationFilterFormState[] =>
editFilterState[notificationActionId] ?? [];
const addEditFilter = (notificationActionId: number): void => {
editFilterState[notificationActionId] = [
...editFilters(notificationActionId),
createFilter(),
];
};
const removeEditFilter = (notificationActionId: number, index: number): void => {
editFilterState[notificationActionId] = editFilters(notificationActionId).filter(
(_, candidateIndex) => candidateIndex !== index,
);
};
const createSerializedFilters = computed<string>(() =>
JSON.stringify(serializeFilters(createFilterState.filters)),
);
const editSerializedFilters = (notificationActionId: number): string =>
JSON.stringify(serializeFilters(editFilters(notificationActionId)));
props.notificationActions.forEach((notificationAction) => { props.notificationActions.forEach((notificationAction) => {
editChannelState[notificationAction.id] = notificationAction.channel; editChannelState[notificationAction.id] = notificationAction.channel;
editFilterState[notificationAction.id] = (
notificationAction.filters ?? []
).map(toFilterFormState);
}); });
const selectedChannel = (notificationAction: NotificationActionModel): string => const selectedChannel = (notificationAction: NotificationActionModel): string =>
@@ -167,7 +292,8 @@ const notificationActionTimestamp = (
reset-on-success reset-on-success
@success=" @success="
createChannelState.value = createChannelState.value =
defaultCreateChannel defaultCreateChannel;
resetCreateFilters();
" "
class="space-y-4" class="space-y-4"
v-slot="{ errors, processing }" v-slot="{ errors, processing }"
@@ -285,6 +411,148 @@ const notificationActionTimestamp = (
/> />
</div> </div>
<div class="space-y-3 rounded-lg border p-4">
<div class="space-y-1">
<Label>Notification filters</Label>
<p
class="text-muted-foreground text-sm"
>
Optional rules that must match the
ticket payload before this
notification is dispatched.
</p>
</div>
<input
type="hidden"
name="filters"
:value="createSerializedFilters"
/>
<div
v-if="createFilterState.filters.length === 0"
class="text-muted-foreground text-sm"
>
No filters configured. This action runs
for every matching event.
</div>
<div
v-for="(
filter,
filterIndex
) in createFilterState.filters"
:key="filter.id"
class="grid gap-3 rounded-md border p-3"
>
<div class="grid gap-3 md:grid-cols-2">
<div class="grid gap-2">
<Label
:for="`create-filter-attribute-${filter.id}`"
>
Attribute
</Label>
<select
:id="`create-filter-attribute-${filter.id}`"
v-model="filter.attribute"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="filterAttributeOption in filterAttributeOptions"
:key="filterAttributeOption.value"
:value="filterAttributeOption.value"
>
{{
filterAttributeOption.label
}}
</option>
</select>
</div>
<div class="grid gap-2">
<Label
:for="`create-filter-operator-${filter.id}`"
>
Operator
</Label>
<select
:id="`create-filter-operator-${filter.id}`"
v-model="filter.operator"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="filterOperatorOption in filterOperatorOptions"
:key="filterOperatorOption.value"
:value="filterOperatorOption.value"
>
{{
filterOperatorOption.label
}}
</option>
</select>
</div>
</div>
<div
v-if="supportsFilterValue(filter.operator)"
class="grid gap-2"
>
<Label
:for="`create-filter-value-${filter.id}`"
>
{{
supportsArrayFilterValue(
filter.operator,
)
? 'Values (comma or newline separated)'
: 'Value'
}}
</Label>
<textarea
:id="`create-filter-value-${filter.id}`"
v-model="filter.value"
rows="2"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-h-20 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
:placeholder="
supportsArrayFilterValue(
filter.operator,
)
? 'CORE-1, CORE-2'
: 'Doing'
"
/>
</div>
<div class="flex justify-end">
<Button
type="button"
variant="outline"
@click="
removeCreateFilter(
filterIndex,
)
"
>
Remove filter
</Button>
</div>
</div>
<div class="flex justify-start">
<Button
type="button"
variant="outline"
@click="addCreateFilter"
>
Add filter
</Button>
</div>
<InputError
:message="notificationFilterError(errors)"
/>
</div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label <Label
for="create-notification-enabled" for="create-notification-enabled"
@@ -573,6 +841,159 @@ const notificationActionTimestamp = (
/> />
</div> </div>
<div class="space-y-3 rounded-lg border p-4">
<div class="space-y-1">
<Label>Notification filters</Label>
<p
class="text-muted-foreground text-sm"
>
Optional rules that must all match
this event payload before delivery.
</p>
</div>
<input
type="hidden"
name="filters"
:value="
editSerializedFilters(
notificationAction.id,
)
"
/>
<div
v-if="
editFilters(notificationAction.id)
.length === 0
"
class="text-muted-foreground text-sm"
>
No filters configured. This action runs
for every matching event.
</div>
<div
v-for="(
filter,
filterIndex
) in editFilters(notificationAction.id)"
:key="filter.id"
class="grid gap-3 rounded-md border p-3"
>
<div class="grid gap-3 md:grid-cols-2">
<div class="grid gap-2">
<Label
:for="`notification-filter-attribute-${notificationAction.id}-${filter.id}`"
>
Attribute
</Label>
<select
:id="`notification-filter-attribute-${notificationAction.id}-${filter.id}`"
v-model="filter.attribute"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="filterAttributeOption in filterAttributeOptions"
:key="filterAttributeOption.value"
:value="filterAttributeOption.value"
>
{{
filterAttributeOption.label
}}
</option>
</select>
</div>
<div class="grid gap-2">
<Label
:for="`notification-filter-operator-${notificationAction.id}-${filter.id}`"
>
Operator
</Label>
<select
:id="`notification-filter-operator-${notificationAction.id}-${filter.id}`"
v-model="filter.operator"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="filterOperatorOption in filterOperatorOptions"
:key="filterOperatorOption.value"
:value="filterOperatorOption.value"
>
{{
filterOperatorOption.label
}}
</option>
</select>
</div>
</div>
<div
v-if="supportsFilterValue(filter.operator)"
class="grid gap-2"
>
<Label
:for="`notification-filter-value-${notificationAction.id}-${filter.id}`"
>
{{
supportsArrayFilterValue(
filter.operator,
)
? 'Values (comma or newline separated)'
: 'Value'
}}
</Label>
<textarea
:id="`notification-filter-value-${notificationAction.id}-${filter.id}`"
v-model="filter.value"
rows="2"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-h-20 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
:placeholder="
supportsArrayFilterValue(
filter.operator,
)
? 'CORE-1, CORE-2'
: 'Doing'
"
/>
</div>
<div class="flex justify-end">
<Button
type="button"
variant="outline"
@click="
removeEditFilter(
notificationAction.id,
filterIndex,
)
"
>
Remove filter
</Button>
</div>
</div>
<div class="flex justify-start">
<Button
type="button"
variant="outline"
@click="
addEditFilter(
notificationAction.id,
)
"
>
Add filter
</Button>
</div>
<InputError
:message="notificationFilterError(errors)"
/>
</div>
<div <div
class="flex flex-col gap-3 border-t pt-4 sm:flex-row sm:items-center sm:justify-between" class="flex flex-col gap-3 border-t pt-4 sm:flex-row sm:items-center sm:justify-between"
> >
@@ -0,0 +1,5 @@
export type NotificationActionFilterModel = {
attribute: string;
operator: string;
value: string | string[] | null;
};
@@ -1,3 +1,5 @@
import type { NotificationActionFilterModel } from '@/types/models/notification-action-filter-model';
export type NotificationActionModel = { export type NotificationActionModel = {
id: number; id: number;
project_id: number; project_id: number;
@@ -8,6 +10,7 @@ export type NotificationActionModel = {
channel: string; channel: string;
channel_label: string; channel_label: string;
channel_configuration: Record<string, unknown>; channel_configuration: Record<string, unknown>;
filters: NotificationActionFilterModel[];
has_delivery_url: boolean; has_delivery_url: boolean;
message_template: string | null; message_template: string | null;
created_at: string | null; created_at: string | null;
+2
View File
@@ -109,6 +109,8 @@ export type ProjectRepositorySettingsPageProps = {
export type ProjectNotificationSettingsPageProps = { export type ProjectNotificationSettingsPageProps = {
channelOptions: NotificationActionOptionModel[]; channelOptions: NotificationActionOptionModel[];
eventTypeOptions: NotificationActionOptionModel[]; eventTypeOptions: NotificationActionOptionModel[];
filterAttributeOptions: NotificationActionOptionModel[];
filterOperatorOptions: NotificationActionOptionModel[];
notificationActions: NotificationActionModel[]; notificationActions: NotificationActionModel[];
project: ProjectModel; project: ProjectModel;
status?: string | null; status?: string | null;
@@ -33,6 +33,7 @@ class NotificationActionTest extends TestCase
$this->assertSame(NotificationEventType::TicketStatusChanged, $notificationAction->fresh()->event_type); $this->assertSame(NotificationEventType::TicketStatusChanged, $notificationAction->fresh()->event_type);
$this->assertSame(NotificationChannel::Webhook, $notificationAction->fresh()->channel); $this->assertSame(NotificationChannel::Webhook, $notificationAction->fresh()->channel);
$this->assertSame([], $notificationAction->fresh()->channel_configuration); $this->assertSame([], $notificationAction->fresh()->channel_configuration);
$this->assertSame([], $notificationAction->fresh()->filters ?? []);
} }
/** /**
@@ -61,5 +62,6 @@ class NotificationActionTest extends TestCase
$this->assertArrayNotHasKey('delivery_url', $notificationAction->fresh()->toArray()); $this->assertArrayNotHasKey('delivery_url', $notificationAction->fresh()->toArray());
$this->assertArrayNotHasKey('delivery_url', $resource); $this->assertArrayNotHasKey('delivery_url', $resource);
$this->assertTrue($resource['has_delivery_url']); $this->assertTrue($resource['has_delivery_url']);
$this->assertSame([], $resource['filters']);
} }
} }
@@ -34,6 +34,13 @@ class ProjectNotificationActionManagementTest extends TestCase
'event_type' => NotificationEventType::TicketStatusChanged, 'event_type' => NotificationEventType::TicketStatusChanged,
'channel' => NotificationChannel::Discord, 'channel' => NotificationChannel::Discord,
'channel_configuration' => ['mentions' => ['ops']], 'channel_configuration' => ['mentions' => ['ops']],
'filters' => [
[
'attribute' => 'status.to.label',
'operator' => 'equals',
'value' => 'Doing',
],
],
'message_template' => '$ticket_key changed to $status_to', 'message_template' => '$ticket_key changed to $status_to',
'delivery_url' => 'https://discord.com/api/webhooks/123456/discord-alerts', 'delivery_url' => 'https://discord.com/api/webhooks/123456/discord-alerts',
]); ]);
@@ -58,6 +65,13 @@ class ProjectNotificationActionManagementTest extends TestCase
$this->assertCount(2, $listedActions); $this->assertCount(2, $listedActions);
$this->assertSame(['Discord alerts', 'Webhook alerts'], $listedActions->pluck('name')->all()); $this->assertSame(['Discord alerts', 'Webhook alerts'], $listedActions->pluck('name')->all());
$this->assertSame(['mentions' => ['ops']], $listedActions->first()->channel_configuration); $this->assertSame(['mentions' => ['ops']], $listedActions->first()->channel_configuration);
$this->assertSame([
[
'attribute' => 'status.to.label',
'operator' => 'equals',
'value' => 'Doing',
],
], $listedActions->first()->filters);
} }
/** /**
@@ -74,6 +88,13 @@ class ProjectNotificationActionManagementTest extends TestCase
$updatedAction = $updateAction->handle($project, $notificationAction, [ $updatedAction = $updateAction->handle($project, $notificationAction, [
'enabled' => false, 'enabled' => false,
'filters' => [
[
'attribute' => 'ticket.key',
'operator' => 'in',
'value' => 'CORE-1, CORE-2',
],
],
'message_template' => '$ticket_key moved from $status_from to $status_to', 'message_template' => '$ticket_key moved from $status_from to $status_to',
'channel_configuration' => ['mentions' => ['support']], 'channel_configuration' => ['mentions' => ['support']],
'delivery_url' => '', 'delivery_url' => '',
@@ -85,6 +106,13 @@ class ProjectNotificationActionManagementTest extends TestCase
$updatedAction->message_template, $updatedAction->message_template,
); );
$this->assertSame(['mentions' => ['support']], $updatedAction->channel_configuration); $this->assertSame(['mentions' => ['support']], $updatedAction->channel_configuration);
$this->assertSame([
[
'attribute' => 'ticket.key',
'operator' => 'in',
'value' => ['CORE-1', 'CORE-2'],
],
], $updatedAction->filters);
$this->assertSame( $this->assertSame(
'https://discord.com/api/webhooks/123456/original-secret', 'https://discord.com/api/webhooks/123456/original-secret',
$updatedAction->delivery_url, $updatedAction->delivery_url,
@@ -71,6 +71,34 @@ class TicketCreatedNotificationDispatchTest extends TestCase
'name' => 'Status changed webhook', 'name' => 'Status changed webhook',
'event_type' => NotificationEventType::TicketStatusChanged, 'event_type' => NotificationEventType::TicketStatusChanged,
]); ]);
$filterNotExistsAction = NotificationAction::factory()
->webhook()
->for($project)
->create([
'name' => 'Ticket create without origin status',
'event_type' => NotificationEventType::TicketCreate,
'filters' => [
[
'attribute' => 'status.from.id',
'operator' => 'not_exists',
'value' => null,
],
],
]);
$filterExistsAction = NotificationAction::factory()
->webhook()
->for($project)
->create([
'name' => 'Ticket create with origin status',
'event_type' => NotificationEventType::TicketCreate,
'filters' => [
[
'attribute' => 'status.from.id',
'operator' => 'exists',
'value' => null,
],
],
]);
$foreignProjectAction = NotificationAction::factory() $foreignProjectAction = NotificationAction::factory()
->for($otherProject) ->for($otherProject)
->create([ ->create([
@@ -100,7 +128,7 @@ class TicketCreatedNotificationDispatchTest extends TestCase
->assertRedirect(route('projects.tickets.show', [$project, $ticket])) ->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
->assertSessionHas('status', 'CORE-1 was created.'); ->assertSessionHas('status', 'CORE-1 was created.');
Queue::assertPushed(DeliverNotificationAction::class, 2); Queue::assertPushed(DeliverNotificationAction::class, 3);
Queue::assertPushed(DeliverNotificationAction::class, function (DeliverNotificationAction $job) use ($webhookAction, $project, $ticket, $user, $backlogStatus): bool { Queue::assertPushed(DeliverNotificationAction::class, function (DeliverNotificationAction $job) use ($webhookAction, $project, $ticket, $user, $backlogStatus): bool {
return $job->notificationActionId === $webhookAction->id return $job->notificationActionId === $webhookAction->id
&& $job->payload->projectId === $project->id && $job->payload->projectId === $project->id
@@ -122,11 +150,15 @@ class TicketCreatedNotificationDispatchTest extends TestCase
Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $discordAction->id Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $discordAction->id
&& $job->payload->eventType === NotificationEventType::TicketCreate && $job->payload->eventType === NotificationEventType::TicketCreate
&& $job->notificationDeliveryLogId !== null); && $job->notificationDeliveryLogId !== null);
Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $filterNotExistsAction->id
&& $job->payload->eventType === NotificationEventType::TicketCreate
&& $job->notificationDeliveryLogId !== null);
Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $disabledAction->id); Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $disabledAction->id);
Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $statusChangedAction->id); Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $statusChangedAction->id);
Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $filterExistsAction->id);
Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $foreignProjectAction->id); Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $foreignProjectAction->id);
$this->assertDatabaseCount('notification_delivery_logs', 2); $this->assertDatabaseCount('notification_delivery_logs', 3);
$this->assertDatabaseHas('notification_delivery_logs', [ $this->assertDatabaseHas('notification_delivery_logs', [
'notification_action_id' => $webhookAction->id, 'notification_action_id' => $webhookAction->id,
'delivery_status' => NotificationDeliveryStatus::Pending->value, 'delivery_status' => NotificationDeliveryStatus::Pending->value,
@@ -141,6 +173,13 @@ class TicketCreatedNotificationDispatchTest extends TestCase
'attempt_count' => 0, 'attempt_count' => 0,
'http_status' => null, 'http_status' => null,
]); ]);
$this->assertDatabaseHas('notification_delivery_logs', [
'notification_action_id' => $filterNotExistsAction->id,
'delivery_status' => NotificationDeliveryStatus::Pending->value,
'event_type' => NotificationEventType::TicketCreate->value,
'attempt_count' => 0,
'http_status' => null,
]);
} }
private function backlogStatus(Project $project): TicketStatus private function backlogStatus(Project $project): TicketStatus
@@ -72,6 +72,71 @@ class TicketStatusChangedNotificationDispatchTest extends TestCase
->create([ ->create([
'name' => 'Disabled webhook', 'name' => 'Disabled webhook',
]); ]);
$filterEqualsAction = NotificationAction::factory()
->webhook()
->for($project)
->create([
'name' => 'Filtered equals webhook',
'filters' => [
[
'attribute' => 'status.to.id',
'operator' => 'equals',
'value' => (string) $doingStatus->id,
],
],
]);
$filterNotEqualsAction = NotificationAction::factory()
->webhook()
->for($project)
->create([
'name' => 'Filtered not equals webhook',
'filters' => [
[
'attribute' => 'status.to.id',
'operator' => 'not_equals',
'value' => (string) $doingStatus->id,
],
],
]);
$filterExistsAction = NotificationAction::factory()
->webhook()
->for($project)
->create([
'name' => 'Filtered exists webhook',
'filters' => [
[
'attribute' => 'actor.id',
'operator' => 'exists',
'value' => null,
],
],
]);
$filterNotExistsAction = NotificationAction::factory()
->webhook()
->for($project)
->create([
'name' => 'Filtered not exists webhook',
'filters' => [
[
'attribute' => 'actor.id',
'operator' => 'not_exists',
'value' => null,
],
],
]);
$filterInAction = NotificationAction::factory()
->webhook()
->for($project)
->create([
'name' => 'Filtered in webhook',
'filters' => [
[
'attribute' => 'ticket.key',
'operator' => 'in',
'value' => ['CORE-1', 'CORE-99'],
],
],
]);
$foreignProjectAction = NotificationAction::factory() $foreignProjectAction = NotificationAction::factory()
->for($otherProject) ->for($otherProject)
->create([ ->create([
@@ -101,7 +166,7 @@ class TicketStatusChangedNotificationDispatchTest extends TestCase
->assertRedirect(route('projects.backlog.index', $project)) ->assertRedirect(route('projects.backlog.index', $project))
->assertSessionHas('status', 'CORE-1 moved to Doing.'); ->assertSessionHas('status', 'CORE-1 moved to Doing.');
Queue::assertPushed(DeliverNotificationAction::class, 2); Queue::assertPushed(DeliverNotificationAction::class, 5);
Queue::assertPushed(DeliverNotificationAction::class, function (DeliverNotificationAction $job) use ($webhookAction, $project, $ticket, $user, $backlogStatus, $doingStatus): bool { Queue::assertPushed(DeliverNotificationAction::class, function (DeliverNotificationAction $job) use ($webhookAction, $project, $ticket, $user, $backlogStatus, $doingStatus): bool {
return $job->notificationActionId === $webhookAction->id return $job->notificationActionId === $webhookAction->id
&& $job->payload->projectId === $project->id && $job->payload->projectId === $project->id
@@ -122,10 +187,18 @@ class TicketStatusChangedNotificationDispatchTest extends TestCase
}); });
Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $discordAction->id Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $discordAction->id
&& $job->notificationDeliveryLogId !== null); && $job->notificationDeliveryLogId !== null);
Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $filterEqualsAction->id
&& $job->notificationDeliveryLogId !== null);
Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $filterExistsAction->id
&& $job->notificationDeliveryLogId !== null);
Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $filterInAction->id
&& $job->notificationDeliveryLogId !== null);
Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $disabledAction->id); Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $disabledAction->id);
Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $filterNotEqualsAction->id);
Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $filterNotExistsAction->id);
Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $foreignProjectAction->id); Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $foreignProjectAction->id);
$this->assertDatabaseCount('notification_delivery_logs', 2); $this->assertDatabaseCount('notification_delivery_logs', 5);
$this->assertDatabaseHas('notification_delivery_logs', [ $this->assertDatabaseHas('notification_delivery_logs', [
'notification_action_id' => $webhookAction->id, 'notification_action_id' => $webhookAction->id,
'delivery_status' => NotificationDeliveryStatus::Pending->value, 'delivery_status' => NotificationDeliveryStatus::Pending->value,
@@ -138,6 +211,24 @@ class TicketStatusChangedNotificationDispatchTest extends TestCase
'attempt_count' => 0, 'attempt_count' => 0,
'http_status' => null, 'http_status' => null,
]); ]);
$this->assertDatabaseHas('notification_delivery_logs', [
'notification_action_id' => $filterEqualsAction->id,
'delivery_status' => NotificationDeliveryStatus::Pending->value,
'attempt_count' => 0,
'http_status' => null,
]);
$this->assertDatabaseHas('notification_delivery_logs', [
'notification_action_id' => $filterExistsAction->id,
'delivery_status' => NotificationDeliveryStatus::Pending->value,
'attempt_count' => 0,
'http_status' => null,
]);
$this->assertDatabaseHas('notification_delivery_logs', [
'notification_action_id' => $filterInAction->id,
'delivery_status' => NotificationDeliveryStatus::Pending->value,
'attempt_count' => 0,
'http_status' => null,
]);
$this->assertDatabaseHas('ticket_worklog_entries', [ $this->assertDatabaseHas('ticket_worklog_entries', [
'ticket_id' => $ticket->id, 'ticket_id' => $ticket->id,
@@ -29,6 +29,13 @@ class ProjectNotificationSettingsControllerTest extends TestCase
->for($project) ->for($project)
->create([ ->create([
'name' => 'Discord alerts', 'name' => 'Discord alerts',
'filters' => [
[
'attribute' => 'status.to.label',
'operator' => 'equals',
'value' => 'Doing',
],
],
]); ]);
$this->syncProjectPermissions($project, $user, ['edit']); $this->syncProjectPermissions($project, $user, ['edit']);
@@ -44,10 +51,15 @@ class ProjectNotificationSettingsControllerTest extends TestCase
->where('notificationActions.0.event_type', NotificationEventType::TicketStatusChanged->value) ->where('notificationActions.0.event_type', NotificationEventType::TicketStatusChanged->value)
->where('notificationActions.0.enabled', true) ->where('notificationActions.0.enabled', true)
->where('notificationActions.0.has_delivery_url', true) ->where('notificationActions.0.has_delivery_url', true)
->where('notificationActions.0.filters.0.attribute', 'status.to.label')
->where('notificationActions.0.filters.0.operator', 'equals')
->where('notificationActions.0.filters.0.value', 'Doing')
->where('notificationActions.0.updated_at', $notificationAction->updated_at?->toISOString()) ->where('notificationActions.0.updated_at', $notificationAction->updated_at?->toISOString())
->where('channelOptions.0.value', NotificationChannel::Discord->value) ->where('channelOptions.0.value', NotificationChannel::Discord->value)
->where('eventTypeOptions.0.value', NotificationEventType::TicketStatusChanged->value) ->where('eventTypeOptions.0.value', NotificationEventType::TicketStatusChanged->value)
->where('eventTypeOptions.1.value', NotificationEventType::TicketCreate->value) ->where('eventTypeOptions.1.value', NotificationEventType::TicketCreate->value)
->where('filterAttributeOptions.0.value', 'project.id')
->where('filterOperatorOptions.0.value', 'equals')
->where('templateVariables.0.value', '$url') ->where('templateVariables.0.value', '$url')
->where('templateVariables.6.value', '$actor_name') ->where('templateVariables.6.value', '$actor_name')
->missing('notificationActions.0.delivery_url'), ->missing('notificationActions.0.delivery_url'),
@@ -86,6 +98,13 @@ class ProjectNotificationSettingsControllerTest extends TestCase
'channel' => NotificationChannel::Discord->value, 'channel' => NotificationChannel::Discord->value,
'enabled' => '1', 'enabled' => '1',
'delivery_url' => 'https://discord.com/api/webhooks/123456/discord-alerts', 'delivery_url' => 'https://discord.com/api/webhooks/123456/discord-alerts',
'filters' => json_encode([
[
'attribute' => 'status.to.label',
'operator' => 'equals',
'value' => 'Doing',
],
], JSON_THROW_ON_ERROR),
'message_template' => '$ticket_key moved from $status_from to $status_to by $actor_name: $url', 'message_template' => '$ticket_key moved from $status_from to $status_to by $actor_name: $url',
]) ])
->assertRedirect(route('projects.settings.notifications', $project)) ->assertRedirect(route('projects.settings.notifications', $project))
@@ -101,6 +120,13 @@ class ProjectNotificationSettingsControllerTest extends TestCase
'https://discord.com/api/webhooks/123456/discord-alerts', 'https://discord.com/api/webhooks/123456/discord-alerts',
$notificationAction->delivery_url, $notificationAction->delivery_url,
); );
$this->assertSame([
[
'attribute' => 'status.to.label',
'operator' => 'equals',
'value' => 'Doing',
],
], $notificationAction->filters);
} }
/** /**
@@ -127,6 +153,13 @@ class ProjectNotificationSettingsControllerTest extends TestCase
'channel' => NotificationChannel::Discord->value, 'channel' => NotificationChannel::Discord->value,
'enabled' => '1', 'enabled' => '1',
'delivery_url' => '', 'delivery_url' => '',
'filters' => json_encode([
[
'attribute' => 'ticket.key',
'operator' => 'in',
'value' => ['CORE-1', 'CORE-2'],
],
], JSON_THROW_ON_ERROR),
'message_template' => '$ticket_key moved to $status_to: $url', 'message_template' => '$ticket_key moved to $status_to: $url',
]) ])
->assertRedirect(route('projects.settings.notifications', $project)) ->assertRedirect(route('projects.settings.notifications', $project))
@@ -139,6 +172,13 @@ class ProjectNotificationSettingsControllerTest extends TestCase
'https://discord.com/api/webhooks/123456/original-secret', 'https://discord.com/api/webhooks/123456/original-secret',
$notificationAction->delivery_url, $notificationAction->delivery_url,
); );
$this->assertSame([
[
'attribute' => 'ticket.key',
'operator' => 'in',
'value' => ['CORE-1', 'CORE-2'],
],
], $notificationAction->filters);
$this->assertSame('$ticket_key moved to $status_to: $url', $notificationAction->message_template); $this->assertSame('$ticket_key moved to $status_to: $url', $notificationAction->message_template);
} }
@@ -258,6 +298,13 @@ class ProjectNotificationSettingsControllerTest extends TestCase
'channel' => NotificationChannel::Discord->value, 'channel' => NotificationChannel::Discord->value,
'enabled' => 'definitely-not-a-boolean', 'enabled' => 'definitely-not-a-boolean',
'delivery_url' => 'not-a-url', 'delivery_url' => 'not-a-url',
'filters' => json_encode([
[
'attribute' => 'ticket.key',
'operator' => 'in',
'value' => [],
],
], JSON_THROW_ON_ERROR),
'message_template' => '', 'message_template' => '',
]) ])
->assertRedirect(route('projects.settings.notifications', $project)) ->assertRedirect(route('projects.settings.notifications', $project))
@@ -266,6 +313,7 @@ class ProjectNotificationSettingsControllerTest extends TestCase
'event_type', 'event_type',
'enabled', 'enabled',
'delivery_url', 'delivery_url',
'filters.0.value',
'message_template', 'message_template',
]); ]);
} }