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'),