From 5f74ddd964f94507ec88690039b7ff07ff889dea Mon Sep 17 00:00:00 2001 From: Koda Date: Fri, 24 Apr 2026 19:35:13 +0000 Subject: [PATCH 1/2] Add ticket create notification event support --- ...uildProjectNotificationSettingsPayload.php | 10 +- .../BuildTicketCreatedPayload.php | 50 ++++++ ...ketStatusChangedNotificationDeliveries.php | 2 +- .../RenderDiscordMessageTemplate.php | 2 +- app/Actions/Notifications/TicketCreated.php | 15 ++ .../TicketStatusChangedPayload.php | 16 +- app/Actions/Tickets/CreateProjectTicket.php | 16 ++ app/Enums/NotificationEventType.php | 2 + app/Providers/AppServiceProvider.php | 6 + .../pages/projects/settings/Notifications.vue | 8 +- .../TicketCreatedNotificationDispatchTest.php | 152 ++++++++++++++++++ ...jectNotificationSettingsControllerTest.php | 1 + 12 files changed, 262 insertions(+), 18 deletions(-) create mode 100644 app/Actions/Notifications/BuildTicketCreatedPayload.php create mode 100644 app/Actions/Notifications/TicketCreated.php create mode 100644 tests/Feature/Notifications/TicketCreatedNotificationDispatchTest.php diff --git a/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php b/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php index 995a4f3..46323b1 100644 --- a/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php +++ b/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php @@ -38,7 +38,7 @@ class BuildProjectNotificationSettingsPayload { return [ [ - 'description' => 'Absolute ticket URL for the status change event.', + 'description' => 'Absolute ticket URL for the notification event.', 'value' => '$url', ], [ @@ -46,7 +46,7 @@ class BuildProjectNotificationSettingsPayload 'value' => '$ticket_key', ], [ - 'description' => 'Ticket title from the changed ticket.', + 'description' => 'Ticket title for the notification event.', 'value' => '$ticket_title', ], [ @@ -54,15 +54,15 @@ class BuildProjectNotificationSettingsPayload 'value' => '$project_name', ], [ - 'description' => 'Origin status label before the move.', + 'description' => 'Origin status label for status moves; blank for ticket create.', 'value' => '$status_from', ], [ - 'description' => 'Destination status label after the move.', + 'description' => 'Destination or initial status label for the ticket event.', 'value' => '$status_to', ], [ - 'description' => 'Actor name when the move has a known user.', + 'description' => 'Actor name when the event has a known user.', 'value' => '$actor_name', ], ]; diff --git a/app/Actions/Notifications/BuildTicketCreatedPayload.php b/app/Actions/Notifications/BuildTicketCreatedPayload.php new file mode 100644 index 0000000..924990c --- /dev/null +++ b/app/Actions/Notifications/BuildTicketCreatedPayload.php @@ -0,0 +1,50 @@ +id, + projectKey: $project->key, + projectName: $project->name, + ticketId: $ticket->id, + ticketKey: $ticket->ticket_key, + ticketTitle: $ticket->title, + ticketUrl: route('projects.tickets.show', [$project, $ticket]), + actorId: $actor?->id, + actorName: $actor?->name, + fromStatusId: null, + fromStatusLabel: null, + toStatusId: $ticketStatus->id, + toStatusLabel: $this->statusLabel($ticketStatus), + occurredAt: $occurredAt, + ); + } + + private function statusLabel(TicketStatus $ticketStatus): string + { + $ticketStatus->loadMissing('boardColumn'); + + if ($ticketStatus->boardColumn === null || $ticketStatus->boardColumn->name === $ticketStatus->name) { + return $ticketStatus->name; + } + + return "{$ticketStatus->boardColumn->name} / {$ticketStatus->name}"; + } +} diff --git a/app/Actions/Notifications/QueueTicketStatusChangedNotificationDeliveries.php b/app/Actions/Notifications/QueueTicketStatusChangedNotificationDeliveries.php index eb195cd..42abca3 100644 --- a/app/Actions/Notifications/QueueTicketStatusChangedNotificationDeliveries.php +++ b/app/Actions/Notifications/QueueTicketStatusChangedNotificationDeliveries.php @@ -9,7 +9,7 @@ use App\Models\NotificationDeliveryLog; class QueueTicketStatusChangedNotificationDeliveries { - public function handle(TicketStatusChanged $event): void + public function handle(TicketStatusChanged|TicketCreated $event): void { NotificationAction::query() ->enabled() diff --git a/app/Actions/Notifications/RenderDiscordMessageTemplate.php b/app/Actions/Notifications/RenderDiscordMessageTemplate.php index d9be56e..e06bd20 100644 --- a/app/Actions/Notifications/RenderDiscordMessageTemplate.php +++ b/app/Actions/Notifications/RenderDiscordMessageTemplate.php @@ -13,7 +13,7 @@ class RenderDiscordMessageTemplate '$ticket_key' => $payload->ticketKey, '$ticket_title' => $payload->ticketTitle, '$project_name' => $payload->projectName, - '$status_from' => $payload->fromStatusLabel, + '$status_from' => $payload->fromStatusLabel ?? '', '$status_to' => $payload->toStatusLabel, '$actor_name' => $payload->actorName ?? '', ]); diff --git a/app/Actions/Notifications/TicketCreated.php b/app/Actions/Notifications/TicketCreated.php new file mode 100644 index 0000000..0a50410 --- /dev/null +++ b/app/Actions/Notifications/TicketCreated.php @@ -0,0 +1,15 @@ + $this->actorId, 'name' => $this->actorName, ], - 'from' => [ - 'id' => $this->fromStatusId, - 'label' => $this->fromStatusLabel, - ], + 'from' => $this->fromStatusId === null || $this->fromStatusLabel === null + ? null + : [ + 'id' => $this->fromStatusId, + 'label' => $this->fromStatusLabel, + ], 'to' => [ 'id' => $this->toStatusId, 'label' => $this->toStatusLabel, diff --git a/app/Actions/Tickets/CreateProjectTicket.php b/app/Actions/Tickets/CreateProjectTicket.php index c7b01e6..7ea665a 100644 --- a/app/Actions/Tickets/CreateProjectTicket.php +++ b/app/Actions/Tickets/CreateProjectTicket.php @@ -2,17 +2,21 @@ namespace App\Actions\Tickets; +use App\Actions\Notifications\BuildTicketCreatedPayload; +use App\Actions\Notifications\TicketCreated; use App\Enums\TicketWorklogEntryType; use App\Models\Project; use App\Models\Ticket; use App\Models\TicketStatus; use App\Models\User; +use Carbon\CarbonImmutable; use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; class CreateProjectTicket { public function __construct( + private readonly BuildTicketCreatedPayload $buildTicketCreatedPayload, private readonly CreateTicketWorklogEntry $createTicketWorklogEntry, ) {} @@ -53,6 +57,8 @@ class CreateProjectTicket 'story_points' => Arr::get($attributes, 'story_points'), ]); + $occurredAt = CarbonImmutable::now(); + if ($actor !== null) { $this->createTicketWorklogEntry->handle( $ticket, @@ -69,6 +75,16 @@ class CreateProjectTicket ); } + TicketCreated::dispatch( + $this->buildTicketCreatedPayload->handle( + $project, + $ticket, + $ticketStatus, + $actor, + $occurredAt, + ), + ); + return $ticket; }); } diff --git a/app/Enums/NotificationEventType.php b/app/Enums/NotificationEventType.php index c9e120b..39d0c20 100644 --- a/app/Enums/NotificationEventType.php +++ b/app/Enums/NotificationEventType.php @@ -5,11 +5,13 @@ namespace App\Enums; enum NotificationEventType: string { case TicketStatusChanged = 'ticket_status_changed'; + case TicketCreate = 'ticket_create'; public function label(): string { return match ($this) { self::TicketStatusChanged => 'Ticket status changed', + self::TicketCreate => 'Ticket create', }; } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 00ab2d1..fd02c24 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use App\Actions\Notifications\QueueTicketStatusChangedNotificationDeliveries; +use App\Actions\Notifications\TicketCreated; use App\Actions\Notifications\TicketStatusChanged; use App\Actions\Permissions\PermissionService; use App\Enums\Permission; @@ -99,5 +100,10 @@ class AppServiceProvider extends ServiceProvider TicketStatusChanged::class, QueueTicketStatusChangedNotificationDeliveries::class, ); + + Event::listen( + TicketCreated::class, + QueueTicketStatusChangedNotificationDeliveries::class, + ); } } diff --git a/resources/js/pages/projects/settings/Notifications.vue b/resources/js/pages/projects/settings/Notifications.vue index 45fba9c..3ab2780 100644 --- a/resources/js/pages/projects/settings/Notifications.vue +++ b/resources/js/pages/projects/settings/Notifications.vue @@ -99,7 +99,7 @@ const notificationActionTimestamp = ( @@ -152,7 +152,7 @@ const notificationActionTimestamp = ( Create notification Add a project-scoped notification action for - the supported ticket status change event. + supported ticket events. @@ -270,7 +270,7 @@ const notificationActionTimestamp = ( ? 'hidden' : 'block', ]" - placeholder="$ticket_key moved from $status_from to $status_to by $actor_name: $url" + placeholder="$ticket_key in $project_name ($status_to) by $actor_name: $url" />

{{ @@ -558,7 +558,7 @@ const notificationActionTimestamp = ( ? 'hidden' : 'block', ]" - placeholder="$ticket_key moved from $status_from to $status_to by $actor_name: $url" + placeholder="$ticket_key in $project_name ($status_to) by $actor_name: $url" />

{{ diff --git a/tests/Feature/Notifications/TicketCreatedNotificationDispatchTest.php b/tests/Feature/Notifications/TicketCreatedNotificationDispatchTest.php new file mode 100644 index 0000000..4914230 --- /dev/null +++ b/tests/Feature/Notifications/TicketCreatedNotificationDispatchTest.php @@ -0,0 +1,152 @@ +create([ + 'name' => 'Erica Example', + ]); + $project = Project::factory()->create([ + 'key' => 'CORE', + 'name' => 'Core Platform', + ]); + $otherProject = Project::factory()->create(); + + app(InitializeProjectTicketMetadata::class)->handle($project); + app(InitializeProjectTicketMetadata::class)->handle($otherProject); + + $backlogStatus = $this->backlogStatus($project); + $ticketType = $project->ticketTypes() + ->where('is_parent', false) + ->orderBy('position') + ->firstOrFail(); + $priority = $project->ticketPriorities()->firstOrFail(); + + $webhookAction = NotificationAction::factory() + ->webhook() + ->for($project) + ->create([ + 'name' => 'Primary webhook', + 'event_type' => NotificationEventType::TicketCreate, + ]); + $discordAction = NotificationAction::factory() + ->discord() + ->for($project) + ->create([ + 'name' => 'Discord alerts', + 'event_type' => NotificationEventType::TicketCreate, + ]); + $disabledAction = NotificationAction::factory() + ->disabled() + ->for($project) + ->create([ + 'name' => 'Disabled webhook', + 'event_type' => NotificationEventType::TicketCreate, + ]); + $statusChangedAction = NotificationAction::factory() + ->for($project) + ->create([ + 'name' => 'Status changed webhook', + 'event_type' => NotificationEventType::TicketStatusChanged, + ]); + $foreignProjectAction = NotificationAction::factory() + ->for($otherProject) + ->create([ + 'name' => 'Foreign webhook', + 'event_type' => NotificationEventType::TicketCreate, + ]); + + $this->syncProjectPermissions($project, $user, ['edit', 'view']); + + $response = $this->actingAs($user) + ->post(route('projects.tickets.store', $project), [ + 'acceptance_criteria' => 'Ship it.', + 'assignee_user_id' => null, + 'description' => 'Created from notification dispatch test.', + 'story_points' => 3, + 'ticket_priority_id' => $priority->id, + 'ticket_status_id' => $backlogStatus->id, + 'ticket_type_id' => $ticketType->id, + 'title' => 'Trigger ticket created notification', + ]); + + $ticket = $project->tickets() + ->where('ticket_key', 'CORE-1') + ->firstOrFail(); + + $response + ->assertRedirect(route('projects.tickets.show', [$project, $ticket])) + ->assertSessionHas('status', 'CORE-1 was created.'); + + Queue::assertPushed(DeliverNotificationAction::class, 2); + Queue::assertPushed(DeliverNotificationAction::class, function (DeliverNotificationAction $job) use ($webhookAction, $project, $ticket, $user, $backlogStatus): bool { + return $job->notificationActionId === $webhookAction->id + && $job->payload->projectId === $project->id + && $job->payload->projectKey === 'CORE' + && $job->payload->projectName === 'Core Platform' + && $job->payload->ticketId === $ticket->id + && $job->payload->ticketKey === $ticket->ticket_key + && $job->payload->ticketTitle === 'Trigger ticket created notification' + && $job->payload->ticketUrl === route('projects.tickets.show', [$project, $ticket]) + && $job->payload->actorId === $user->id + && $job->payload->actorName === 'Erica Example' + && $job->payload->fromStatusId === null + && $job->payload->fromStatusLabel === null + && $job->payload->toStatusId === $backlogStatus->id + && $job->payload->toStatusLabel === 'Backlog' + && $job->payload->eventType === NotificationEventType::TicketCreate + && $job->notificationDeliveryLogId !== null; + }); + Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $discordAction->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 === $statusChangedAction->id); + Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $foreignProjectAction->id); + + $this->assertDatabaseCount('notification_delivery_logs', 2); + $this->assertDatabaseHas('notification_delivery_logs', [ + 'notification_action_id' => $webhookAction->id, + 'delivery_status' => NotificationDeliveryStatus::Pending->value, + 'event_type' => NotificationEventType::TicketCreate->value, + 'attempt_count' => 0, + 'http_status' => null, + ]); + $this->assertDatabaseHas('notification_delivery_logs', [ + 'notification_action_id' => $discordAction->id, + 'delivery_status' => NotificationDeliveryStatus::Pending->value, + 'event_type' => NotificationEventType::TicketCreate->value, + 'attempt_count' => 0, + 'http_status' => null, + ]); + } + + private function backlogStatus(Project $project): TicketStatus + { + return $project->ticketStatuses() + ->whereNull('board_column_id') + ->firstOrFail(); + } +} diff --git a/tests/Feature/Projects/ProjectNotificationSettingsControllerTest.php b/tests/Feature/Projects/ProjectNotificationSettingsControllerTest.php index e2c317a..402d15b 100644 --- a/tests/Feature/Projects/ProjectNotificationSettingsControllerTest.php +++ b/tests/Feature/Projects/ProjectNotificationSettingsControllerTest.php @@ -47,6 +47,7 @@ class ProjectNotificationSettingsControllerTest extends TestCase ->where('notificationActions.0.updated_at', $notificationAction->updated_at?->toISOString()) ->where('channelOptions.0.value', NotificationChannel::Discord->value) ->where('eventTypeOptions.0.value', NotificationEventType::TicketStatusChanged->value) + ->where('eventTypeOptions.1.value', NotificationEventType::TicketCreate->value) ->where('templateVariables.0.value', '$url') ->where('templateVariables.6.value', '$actor_name') ->missing('notificationActions.0.delivery_url'), From c54df44ebe44d2584eb7540e6d37fdad00f9b512 Mon Sep 17 00:00:00 2001 From: Koda Date: Fri, 24 Apr 2026 20:02:22 +0000 Subject: [PATCH 2/2] Add notification filters --- ...uildProjectNotificationSettingsPayload.php | 4 + .../MatchNotificationFilters.php | 208 +++++++++ .../NormalizeNotificationFilters.php | 107 +++++ ...ketStatusChangedNotificationDeliveries.php | 10 +- .../StoreProjectNotificationAction.php | 6 + .../UpdateProjectNotificationAction.php | 11 + app/Enums/NotificationFilterAttribute.php | 64 +++ app/Enums/NotificationFilterOperator.php | 48 ++ .../InteractsWithNotificationFilters.php | 124 +++++ .../StoreProjectNotificationActionRequest.php | 15 + ...UpdateProjectNotificationActionRequest.php | 15 + .../Resources/NotificationActionResource.php | 1 + app/Models/NotificationAction.php | 2 + .../factories/NotificationActionFactory.php | 1 + ..._filters_to_notification_actions_table.php | 28 ++ .../pages/projects/settings/Notifications.vue | 425 +++++++++++++++++- .../notification-action-filter-model.ts | 5 + .../types/models/notification-action-model.ts | 3 + resources/js/types/page-props.ts | 2 + .../Feature/Models/NotificationActionTest.php | 2 + ...rojectNotificationActionManagementTest.php | 28 ++ .../TicketCreatedNotificationDispatchTest.php | 43 +- ...tStatusChangedNotificationDispatchTest.php | 95 +++- ...jectNotificationSettingsControllerTest.php | 48 ++ 24 files changed, 1288 insertions(+), 7 deletions(-) create mode 100644 app/Actions/Notifications/MatchNotificationFilters.php create mode 100644 app/Actions/Notifications/NormalizeNotificationFilters.php create mode 100644 app/Enums/NotificationFilterAttribute.php create mode 100644 app/Enums/NotificationFilterOperator.php create mode 100644 app/Http/Requests/Concerns/InteractsWithNotificationFilters.php create mode 100644 database/migrations/2026_04_24_194800_add_filters_to_notification_actions_table.php create mode 100644 resources/js/types/models/notification-action-filter-model.ts 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 @@