diff --git a/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php b/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php index 46323b1..21053a2 100644 --- a/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php +++ b/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php @@ -4,6 +4,8 @@ namespace App\Actions\Notifications; use App\Enums\NotificationChannel; use App\Enums\NotificationEventType; +use App\Enums\NotificationFilterAttribute; +use App\Enums\NotificationFilterOperator; use App\Http\Resources\NotificationActionResource; use App\Http\Resources\ProjectResource; use App\Models\Project; @@ -22,6 +24,8 @@ class BuildProjectNotificationSettingsPayload return [ 'channelOptions' => NotificationChannel::options(), 'eventTypeOptions' => NotificationEventType::options(), + 'filterAttributeOptions' => NotificationFilterAttribute::options(), + 'filterOperatorOptions' => NotificationFilterOperator::options(), 'notificationActions' => NotificationActionResource::collection( $this->listProjectNotificationActions->handle($project), )->resolve(), diff --git a/app/Actions/Notifications/MatchNotificationFilters.php b/app/Actions/Notifications/MatchNotificationFilters.php new file mode 100644 index 0000000..e273931 --- /dev/null +++ b/app/Actions/Notifications/MatchNotificationFilters.php @@ -0,0 +1,208 @@ + $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 $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|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|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|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 + */ + 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 + */ + 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, + ], + ], + ]; + } +} diff --git a/app/Actions/Notifications/NormalizeNotificationFilters.php b/app/Actions/Notifications/NormalizeNotificationFilters.php new file mode 100644 index 0000000..a1afdc2 --- /dev/null +++ b/app/Actions/Notifications/NormalizeNotificationFilters.php @@ -0,0 +1,107 @@ +|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|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 + */ + 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)); + } +} diff --git a/app/Actions/Notifications/QueueTicketStatusChangedNotificationDeliveries.php b/app/Actions/Notifications/QueueTicketStatusChangedNotificationDeliveries.php index 42abca3..1458ef5 100644 --- a/app/Actions/Notifications/QueueTicketStatusChangedNotificationDeliveries.php +++ b/app/Actions/Notifications/QueueTicketStatusChangedNotificationDeliveries.php @@ -9,14 +9,22 @@ use App\Models\NotificationDeliveryLog; class QueueTicketStatusChangedNotificationDeliveries { + public function __construct( + private readonly MatchNotificationFilters $matchNotificationFilters, + ) {} + public function handle(TicketStatusChanged|TicketCreated $event): void { NotificationAction::query() ->enabled() ->where('project_id', $event->payload->projectId) ->where('event_type', $event->payload->eventType->value) - ->get(['channel', 'id']) + ->get(['channel', 'filters', 'id']) ->each(function (NotificationAction $notificationAction) use ($event): void { + if (! $this->matchNotificationFilters->handle($notificationAction->filters ?? [], $event->payload)) { + return; + } + $notificationDeliveryLogId = null; if (in_array($notificationAction->channel, [NotificationChannel::Discord, NotificationChannel::Webhook], true)) { diff --git a/app/Actions/Notifications/StoreProjectNotificationAction.php b/app/Actions/Notifications/StoreProjectNotificationAction.php index 17d3270..3ab1564 100644 --- a/app/Actions/Notifications/StoreProjectNotificationAction.php +++ b/app/Actions/Notifications/StoreProjectNotificationAction.php @@ -8,6 +8,10 @@ use Illuminate\Support\Arr; class StoreProjectNotificationAction { + public function __construct( + private readonly NormalizeNotificationFilters $normalizeNotificationFilters, + ) {} + /** * @param array{ * channel: mixed, @@ -15,6 +19,7 @@ class StoreProjectNotificationAction * delivery_url: string, * enabled: bool, * event_type: mixed, + * filters?: mixed, * message_template?: string|null, * name: string * } $validated @@ -27,6 +32,7 @@ class StoreProjectNotificationAction 'delivery_url' => trim((string) Arr::get($validated, 'delivery_url')), 'enabled' => (bool) Arr::get($validated, 'enabled'), 'event_type' => Arr::get($validated, 'event_type'), + 'filters' => $this->normalizeNotificationFilters->handle(Arr::get($validated, 'filters')), 'message_template' => $this->normalizeMessageTemplate(Arr::get($validated, 'message_template')), 'name' => trim((string) Arr::get($validated, 'name')), ]); diff --git a/app/Actions/Notifications/UpdateProjectNotificationAction.php b/app/Actions/Notifications/UpdateProjectNotificationAction.php index 59faabd..0a9c18d 100644 --- a/app/Actions/Notifications/UpdateProjectNotificationAction.php +++ b/app/Actions/Notifications/UpdateProjectNotificationAction.php @@ -8,6 +8,10 @@ use Illuminate\Support\Arr; class UpdateProjectNotificationAction { + public function __construct( + private readonly NormalizeNotificationFilters $normalizeNotificationFilters, + ) {} + /** * @param array{ * channel?: mixed, @@ -15,6 +19,7 @@ class UpdateProjectNotificationAction * delivery_url?: string|null, * enabled?: bool, * event_type?: mixed, + * filters?: mixed, * message_template?: string|null, * name?: string * } $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'); if (is_string($deliveryUrl) && trim($deliveryUrl) !== '') { diff --git a/app/Enums/NotificationFilterAttribute.php b/app/Enums/NotificationFilterAttribute.php new file mode 100644 index 0000000..bcd9305 --- /dev/null +++ b/app/Enums/NotificationFilterAttribute.php @@ -0,0 +1,64 @@ + '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 + */ + public static function options(): array + { + return array_map( + static fn (self $attribute): array => [ + 'label' => $attribute->label(), + 'value' => $attribute->value, + ], + self::cases(), + ); + } + + /** + * @return array + */ + public static function values(): array + { + return array_map( + static fn (self $attribute): string => $attribute->value, + self::cases(), + ); + } +} diff --git a/app/Enums/NotificationFilterOperator.php b/app/Enums/NotificationFilterOperator.php new file mode 100644 index 0000000..6d6cc7f --- /dev/null +++ b/app/Enums/NotificationFilterOperator.php @@ -0,0 +1,48 @@ + 'Equals', + self::NotEquals => 'Not equals', + self::Exists => 'Exists', + self::NotExists => 'Not exists', + self::In => 'In list', + }; + } + + /** + * @return array + */ + public static function options(): array + { + return array_map( + static fn (self $operator): array => [ + 'label' => $operator->label(), + 'value' => $operator->value, + ], + self::cases(), + ); + } + + /** + * @return array + */ + public static function values(): array + { + return array_map( + static fn (self $operator): string => $operator->value, + self::cases(), + ); + } +} diff --git a/app/Http/Requests/Concerns/InteractsWithNotificationFilters.php b/app/Http/Requests/Concerns/InteractsWithNotificationFilters.php new file mode 100644 index 0000000..0cdfcbc --- /dev/null +++ b/app/Http/Requests/Concerns/InteractsWithNotificationFilters.php @@ -0,0 +1,124 @@ +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 + */ + 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; + } +} diff --git a/app/Http/Requests/StoreProjectNotificationActionRequest.php b/app/Http/Requests/StoreProjectNotificationActionRequest.php index ed7f636..715716d 100644 --- a/app/Http/Requests/StoreProjectNotificationActionRequest.php +++ b/app/Http/Requests/StoreProjectNotificationActionRequest.php @@ -4,12 +4,16 @@ namespace App\Http\Requests; use App\Enums\NotificationChannel; use App\Enums\NotificationEventType; +use App\Http\Requests\Concerns\InteractsWithNotificationFilters; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; +use Illuminate\Validation\Validator; class StoreProjectNotificationActionRequest extends FormRequest { + use InteractsWithNotificationFilters; + /** * Determine if the user is authorized to make this request. */ @@ -32,9 +36,20 @@ class StoreProjectNotificationActionRequest extends FormRequest 'enabled' => ['required', 'boolean'], 'delivery_url' => ['required', 'string', 'max:2048', 'url'], 'message_template' => $this->messageTemplateRules(), + ...$this->notificationFilterRules(), ]; } + protected function prepareForValidation(): void + { + $this->prepareNotificationFiltersForValidation(); + } + + public function withValidator(Validator $validator): void + { + $this->withNotificationFilterValidator($validator); + } + /** * @return array */ diff --git a/app/Http/Requests/UpdateProjectNotificationActionRequest.php b/app/Http/Requests/UpdateProjectNotificationActionRequest.php index 0e281bf..f4fb6f2 100644 --- a/app/Http/Requests/UpdateProjectNotificationActionRequest.php +++ b/app/Http/Requests/UpdateProjectNotificationActionRequest.php @@ -4,12 +4,16 @@ namespace App\Http\Requests; use App\Enums\NotificationChannel; use App\Enums\NotificationEventType; +use App\Http\Requests\Concerns\InteractsWithNotificationFilters; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; +use Illuminate\Validation\Validator; class UpdateProjectNotificationActionRequest extends FormRequest { + use InteractsWithNotificationFilters; + /** * Determine if the user is authorized to make this request. */ @@ -32,9 +36,20 @@ class UpdateProjectNotificationActionRequest extends FormRequest 'enabled' => ['required', 'boolean'], 'delivery_url' => ['nullable', 'string', 'max:2048', 'url'], 'message_template' => $this->messageTemplateRules(), + ...$this->notificationFilterRules(), ]; } + protected function prepareForValidation(): void + { + $this->prepareNotificationFiltersForValidation(); + } + + public function withValidator(Validator $validator): void + { + $this->withNotificationFilterValidator($validator); + } + /** * @return array */ diff --git a/app/Http/Resources/NotificationActionResource.php b/app/Http/Resources/NotificationActionResource.php index d510ae2..eae6c76 100644 --- a/app/Http/Resources/NotificationActionResource.php +++ b/app/Http/Resources/NotificationActionResource.php @@ -25,6 +25,7 @@ class NotificationActionResource extends JsonResource 'channel' => $this->channel->value, 'channel_label' => $this->channel->label(), 'channel_configuration' => $this->channel_configuration ?? [], + 'filters' => $this->filters ?? [], 'has_delivery_url' => $this->resource instanceof NotificationAction && filled($this->delivery_url), 'message_template' => $this->message_template, 'created_at' => $this->created_at?->toISOString(), diff --git a/app/Models/NotificationAction.php b/app/Models/NotificationAction.php index 3e42278..e9e6cc2 100644 --- a/app/Models/NotificationAction.php +++ b/app/Models/NotificationAction.php @@ -19,6 +19,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; 'delivery_url', 'enabled', 'event_type', + 'filters', 'message_template', 'name', 'project_id', @@ -40,6 +41,7 @@ class NotificationAction extends Model 'delivery_url' => 'encrypted', 'enabled' => 'boolean', 'event_type' => NotificationEventType::class, + 'filters' => 'array', ]; } diff --git a/database/factories/NotificationActionFactory.php b/database/factories/NotificationActionFactory.php index 59daa68..70cc628 100644 --- a/database/factories/NotificationActionFactory.php +++ b/database/factories/NotificationActionFactory.php @@ -26,6 +26,7 @@ class NotificationActionFactory extends Factory 'delivery_url' => 'https://hooks.'.fake()->domainName().'/kanbangit/'.fake()->uuid(), 'enabled' => true, 'event_type' => NotificationEventType::TicketStatusChanged, + 'filters' => [], 'message_template' => null, 'name' => fake()->unique()->sentence(3), 'project_id' => Project::factory(), diff --git a/database/migrations/2026_04_24_194800_add_filters_to_notification_actions_table.php b/database/migrations/2026_04_24_194800_add_filters_to_notification_actions_table.php new file mode 100644 index 0000000..6139f3d --- /dev/null +++ b/database/migrations/2026_04_24_194800_add_filters_to_notification_actions_table.php @@ -0,0 +1,28 @@ +json('filters')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('notification_actions', function (Blueprint $table): void { + $table->dropColumn('filters'); + }); + } +}; diff --git a/resources/js/pages/projects/settings/Notifications.vue b/resources/js/pages/projects/settings/Notifications.vue index 3ab2780..410a323 100644 --- a/resources/js/pages/projects/settings/Notifications.vue +++ b/resources/js/pages/projects/settings/Notifications.vue @@ -1,6 +1,6 @@