From 71f07a8f7bd371bb6b70039fedc8c6d8a9966812 Mon Sep 17 00:00:00 2001 From: Eric Peters Date: Mon, 20 Apr 2026 22:50:22 +0200 Subject: [PATCH] Add ticket status changed notification dispatch --- .../BuildTicketStatusChangedPayload.php | 42 ++++ .../DeliverNotificationAction.php | 18 ++ ...ketStatusChangedNotificationDeliveries.php | 23 ++ .../Notifications/TicketStatusChanged.php | 15 ++ .../TicketStatusChangedPayload.php | 76 +++++++ app/Actions/Tickets/MoveProjectTicket.php | 19 ++ app/Providers/AppServiceProvider.php | 15 ++ ...tStatusChangedNotificationDispatchTest.php | 209 ++++++++++++++++++ 8 files changed, 417 insertions(+) create mode 100644 app/Actions/Notifications/BuildTicketStatusChangedPayload.php create mode 100644 app/Actions/Notifications/DeliverNotificationAction.php create mode 100644 app/Actions/Notifications/QueueTicketStatusChangedNotificationDeliveries.php create mode 100644 app/Actions/Notifications/TicketStatusChanged.php create mode 100644 app/Actions/Notifications/TicketStatusChangedPayload.php create mode 100644 tests/Feature/Notifications/TicketStatusChangedNotificationDispatchTest.php diff --git a/app/Actions/Notifications/BuildTicketStatusChangedPayload.php b/app/Actions/Notifications/BuildTicketStatusChangedPayload.php new file mode 100644 index 0000000..b923fed --- /dev/null +++ b/app/Actions/Notifications/BuildTicketStatusChangedPayload.php @@ -0,0 +1,42 @@ +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: $originStatusId, + fromStatusLabel: $originLabel, + toStatusId: $destinationStatus->id, + toStatusLabel: $destinationLabel, + occurredAt: $occurredAt, + ); + } +} diff --git a/app/Actions/Notifications/DeliverNotificationAction.php b/app/Actions/Notifications/DeliverNotificationAction.php new file mode 100644 index 0000000..55606a4 --- /dev/null +++ b/app/Actions/Notifications/DeliverNotificationAction.php @@ -0,0 +1,18 @@ +enabled() + ->where('project_id', $event->payload->projectId) + ->where('event_type', $event->payload->eventType->value) + ->get(['id']) + ->each(function (NotificationAction $notificationAction) use ($event): void { + DeliverNotificationAction::dispatch( + $notificationAction->id, + $event->payload, + ); + }); + } +} diff --git a/app/Actions/Notifications/TicketStatusChanged.php b/app/Actions/Notifications/TicketStatusChanged.php new file mode 100644 index 0000000..245d0b7 --- /dev/null +++ b/app/Actions/Notifications/TicketStatusChanged.php @@ -0,0 +1,15 @@ + + */ +final readonly class TicketStatusChangedPayload implements Arrayable +{ + public function __construct( + public NotificationEventType $eventType, + public int $projectId, + public string $projectKey, + public string $projectName, + public int $ticketId, + public string $ticketKey, + public string $ticketTitle, + public string $ticketUrl, + public ?int $actorId, + public ?string $actorName, + public int $fromStatusId, + public string $fromStatusLabel, + public int $toStatusId, + public string $toStatusLabel, + public CarbonImmutable $occurredAt, + ) {} + + /** + * @return array{ + * event_type: string, + * project: array{id: int, key: string, name: string}, + * ticket: array{id: int, key: string, title: string}, + * actor: array{id: int, name: string}|null, + * from: array{id: int, label: string}, + * to: array{id: int, label: string}, + * ticket_url: string, + * occurred_at: string + * } + */ + public function toArray(): array + { + return [ + 'event_type' => $this->eventType->value, + 'project' => [ + 'id' => $this->projectId, + 'key' => $this->projectKey, + 'name' => $this->projectName, + ], + 'ticket' => [ + 'id' => $this->ticketId, + 'key' => $this->ticketKey, + 'title' => $this->ticketTitle, + ], + 'actor' => $this->actorId === null || $this->actorName === null + ? null + : [ + 'id' => $this->actorId, + 'name' => $this->actorName, + ], + 'from' => [ + 'id' => $this->fromStatusId, + 'label' => $this->fromStatusLabel, + ], + 'to' => [ + 'id' => $this->toStatusId, + 'label' => $this->toStatusLabel, + ], + 'ticket_url' => $this->ticketUrl, + 'occurred_at' => $this->occurredAt->toISOString(), + ]; + } +} diff --git a/app/Actions/Tickets/MoveProjectTicket.php b/app/Actions/Tickets/MoveProjectTicket.php index 79b74ae..884b8b5 100644 --- a/app/Actions/Tickets/MoveProjectTicket.php +++ b/app/Actions/Tickets/MoveProjectTicket.php @@ -2,16 +2,20 @@ namespace App\Actions\Tickets; +use App\Actions\Notifications\BuildTicketStatusChangedPayload; +use App\Actions\Notifications\TicketStatusChanged; 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; class MoveProjectTicket { public function __construct( + private readonly BuildTicketStatusChangedPayload $buildTicketStatusChangedPayload, private readonly CreateTicketWorklogEntry $createTicketWorklogEntry, ) {} @@ -44,6 +48,8 @@ class MoveProjectTicket 'ticket_status_id' => $destinationStatus->id, ]); + $occurredAt = CarbonImmutable::now(); + $this->createTicketWorklogEntry->handle( $ticket->fresh(), $user, @@ -61,6 +67,19 @@ class MoveProjectTicket ], ); + TicketStatusChanged::dispatch( + $this->buildTicketStatusChangedPayload->handle( + $project, + $ticket, + $user, + $originStatusId, + $originLabel, + $destinationStatus, + $destinationLabel, + $occurredAt, + ), + ); + return [ 'destination_label' => $destinationLabel, 'moved' => true, diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 78f4da5..00ab2d1 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,8 @@ namespace App\Providers; +use App\Actions\Notifications\QueueTicketStatusChangedNotificationDeliveries; +use App\Actions\Notifications\TicketStatusChanged; use App\Actions\Permissions\PermissionService; use App\Enums\Permission; use App\Models\Project; @@ -11,6 +13,7 @@ use Carbon\CarbonImmutable; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; @@ -37,6 +40,7 @@ class AppServiceProvider extends ServiceProvider } $this->configureAuthorization(); + $this->configureEventListeners(); $this->configureDefaults(); } @@ -85,4 +89,15 @@ class AppServiceProvider extends ServiceProvider : null, ); } + + /** + * Configure application event listeners. + */ + protected function configureEventListeners(): void + { + Event::listen( + TicketStatusChanged::class, + QueueTicketStatusChangedNotificationDeliveries::class, + ); + } } diff --git a/tests/Feature/Notifications/TicketStatusChangedNotificationDispatchTest.php b/tests/Feature/Notifications/TicketStatusChangedNotificationDispatchTest.php new file mode 100644 index 0000000..ca73341 --- /dev/null +++ b/tests/Feature/Notifications/TicketStatusChangedNotificationDispatchTest.php @@ -0,0 +1,209 @@ +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); + + $doingColumn = BoardColumn::factory()->for($project)->create([ + 'name' => 'Doing', + 'position' => 1, + ]); + $doingStatus = $this->createColumnStatus($doingColumn); + $backlogStatus = $this->backlogStatus($project); + $this->allowMove($backlogStatus, $doingStatus); + + $ticket = $this->createTicket($project, $backlogStatus, 'Fix webhook dispatch'); + + $webhookAction = NotificationAction::factory() + ->webhook() + ->for($project) + ->create([ + 'name' => 'Primary webhook', + ]); + $discordAction = NotificationAction::factory() + ->discord() + ->for($project) + ->create([ + 'name' => 'Discord alerts', + ]); + $disabledAction = NotificationAction::factory() + ->disabled() + ->for($project) + ->create([ + 'name' => 'Disabled webhook', + ]); + $foreignProjectAction = NotificationAction::factory() + ->for($otherProject) + ->create([ + 'name' => 'Foreign webhook', + ]); + + DB::table('notification_actions')->insert([ + 'project_id' => $project->id, + 'name' => 'Wrong event', + 'enabled' => true, + 'event_type' => 'ticket_created', + 'channel' => NotificationChannel::Webhook->value, + 'channel_configuration' => json_encode([]), + 'message_template' => null, + 'delivery_url' => 'https://hooks.example.test/kanbangit/wrong-event', + 'created_at' => CarbonImmutable::now(), + 'updated_at' => CarbonImmutable::now(), + ]); + + $this->syncProjectPermissions($project, $user, ['edit', 'view']); + + $this->actingAs($user) + ->patch(route('projects.tickets.status.update', [$project, $ticket]), [ + 'redirect_to' => 'backlog', + 'ticket_status_id' => $doingStatus->id, + ]) + ->assertRedirect(route('projects.backlog.index', $project)) + ->assertSessionHas('status', 'CORE-1 moved to Doing.'); + + Queue::assertPushed(DeliverNotificationAction::class, 2); + Queue::assertPushed(DeliverNotificationAction::class, function (DeliverNotificationAction $job) use ($webhookAction, $project, $ticket, $user, $backlogStatus, $doingStatus): 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 === 'CORE-1' + && $job->payload->ticketTitle === 'Fix webhook dispatch' + && $job->payload->ticketUrl === route('projects.tickets.show', [$project, $ticket]) + && $job->payload->actorId === $user->id + && $job->payload->actorName === 'Erica Example' + && $job->payload->fromStatusId === $backlogStatus->id + && $job->payload->fromStatusLabel === 'Backlog' + && $job->payload->toStatusId === $doingStatus->id + && $job->payload->toStatusLabel === 'Doing' + && $job->payload->eventType === NotificationEventType::TicketStatusChanged; + }); + Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $discordAction->id); + Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $disabledAction->id); + Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $foreignProjectAction->id); + + $this->assertDatabaseHas('ticket_worklog_entries', [ + 'ticket_id' => $ticket->id, + 'actor_id' => $user->id, + 'entry_type' => 'status_changed', + 'summary' => 'Moved from Backlog to Doing.', + ]); + } + + /** + * Ensure no-op moves keep their response behavior and do not queue deliveries. + */ + public function test_no_op_status_moves_do_not_queue_notification_deliveries(): void + { + Queue::fake(); + + $user = User::factory()->create(); + $project = Project::factory()->create([ + 'key' => 'CORE', + ]); + + app(InitializeProjectTicketMetadata::class)->handle($project); + + $backlogStatus = $this->backlogStatus($project); + $ticket = $this->createTicket($project, $backlogStatus, 'Stay in backlog'); + + NotificationAction::factory() + ->for($project) + ->create([ + 'name' => 'Backlog webhook', + ]); + + $this->syncProjectPermissions($project, $user, ['edit', 'view']); + + $this->actingAs($user) + ->patch(route('projects.tickets.status.update', [$project, $ticket]), [ + 'redirect_to' => 'backlog', + 'ticket_status_id' => $backlogStatus->id, + ]) + ->assertRedirect(route('projects.backlog.index', $project)) + ->assertSessionHas('status', 'CORE-1 already belongs to Backlog.'); + + Queue::assertNothingPushed(); + $this->assertDatabaseCount('ticket_worklog_entries', 0); + } + + private function backlogStatus(Project $project): TicketStatus + { + return $project->ticketStatuses() + ->whereNull('board_column_id') + ->firstOrFail(); + } + + private function createColumnStatus(BoardColumn $boardColumn): TicketStatus + { + return TicketStatus::factory() + ->for($boardColumn->project) + ->for($boardColumn) + ->create([ + 'name' => $boardColumn->name, + 'position' => 1, + ]); + } + + private function createTicket(Project $project, TicketStatus $ticketStatus, string $title): Ticket + { + $priority = TicketPriority::factory()->for($project)->create([ + 'name' => 'High', + 'position' => 2, + ]); + + return Ticket::factory()->create([ + 'project_id' => $project->id, + 'sequence_number' => 1, + 'ticket_key' => 'CORE-1', + 'ticket_priority_id' => $priority->id, + 'ticket_status_id' => $ticketStatus->id, + 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, + 'title' => $title, + ]); + } + + private function allowMove(TicketStatus $sourceStatus, TicketStatus $targetStatus): void + { + $sourceStatus->allowedTargetStatuses()->syncWithoutDetaching([$targetStatus->id]); + } +} -- 2.54.0