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 = (
{{ @@ -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'),