diff --git a/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php b/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php index ea021c9..cf14a03 100644 --- a/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php +++ b/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php @@ -40,7 +40,7 @@ class BuildProjectNotificationSettingsPayload { return [ [ - 'description' => 'Absolute ticket URL for the status change event.', + 'description' => 'Absolute ticket URL for the notification event.', 'value' => '$url', ], [ @@ -48,7 +48,7 @@ class BuildProjectNotificationSettingsPayload 'value' => '$ticket_key', ], [ - 'description' => 'Ticket title from the changed ticket.', + 'description' => 'Ticket title for the notification event.', 'value' => '$ticket_title', ], [ @@ -56,15 +56,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..0bdb9fc --- /dev/null +++ b/app/Actions/Notifications/BuildTicketCreatedPayload.php @@ -0,0 +1,46 @@ +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, + 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/BuildTicketStatusChangedPayload.php b/app/Actions/Notifications/BuildTicketStatusChangedPayload.php index b923fed..96e2ebd 100644 --- a/app/Actions/Notifications/BuildTicketStatusChangedPayload.php +++ b/app/Actions/Notifications/BuildTicketStatusChangedPayload.php @@ -2,7 +2,6 @@ namespace App\Actions\Notifications; -use App\Enums\NotificationEventType; use App\Models\Project; use App\Models\Ticket; use App\Models\TicketStatus; @@ -22,7 +21,6 @@ class BuildTicketStatusChangedPayload CarbonImmutable $occurredAt, ): TicketStatusChangedPayload { return new TicketStatusChangedPayload( - eventType: NotificationEventType::TicketStatusChanged, projectId: $project->id, projectKey: $project->key, projectName: $project->name, diff --git a/app/Actions/Notifications/DeliverNotificationAction.php b/app/Actions/Notifications/DeliverNotificationAction.php index 37bbf75..d614767 100644 --- a/app/Actions/Notifications/DeliverNotificationAction.php +++ b/app/Actions/Notifications/DeliverNotificationAction.php @@ -28,7 +28,7 @@ class DeliverNotificationAction implements ShouldQueue public function __construct( public int $notificationActionId, - public TicketStatusChangedPayload $payload, + public NotificationEvent $event, public ?int $notificationDeliveryLogId, ) {} @@ -60,7 +60,7 @@ class DeliverNotificationAction implements ShouldQueue if ($notificationAction->channel === NotificationChannel::Discord) { $discordMessage = app(RenderDiscordMessageTemplate::class)->handle( $notificationAction->message_template, - $this->payload, + $this->event, ); $validationError = $this->discordMessageValidationError($discordMessage); @@ -245,7 +245,7 @@ class DeliverNotificationAction implements ShouldQueue 'attempt_count' => 0, 'channel' => $notificationAction->channel, 'delivery_status' => NotificationDeliveryStatus::Pending, - 'event_type' => $this->payload->eventType, + 'event_type' => $this->event->eventType(), 'notification_action_id' => $notificationAction->id, 'payload_metadata' => $this->payloadMetadata(), ]); @@ -334,7 +334,7 @@ class DeliverNotificationAction implements ShouldQueue ->asJson() ->connectTimeout(5) ->timeout(10) - ->post($notificationAction->delivery_url, $this->payload->toArray()); + ->post($notificationAction->delivery_url, $this->event->payload()->toArray()); } private function hasAttemptsRemaining(int $attemptCount): bool @@ -398,14 +398,11 @@ class DeliverNotificationAction implements ShouldQueue } /** - * @return array{project_key: string, ticket_key: string} + * @return array */ private function payloadMetadata(): array { - return [ - 'project_key' => $this->payload->projectKey, - 'ticket_key' => $this->payload->ticketKey, - ]; + return $this->event->payload()->deliveryMetadata(); } private function excerpt(?string $value): ?string diff --git a/app/Actions/Notifications/NotificationEvent.php b/app/Actions/Notifications/NotificationEvent.php new file mode 100644 index 0000000..0af8390 --- /dev/null +++ b/app/Actions/Notifications/NotificationEvent.php @@ -0,0 +1,12 @@ + + */ +interface NotificationEventPayload extends Arrayable +{ + public function projectId(): int; + + /** + * @return array + */ + public function deliveryMetadata(): array; + + /** + * @return array + */ + public function discordTemplateVariables(): array; +} diff --git a/app/Actions/Notifications/QueueTicketStatusChangedNotificationDeliveries.php b/app/Actions/Notifications/QueueNotificationEventDeliveries.php similarity index 66% rename from app/Actions/Notifications/QueueTicketStatusChangedNotificationDeliveries.php rename to app/Actions/Notifications/QueueNotificationEventDeliveries.php index eb195cd..513a234 100644 --- a/app/Actions/Notifications/QueueTicketStatusChangedNotificationDeliveries.php +++ b/app/Actions/Notifications/QueueNotificationEventDeliveries.php @@ -7,16 +7,19 @@ use App\Enums\NotificationDeliveryStatus; use App\Models\NotificationAction; use App\Models\NotificationDeliveryLog; -class QueueTicketStatusChangedNotificationDeliveries +class QueueNotificationEventDeliveries { - public function handle(TicketStatusChanged $event): void + public function handle(NotificationEvent $event): void { + $payload = $event->payload(); + $eventType = $event->eventType(); + NotificationAction::query() ->enabled() - ->where('project_id', $event->payload->projectId) - ->where('event_type', $event->payload->eventType->value) + ->where('project_id', $payload->projectId()) + ->where('event_type', $eventType->value) ->get(['channel', 'id']) - ->each(function (NotificationAction $notificationAction) use ($event): void { + ->each(function (NotificationAction $notificationAction) use ($event, $payload): void { $notificationDeliveryLogId = null; if (in_array($notificationAction->channel, [NotificationChannel::Discord, NotificationChannel::Webhook], true)) { @@ -24,18 +27,15 @@ class QueueTicketStatusChangedNotificationDeliveries 'attempt_count' => 0, 'channel' => $notificationAction->channel, 'delivery_status' => NotificationDeliveryStatus::Pending, - 'event_type' => $event->payload->eventType, + 'event_type' => $event->eventType(), 'notification_action_id' => $notificationAction->id, - 'payload_metadata' => [ - 'project_key' => $event->payload->projectKey, - 'ticket_key' => $event->payload->ticketKey, - ], + 'payload_metadata' => $payload->deliveryMetadata(), ])->id; } DeliverNotificationAction::dispatch( notificationActionId: $notificationAction->id, - payload: $event->payload, + event: $event, notificationDeliveryLogId: $notificationDeliveryLogId, ); }); diff --git a/app/Actions/Notifications/RenderDiscordMessageTemplate.php b/app/Actions/Notifications/RenderDiscordMessageTemplate.php index d9be56e..aafd4e0 100644 --- a/app/Actions/Notifications/RenderDiscordMessageTemplate.php +++ b/app/Actions/Notifications/RenderDiscordMessageTemplate.php @@ -6,16 +6,8 @@ use Illuminate\Support\Str; class RenderDiscordMessageTemplate { - public function handle(?string $messageTemplate, TicketStatusChangedPayload $payload): string + public function handle(?string $messageTemplate, NotificationEvent $event): string { - return strtr((string) Str::of((string) $messageTemplate), [ - '$url' => $payload->ticketUrl, - '$ticket_key' => $payload->ticketKey, - '$ticket_title' => $payload->ticketTitle, - '$project_name' => $payload->projectName, - '$status_from' => $payload->fromStatusLabel, - '$status_to' => $payload->toStatusLabel, - '$actor_name' => $payload->actorName ?? '', - ]); + return strtr((string) Str::of((string) $messageTemplate), $event->payload()->discordTemplateVariables()); } } diff --git a/app/Actions/Notifications/TicketCreated.php b/app/Actions/Notifications/TicketCreated.php new file mode 100644 index 0000000..62bf62c --- /dev/null +++ b/app/Actions/Notifications/TicketCreated.php @@ -0,0 +1,26 @@ +payload; + } +} diff --git a/app/Actions/Notifications/TicketCreatedPayload.php b/app/Actions/Notifications/TicketCreatedPayload.php new file mode 100644 index 0000000..f9373f0 --- /dev/null +++ b/app/Actions/Notifications/TicketCreatedPayload.php @@ -0,0 +1,105 @@ +fromStatusId = null; + $this->fromStatusLabel = null; + } + + public function projectId(): int + { + return $this->projectId; + } + + /** + * @return array + */ + public function deliveryMetadata(): array + { + return [ + 'project_key' => $this->projectKey, + 'ticket_key' => $this->ticketKey, + ]; + } + + /** + * @return array + */ + public function discordTemplateVariables(): array + { + return [ + '$url' => $this->ticketUrl, + '$ticket_key' => $this->ticketKey, + '$ticket_title' => $this->ticketTitle, + '$project_name' => $this->projectName, + '$status_from' => '', + '$status_to' => $this->toStatusLabel, + '$actor_name' => $this->actorName ?? '', + ]; + } + + /** + * @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: null, + * to: array{id: int, label: string}, + * ticket_url: string, + * occurred_at: string + * } + */ + public function toArray(): array + { + return [ + 'event_type' => NotificationEventType::TicketCreate->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' => null, + 'to' => [ + 'id' => $this->toStatusId, + 'label' => $this->toStatusLabel, + ], + 'ticket_url' => $this->ticketUrl, + 'occurred_at' => $this->occurredAt->toISOString(), + ]; + } +} diff --git a/app/Actions/Notifications/TicketStatusChanged.php b/app/Actions/Notifications/TicketStatusChanged.php index 245d0b7..41e2aa5 100644 --- a/app/Actions/Notifications/TicketStatusChanged.php +++ b/app/Actions/Notifications/TicketStatusChanged.php @@ -2,14 +2,25 @@ namespace App\Actions\Notifications; +use App\Enums\NotificationEventType; use Illuminate\Contracts\Events\ShouldDispatchAfterCommit; use Illuminate\Foundation\Events\Dispatchable; -final readonly class TicketStatusChanged implements ShouldDispatchAfterCommit +final readonly class TicketStatusChanged implements NotificationEvent, ShouldDispatchAfterCommit { use Dispatchable; public function __construct( public TicketStatusChangedPayload $payload, ) {} + + public function eventType(): NotificationEventType + { + return NotificationEventType::TicketStatusChanged; + } + + public function payload(): NotificationEventPayload + { + return $this->payload; + } } diff --git a/app/Actions/Notifications/TicketStatusChangedPayload.php b/app/Actions/Notifications/TicketStatusChangedPayload.php index 0bc7cc1..cc6997b 100644 --- a/app/Actions/Notifications/TicketStatusChangedPayload.php +++ b/app/Actions/Notifications/TicketStatusChangedPayload.php @@ -4,15 +4,10 @@ namespace App\Actions\Notifications; use App\Enums\NotificationEventType; use Carbon\CarbonImmutable; -use Illuminate\Contracts\Support\Arrayable; -/** - * @implements Arrayable - */ -final readonly class TicketStatusChangedPayload implements Arrayable +final readonly class TicketStatusChangedPayload implements NotificationEventPayload { public function __construct( - public NotificationEventType $eventType, public int $projectId, public string $projectKey, public string $projectName, @@ -29,6 +24,38 @@ final readonly class TicketStatusChangedPayload implements Arrayable public CarbonImmutable $occurredAt, ) {} + public function projectId(): int + { + return $this->projectId; + } + + /** + * @return array + */ + public function deliveryMetadata(): array + { + return [ + 'project_key' => $this->projectKey, + 'ticket_key' => $this->ticketKey, + ]; + } + + /** + * @return array + */ + public function discordTemplateVariables(): array + { + return [ + '$url' => $this->ticketUrl, + '$ticket_key' => $this->ticketKey, + '$ticket_title' => $this->ticketTitle, + '$project_name' => $this->projectName, + '$status_from' => $this->fromStatusLabel, + '$status_to' => $this->toStatusLabel, + '$actor_name' => $this->actorName ?? '', + ]; + } + /** * @return array{ * event_type: string, @@ -44,7 +71,7 @@ final readonly class TicketStatusChangedPayload implements Arrayable public function toArray(): array { return [ - 'event_type' => $this->eventType->value, + 'event_type' => NotificationEventType::TicketStatusChanged->value, 'project' => [ 'id' => $this->projectId, 'key' => $this->projectKey, 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 a97e52d..629e2af 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..c8685f8 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,7 +2,8 @@ namespace App\Providers; -use App\Actions\Notifications\QueueTicketStatusChangedNotificationDeliveries; +use App\Actions\Notifications\QueueNotificationEventDeliveries; +use App\Actions\Notifications\TicketCreated; use App\Actions\Notifications\TicketStatusChanged; use App\Actions\Permissions\PermissionService; use App\Enums\Permission; @@ -97,7 +98,12 @@ class AppServiceProvider extends ServiceProvider { Event::listen( TicketStatusChanged::class, - QueueTicketStatusChangedNotificationDeliveries::class, + QueueNotificationEventDeliveries::class, + ); + + Event::listen( + TicketCreated::class, + QueueNotificationEventDeliveries::class, ); } } diff --git a/resources/js/pages/projects/settings/Notifications.vue b/resources/js/pages/projects/settings/Notifications.vue index d415a48..e113359 100644 --- a/resources/js/pages/projects/settings/Notifications.vue +++ b/resources/js/pages/projects/settings/Notifications.vue @@ -134,7 +134,7 @@ const notificationActionTimestamp = ( @@ -186,8 +186,8 @@ const notificationActionTimestamp = ( Create notification - Add a project-scoped notification action for the - supported ticket status change event. + Add a project-scoped notification action for + supported ticket events. @@ -357,7 +357,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" />

{{ @@ -685,7 +685,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/Api/TicketApiTest.php b/tests/Feature/Api/TicketApiTest.php index 5015a28..937efab 100644 --- a/tests/Feature/Api/TicketApiTest.php +++ b/tests/Feature/Api/TicketApiTest.php @@ -233,8 +233,8 @@ class TicketApiTest extends TestCase Queue::assertPushed(DeliverNotificationAction::class, function (DeliverNotificationAction $job) use ($notificationAction): bool { return $job->notificationActionId === $notificationAction->id - && $job->payload->ticketKey === 'CORE-1' - && $job->payload->toStatusLabel === 'Doing' + && $job->event->payload()->ticketKey === 'CORE-1' + && $job->event->payload()->toStatusLabel === 'Doing' && $job->notificationDeliveryLogId !== null; }); diff --git a/tests/Feature/Mcp/KanbanGitMcpTest.php b/tests/Feature/Mcp/KanbanGitMcpTest.php index 1a44e9b..f5dde21 100644 --- a/tests/Feature/Mcp/KanbanGitMcpTest.php +++ b/tests/Feature/Mcp/KanbanGitMcpTest.php @@ -359,8 +359,8 @@ class KanbanGitMcpTest extends TestCase Queue::assertPushed(DeliverNotificationAction::class, function (DeliverNotificationAction $job) use ($notificationAction): bool { return $job->notificationActionId === $notificationAction->id - && $job->payload->ticketKey === 'CORE-1' - && $job->payload->toStatusLabel === 'Doing' + && $job->event->payload()->ticketKey === 'CORE-1' + && $job->event->payload()->toStatusLabel === 'Doing' && $job->notificationDeliveryLogId !== null; }); diff --git a/tests/Feature/Notifications/DeliverNotificationActionTest.php b/tests/Feature/Notifications/DeliverNotificationActionTest.php index b73eeaf..d693f5a 100644 --- a/tests/Feature/Notifications/DeliverNotificationActionTest.php +++ b/tests/Feature/Notifications/DeliverNotificationActionTest.php @@ -3,9 +3,9 @@ namespace Tests\Feature\Notifications; use App\Actions\Notifications\DeliverNotificationAction; +use App\Actions\Notifications\TicketStatusChanged; use App\Actions\Notifications\TicketStatusChangedPayload; use App\Enums\NotificationDeliveryStatus; -use App\Enums\NotificationEventType; use App\Models\NotificationAction; use App\Models\NotificationDeliveryLog; use Carbon\CarbonImmutable; @@ -35,7 +35,7 @@ class DeliverNotificationActionTest extends TestCase (new DeliverNotificationAction( notificationActionId: $notificationAction->id, - payload: $this->payload(), + event: $this->event(), notificationDeliveryLogId: $deliveryLog->id, ))->handle(); @@ -77,7 +77,7 @@ class DeliverNotificationActionTest extends TestCase (new DeliverNotificationAction( notificationActionId: $notificationAction->id, - payload: $this->payload(), + event: $this->event(), notificationDeliveryLogId: $deliveryLog->id, ))->handle(); @@ -127,7 +127,7 @@ class DeliverNotificationActionTest extends TestCase (new DeliverNotificationAction( notificationActionId: $notificationAction->id, - payload: $this->payload(), + event: $this->event(), notificationDeliveryLogId: $deliveryLog->id, ))->handle(); @@ -162,7 +162,7 @@ class DeliverNotificationActionTest extends TestCase $deliveryLog = $this->createPendingDeliveryLog($notificationAction); $job = (new DeliverNotificationAction( notificationActionId: $notificationAction->id, - payload: $this->payload(), + event: $this->event(), notificationDeliveryLogId: $deliveryLog->id, ))->withFakeQueueInteractions(); @@ -200,7 +200,7 @@ class DeliverNotificationActionTest extends TestCase $deliveryLog = $this->createPendingDeliveryLog($notificationAction); $job = (new DeliverNotificationAction( notificationActionId: $notificationAction->id, - payload: $this->payload(), + event: $this->event(), notificationDeliveryLogId: $deliveryLog->id, ))->withFakeQueueInteractions(); @@ -237,7 +237,7 @@ class DeliverNotificationActionTest extends TestCase $deliveryLog = $this->createPendingDeliveryLog($notificationAction); $job = (new DeliverNotificationAction( notificationActionId: $notificationAction->id, - payload: $this->payload(), + event: $this->event(), notificationDeliveryLogId: $deliveryLog->id, ))->withFakeQueueInteractions(); @@ -274,7 +274,7 @@ class DeliverNotificationActionTest extends TestCase $deliveryLog = $this->createPendingDeliveryLog($notificationAction); $job = (new DeliverNotificationAction( notificationActionId: $notificationAction->id, - payload: $this->payload(), + event: $this->event(), notificationDeliveryLogId: $deliveryLog->id, ))->withFakeQueueInteractions(); @@ -310,7 +310,7 @@ class DeliverNotificationActionTest extends TestCase $deliveryLog = $this->createPendingDeliveryLog($notificationAction); $job = (new DeliverNotificationAction( notificationActionId: $notificationAction->id, - payload: $this->payload(), + event: $this->event(), notificationDeliveryLogId: $deliveryLog->id, ))->withFakeQueueInteractions(); @@ -349,7 +349,7 @@ class DeliverNotificationActionTest extends TestCase $deliveryLog = $this->createPendingDeliveryLog($notificationAction); $job = (new DeliverNotificationAction( notificationActionId: $notificationAction->id, - payload: $this->payload(), + event: $this->event(), notificationDeliveryLogId: $deliveryLog->id, ))->withFakeQueueInteractions(); @@ -385,7 +385,7 @@ class DeliverNotificationActionTest extends TestCase $deliveryLog = $this->createPendingDeliveryLog($notificationAction); $job = (new DeliverNotificationAction( notificationActionId: $notificationAction->id, - payload: $this->payload(), + event: $this->event(), notificationDeliveryLogId: $deliveryLog->id, ))->withFakeQueueInteractions(); @@ -413,7 +413,7 @@ class DeliverNotificationActionTest extends TestCase $deliveryLog = $this->createPendingDeliveryLog($notificationAction); $job = (new DeliverNotificationAction( notificationActionId: $notificationAction->id, - payload: $this->payload(actorName: null), + event: $this->event(actorName: null), notificationDeliveryLogId: $deliveryLog->id, ))->withFakeQueueInteractions(); @@ -445,7 +445,7 @@ class DeliverNotificationActionTest extends TestCase $deliveryLog = $this->createPendingDeliveryLog($notificationAction); $job = (new DeliverNotificationAction( notificationActionId: $notificationAction->id, - payload: $this->payload(ticketTitle: str_repeat('A', 2001)), + event: $this->event(ticketTitle: str_repeat('A', 2001)), notificationDeliveryLogId: $deliveryLog->id, ))->withFakeQueueInteractions(); @@ -482,7 +482,7 @@ class DeliverNotificationActionTest extends TestCase $deliveryLog = $this->createPendingDeliveryLog($notificationAction); $job = (new DeliverNotificationAction( notificationActionId: $notificationAction->id, - payload: $this->payload(), + event: $this->event(), notificationDeliveryLogId: $deliveryLog->id, ))->withFakeQueueInteractions(); @@ -508,7 +508,7 @@ class DeliverNotificationActionTest extends TestCase { $job = new DeliverNotificationAction( notificationActionId: 1, - payload: $this->payload(), + event: $this->event(), notificationDeliveryLogId: 1, ); @@ -530,12 +530,11 @@ class DeliverNotificationActionTest extends TestCase ]); } - private function payload( + private function event( ?string $actorName = 'Erica Example', string $ticketTitle = 'Fix webhook delivery', - ): TicketStatusChangedPayload { - return new TicketStatusChangedPayload( - eventType: NotificationEventType::TicketStatusChanged, + ): TicketStatusChanged { + return new TicketStatusChanged(new TicketStatusChangedPayload( projectId: 10, projectKey: 'CORE', projectName: 'Core Platform', @@ -550,7 +549,7 @@ class DeliverNotificationActionTest extends TestCase toStatusId: 2, toStatusLabel: 'Doing', occurredAt: CarbonImmutable::parse('2026-04-24T09:15:30+00:00'), - ); + )); } /** diff --git a/tests/Feature/Notifications/RenderDiscordMessageTemplateTest.php b/tests/Feature/Notifications/RenderDiscordMessageTemplateTest.php index 283b685..6f21c71 100644 --- a/tests/Feature/Notifications/RenderDiscordMessageTemplateTest.php +++ b/tests/Feature/Notifications/RenderDiscordMessageTemplateTest.php @@ -3,8 +3,8 @@ namespace Tests\Feature\Notifications; use App\Actions\Notifications\RenderDiscordMessageTemplate; +use App\Actions\Notifications\TicketStatusChanged; use App\Actions\Notifications\TicketStatusChangedPayload; -use App\Enums\NotificationEventType; use Carbon\CarbonImmutable; use PHPUnit\Framework\Attributes\DataProvider; use Tests\TestCase; @@ -54,7 +54,7 @@ class RenderDiscordMessageTemplateTest extends TestCase #[DataProvider('supportedVariableProvider')] public function test_it_replaces_supported_template_variables(string $template, string $expected): void { - $renderedMessage = app(RenderDiscordMessageTemplate::class)->handle($template, $this->payload()); + $renderedMessage = app(RenderDiscordMessageTemplate::class)->handle($template, $this->event()); $this->assertSame($expected, $renderedMessage); } @@ -66,16 +66,15 @@ class RenderDiscordMessageTemplateTest extends TestCase { $renderedMessage = app(RenderDiscordMessageTemplate::class)->handle( '[$ticket_key] $unknown moved to $status_to', - $this->payload(), + $this->event(), ); $this->assertSame('[CORE-42] $unknown moved to Doing', $renderedMessage); } - private function payload(): TicketStatusChangedPayload + private function event(): TicketStatusChanged { - return new TicketStatusChangedPayload( - eventType: NotificationEventType::TicketStatusChanged, + return new TicketStatusChanged(new TicketStatusChangedPayload( projectId: 10, projectKey: 'CORE', projectName: 'Core Platform', @@ -90,6 +89,6 @@ class RenderDiscordMessageTemplateTest extends TestCase toStatusId: 2, toStatusLabel: 'Doing', occurredAt: CarbonImmutable::parse('2026-04-24T09:15:30+00:00'), - ); + )); } } diff --git a/tests/Feature/Notifications/TicketCreatedNotificationDispatchTest.php b/tests/Feature/Notifications/TicketCreatedNotificationDispatchTest.php new file mode 100644 index 0000000..b8bec34 --- /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->event->payload()->projectId === $project->id + && $job->event->payload()->projectKey === 'CORE' + && $job->event->payload()->projectName === 'Core Platform' + && $job->event->payload()->ticketId === $ticket->id + && $job->event->payload()->ticketKey === $ticket->ticket_key + && $job->event->payload()->ticketTitle === 'Trigger ticket created notification' + && $job->event->payload()->ticketUrl === route('projects.tickets.show', [$project, $ticket]) + && $job->event->payload()->actorId === $user->id + && $job->event->payload()->actorName === 'Erica Example' + && $job->event->payload()->fromStatusId === null + && $job->event->payload()->fromStatusLabel === null + && $job->event->payload()->toStatusId === $backlogStatus->id + && $job->event->payload()->toStatusLabel === 'Backlog' + && $job->event->eventType() === NotificationEventType::TicketCreate + && $job->notificationDeliveryLogId !== null; + }); + Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $discordAction->id + && $job->event->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/Notifications/TicketStatusChangedNotificationDispatchTest.php b/tests/Feature/Notifications/TicketStatusChangedNotificationDispatchTest.php index da8aa82..0aedc19 100644 --- a/tests/Feature/Notifications/TicketStatusChangedNotificationDispatchTest.php +++ b/tests/Feature/Notifications/TicketStatusChangedNotificationDispatchTest.php @@ -82,7 +82,7 @@ class TicketStatusChangedNotificationDispatchTest extends TestCase 'project_id' => $project->id, 'name' => 'Wrong event', 'enabled' => true, - 'event_type' => 'ticket_created', + 'event_type' => NotificationEventType::TicketCreate->value, 'channel' => NotificationChannel::Webhook->value, 'channel_configuration' => json_encode([]), 'message_template' => null, @@ -104,20 +104,20 @@ class TicketStatusChangedNotificationDispatchTest extends TestCase 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 + && $job->event->payload()->projectId === $project->id + && $job->event->payload()->projectKey === 'CORE' + && $job->event->payload()->projectName === 'Core Platform' + && $job->event->payload()->ticketId === $ticket->id + && $job->event->payload()->ticketKey === 'CORE-1' + && $job->event->payload()->ticketTitle === 'Fix webhook dispatch' + && $job->event->payload()->ticketUrl === route('projects.tickets.show', [$project, $ticket]) + && $job->event->payload()->actorId === $user->id + && $job->event->payload()->actorName === 'Erica Example' + && $job->event->payload()->fromStatusId === $backlogStatus->id + && $job->event->payload()->fromStatusLabel === 'Backlog' + && $job->event->payload()->toStatusId === $doingStatus->id + && $job->event->payload()->toStatusLabel === 'Doing' + && $job->event->eventType() === NotificationEventType::TicketStatusChanged && $job->notificationDeliveryLogId !== null; }); Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $discordAction->id diff --git a/tests/Feature/Projects/ProjectNotificationSettingsControllerTest.php b/tests/Feature/Projects/ProjectNotificationSettingsControllerTest.php index 42759db..564eb9b 100644 --- a/tests/Feature/Projects/ProjectNotificationSettingsControllerTest.php +++ b/tests/Feature/Projects/ProjectNotificationSettingsControllerTest.php @@ -52,6 +52,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'),