From 5f74ddd964f94507ec88690039b7ff07ff889dea Mon Sep 17 00:00:00 2001 From: Koda Date: Fri, 24 Apr 2026 19:35:13 +0000 Subject: [PATCH 1/5] 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 61505ace23af49338396696fc6ae3b7ea380d4d9 Mon Sep 17 00:00:00 2001 From: Koda Date: Sat, 25 Apr 2026 21:54:28 +0000 Subject: [PATCH 2/5] Refactor notification event payloads and listener naming --- .../BuildTicketCreatedPayload.php | 8 +- .../BuildTicketStatusChangedPayload.php | 2 - .../DeliverNotificationAction.php | 15 +-- .../Notifications/NotificationEvent.php | 8 ++ .../NotificationEventPayload.php | 26 ++++ ...p => QueueNotificationEventDeliveries.php} | 21 ++-- .../RenderDiscordMessageTemplate.php | 12 +- app/Actions/Notifications/TicketCreated.php | 9 +- .../Notifications/TicketCreatedPayload.php | 113 ++++++++++++++++++ .../Notifications/TicketStatusChanged.php | 7 +- .../TicketStatusChangedPayload.php | 66 +++++++--- app/Providers/AppServiceProvider.php | 6 +- .../DeliverNotificationActionTest.php | 37 +++--- .../RenderDiscordMessageTemplateTest.php | 13 +- .../TicketCreatedNotificationDispatchTest.php | 30 ++--- ...tStatusChangedNotificationDispatchTest.php | 28 ++--- 16 files changed, 286 insertions(+), 115 deletions(-) create mode 100644 app/Actions/Notifications/NotificationEvent.php create mode 100644 app/Actions/Notifications/NotificationEventPayload.php rename app/Actions/Notifications/{QueueTicketStatusChangedNotificationDeliveries.php => QueueNotificationEventDeliveries.php} (65%) create mode 100644 app/Actions/Notifications/TicketCreatedPayload.php diff --git a/app/Actions/Notifications/BuildTicketCreatedPayload.php b/app/Actions/Notifications/BuildTicketCreatedPayload.php index 924990c..0bdb9fc 100644 --- a/app/Actions/Notifications/BuildTicketCreatedPayload.php +++ b/app/Actions/Notifications/BuildTicketCreatedPayload.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; @@ -17,9 +16,8 @@ class BuildTicketCreatedPayload TicketStatus $ticketStatus, ?User $actor, CarbonImmutable $occurredAt, - ): TicketStatusChangedPayload { - return new TicketStatusChangedPayload( - eventType: NotificationEventType::TicketCreate, + ): TicketCreatedPayload { + return new TicketCreatedPayload( projectId: $project->id, projectKey: $project->key, projectName: $project->name, @@ -29,8 +27,6 @@ class BuildTicketCreatedPayload 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, 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..20c7990 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->payload()->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..16dad08 --- /dev/null +++ b/app/Actions/Notifications/NotificationEvent.php @@ -0,0 +1,8 @@ + + */ +interface NotificationEventPayload extends Arrayable +{ + public function eventType(): NotificationEventType; + + 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 65% rename from app/Actions/Notifications/QueueTicketStatusChangedNotificationDeliveries.php rename to app/Actions/Notifications/QueueNotificationEventDeliveries.php index 42abca3..68ab3c8 100644 --- a/app/Actions/Notifications/QueueTicketStatusChangedNotificationDeliveries.php +++ b/app/Actions/Notifications/QueueNotificationEventDeliveries.php @@ -7,16 +7,18 @@ use App\Enums\NotificationDeliveryStatus; use App\Models\NotificationAction; use App\Models\NotificationDeliveryLog; -class QueueTicketStatusChangedNotificationDeliveries +class QueueNotificationEventDeliveries { - public function handle(TicketStatusChanged|TicketCreated $event): void + public function handle(NotificationEvent $event): void { + $payload = $event->payload(); + NotificationAction::query() ->enabled() - ->where('project_id', $event->payload->projectId) - ->where('event_type', $event->payload->eventType->value) + ->where('project_id', $payload->projectId()) + ->where('event_type', $payload->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 +26,15 @@ class QueueTicketStatusChangedNotificationDeliveries 'attempt_count' => 0, 'channel' => $notificationAction->channel, 'delivery_status' => NotificationDeliveryStatus::Pending, - 'event_type' => $event->payload->eventType, + 'event_type' => $payload->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 e06bd20..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 index 0a50410..7d5b192 100644 --- a/app/Actions/Notifications/TicketCreated.php +++ b/app/Actions/Notifications/TicketCreated.php @@ -5,11 +5,16 @@ namespace App\Actions\Notifications; use Illuminate\Contracts\Events\ShouldDispatchAfterCommit; use Illuminate\Foundation\Events\Dispatchable; -final readonly class TicketCreated implements ShouldDispatchAfterCommit +final readonly class TicketCreated implements NotificationEvent, ShouldDispatchAfterCommit { use Dispatchable; public function __construct( - public TicketStatusChangedPayload $payload, + public TicketCreatedPayload $payload, ) {} + + public function payload(): NotificationEventPayload + { + return $this->payload; + } } diff --git a/app/Actions/Notifications/TicketCreatedPayload.php b/app/Actions/Notifications/TicketCreatedPayload.php new file mode 100644 index 0000000..a741cbc --- /dev/null +++ b/app/Actions/Notifications/TicketCreatedPayload.php @@ -0,0 +1,113 @@ +eventType = NotificationEventType::TicketCreate; + $this->fromStatusId = null; + $this->fromStatusLabel = null; + } + + public function eventType(): NotificationEventType + { + return $this->eventType; + } + + 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' => $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' => 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..b31bb55 100644 --- a/app/Actions/Notifications/TicketStatusChanged.php +++ b/app/Actions/Notifications/TicketStatusChanged.php @@ -5,11 +5,16 @@ namespace App\Actions\Notifications; 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 payload(): NotificationEventPayload + { + return $this->payload; + } } diff --git a/app/Actions/Notifications/TicketStatusChangedPayload.php b/app/Actions/Notifications/TicketStatusChangedPayload.php index 87cdd22..fab97e5 100644 --- a/app/Actions/Notifications/TicketStatusChangedPayload.php +++ b/app/Actions/Notifications/TicketStatusChangedPayload.php @@ -4,15 +4,12 @@ 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 NotificationEventType $eventType; + public function __construct( - public NotificationEventType $eventType, public int $projectId, public string $projectKey, public string $projectName, @@ -22,12 +19,51 @@ final readonly class TicketStatusChangedPayload implements Arrayable public string $ticketUrl, public ?int $actorId, public ?string $actorName, - public ?int $fromStatusId, - public ?string $fromStatusLabel, + public int $fromStatusId, + public string $fromStatusLabel, public int $toStatusId, public string $toStatusLabel, public CarbonImmutable $occurredAt, - ) {} + ) { + $this->eventType = NotificationEventType::TicketStatusChanged; + } + + public function eventType(): NotificationEventType + { + return $this->eventType; + } + + 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{ @@ -35,7 +71,7 @@ final readonly class TicketStatusChangedPayload implements Arrayable * 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}|null, + * from: array{id: int, label: string}, * to: array{id: int, label: string}, * ticket_url: string, * occurred_at: string @@ -61,12 +97,10 @@ final readonly class TicketStatusChangedPayload implements Arrayable 'id' => $this->actorId, 'name' => $this->actorName, ], - 'from' => $this->fromStatusId === null || $this->fromStatusLabel === null - ? null - : [ - 'id' => $this->fromStatusId, - 'label' => $this->fromStatusLabel, - ], + 'from' => [ + 'id' => $this->fromStatusId, + 'label' => $this->fromStatusLabel, + ], 'to' => [ 'id' => $this->toStatusId, 'label' => $this->toStatusLabel, diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index fd02c24..c8685f8 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,7 +2,7 @@ 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; @@ -98,12 +98,12 @@ class AppServiceProvider extends ServiceProvider { Event::listen( TicketStatusChanged::class, - QueueTicketStatusChangedNotificationDeliveries::class, + QueueNotificationEventDeliveries::class, ); Event::listen( TicketCreated::class, - QueueTicketStatusChangedNotificationDeliveries::class, + QueueNotificationEventDeliveries::class, ); } } diff --git a/tests/Feature/Notifications/DeliverNotificationActionTest.php b/tests/Feature/Notifications/DeliverNotificationActionTest.php index b73eeaf..2302832 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(); @@ -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 index 4914230..41690c0 100644 --- a/tests/Feature/Notifications/TicketCreatedNotificationDispatchTest.php +++ b/tests/Feature/Notifications/TicketCreatedNotificationDispatchTest.php @@ -103,24 +103,24 @@ class TicketCreatedNotificationDispatchTest extends TestCase 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->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->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->event->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); diff --git a/tests/Feature/Notifications/TicketStatusChangedNotificationDispatchTest.php b/tests/Feature/Notifications/TicketStatusChangedNotificationDispatchTest.php index da8aa82..1b822c6 100644 --- a/tests/Feature/Notifications/TicketStatusChangedNotificationDispatchTest.php +++ b/tests/Feature/Notifications/TicketStatusChangedNotificationDispatchTest.php @@ -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->payload()->eventType() === NotificationEventType::TicketStatusChanged && $job->notificationDeliveryLogId !== null; }); Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $discordAction->id From 2600bdf9d69e1134da2ca0d515a88f5c26225e06 Mon Sep 17 00:00:00 2001 From: Koda Date: Sat, 25 Apr 2026 22:01:59 +0000 Subject: [PATCH 3/5] Polish notification event naming consistency in tests --- tests/Feature/Notifications/DeliverNotificationActionTest.php | 2 +- .../TicketStatusChangedNotificationDispatchTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Feature/Notifications/DeliverNotificationActionTest.php b/tests/Feature/Notifications/DeliverNotificationActionTest.php index 2302832..d693f5a 100644 --- a/tests/Feature/Notifications/DeliverNotificationActionTest.php +++ b/tests/Feature/Notifications/DeliverNotificationActionTest.php @@ -127,7 +127,7 @@ class DeliverNotificationActionTest extends TestCase (new DeliverNotificationAction( notificationActionId: $notificationAction->id, - payload: $this->payload(), + event: $this->event(), notificationDeliveryLogId: $deliveryLog->id, ))->handle(); diff --git a/tests/Feature/Notifications/TicketStatusChangedNotificationDispatchTest.php b/tests/Feature/Notifications/TicketStatusChangedNotificationDispatchTest.php index 1b822c6..32893e7 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, From 4832e7f9841a86e04a764725db0eea2a09031725 Mon Sep 17 00:00:00 2001 From: Koda Date: Sat, 25 Apr 2026 22:10:27 +0000 Subject: [PATCH 4/5] Fix queue job assertions for notification event payload --- tests/Feature/Api/TicketApiTest.php | 4 ++-- tests/Feature/Mcp/KanbanGitMcpTest.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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; }); From ff5e54cad709ffde7ab49969b9ee3733c2d46ebd Mon Sep 17 00:00:00 2001 From: Koda Date: Wed, 29 Apr 2026 09:35:31 +0000 Subject: [PATCH 5/5] Move notification event type onto events --- .../Notifications/DeliverNotificationAction.php | 2 +- app/Actions/Notifications/NotificationEvent.php | 4 ++++ .../Notifications/NotificationEventPayload.php | 3 --- .../QueueNotificationEventDeliveries.php | 5 +++-- app/Actions/Notifications/TicketCreated.php | 6 ++++++ app/Actions/Notifications/TicketCreatedPayload.php | 10 +--------- app/Actions/Notifications/TicketStatusChanged.php | 6 ++++++ .../Notifications/TicketStatusChangedPayload.php | 13 ++----------- .../TicketCreatedNotificationDispatchTest.php | 4 ++-- .../TicketStatusChangedNotificationDispatchTest.php | 2 +- 10 files changed, 26 insertions(+), 29 deletions(-) diff --git a/app/Actions/Notifications/DeliverNotificationAction.php b/app/Actions/Notifications/DeliverNotificationAction.php index 20c7990..d614767 100644 --- a/app/Actions/Notifications/DeliverNotificationAction.php +++ b/app/Actions/Notifications/DeliverNotificationAction.php @@ -245,7 +245,7 @@ class DeliverNotificationAction implements ShouldQueue 'attempt_count' => 0, 'channel' => $notificationAction->channel, 'delivery_status' => NotificationDeliveryStatus::Pending, - 'event_type' => $this->event->payload()->eventType(), + 'event_type' => $this->event->eventType(), 'notification_action_id' => $notificationAction->id, 'payload_metadata' => $this->payloadMetadata(), ]); diff --git a/app/Actions/Notifications/NotificationEvent.php b/app/Actions/Notifications/NotificationEvent.php index 16dad08..0af8390 100644 --- a/app/Actions/Notifications/NotificationEvent.php +++ b/app/Actions/Notifications/NotificationEvent.php @@ -2,7 +2,11 @@ namespace App\Actions\Notifications; +use App\Enums\NotificationEventType; + interface NotificationEvent { + public function eventType(): NotificationEventType; + public function payload(): NotificationEventPayload; } diff --git a/app/Actions/Notifications/NotificationEventPayload.php b/app/Actions/Notifications/NotificationEventPayload.php index 274b8b6..a673434 100644 --- a/app/Actions/Notifications/NotificationEventPayload.php +++ b/app/Actions/Notifications/NotificationEventPayload.php @@ -2,7 +2,6 @@ namespace App\Actions\Notifications; -use App\Enums\NotificationEventType; use Illuminate\Contracts\Support\Arrayable; /** @@ -10,8 +9,6 @@ use Illuminate\Contracts\Support\Arrayable; */ interface NotificationEventPayload extends Arrayable { - public function eventType(): NotificationEventType; - public function projectId(): int; /** diff --git a/app/Actions/Notifications/QueueNotificationEventDeliveries.php b/app/Actions/Notifications/QueueNotificationEventDeliveries.php index 68ab3c8..513a234 100644 --- a/app/Actions/Notifications/QueueNotificationEventDeliveries.php +++ b/app/Actions/Notifications/QueueNotificationEventDeliveries.php @@ -12,11 +12,12 @@ class QueueNotificationEventDeliveries public function handle(NotificationEvent $event): void { $payload = $event->payload(); + $eventType = $event->eventType(); NotificationAction::query() ->enabled() ->where('project_id', $payload->projectId()) - ->where('event_type', $payload->eventType()->value) + ->where('event_type', $eventType->value) ->get(['channel', 'id']) ->each(function (NotificationAction $notificationAction) use ($event, $payload): void { $notificationDeliveryLogId = null; @@ -26,7 +27,7 @@ class QueueNotificationEventDeliveries 'attempt_count' => 0, 'channel' => $notificationAction->channel, 'delivery_status' => NotificationDeliveryStatus::Pending, - 'event_type' => $payload->eventType(), + 'event_type' => $event->eventType(), 'notification_action_id' => $notificationAction->id, 'payload_metadata' => $payload->deliveryMetadata(), ])->id; diff --git a/app/Actions/Notifications/TicketCreated.php b/app/Actions/Notifications/TicketCreated.php index 7d5b192..62bf62c 100644 --- a/app/Actions/Notifications/TicketCreated.php +++ b/app/Actions/Notifications/TicketCreated.php @@ -2,6 +2,7 @@ namespace App\Actions\Notifications; +use App\Enums\NotificationEventType; use Illuminate\Contracts\Events\ShouldDispatchAfterCommit; use Illuminate\Foundation\Events\Dispatchable; @@ -13,6 +14,11 @@ final readonly class TicketCreated implements NotificationEvent, ShouldDispatchA public TicketCreatedPayload $payload, ) {} + public function eventType(): NotificationEventType + { + return NotificationEventType::TicketCreate; + } + public function payload(): NotificationEventPayload { return $this->payload; diff --git a/app/Actions/Notifications/TicketCreatedPayload.php b/app/Actions/Notifications/TicketCreatedPayload.php index a741cbc..f9373f0 100644 --- a/app/Actions/Notifications/TicketCreatedPayload.php +++ b/app/Actions/Notifications/TicketCreatedPayload.php @@ -7,8 +7,6 @@ use Carbon\CarbonImmutable; final readonly class TicketCreatedPayload implements NotificationEventPayload { - public NotificationEventType $eventType; - public ?int $fromStatusId; public ?string $fromStatusLabel; @@ -27,16 +25,10 @@ final readonly class TicketCreatedPayload implements NotificationEventPayload public string $toStatusLabel, public CarbonImmutable $occurredAt, ) { - $this->eventType = NotificationEventType::TicketCreate; $this->fromStatusId = null; $this->fromStatusLabel = null; } - public function eventType(): NotificationEventType - { - return $this->eventType; - } - public function projectId(): int { return $this->projectId; @@ -84,7 +76,7 @@ final readonly class TicketCreatedPayload implements NotificationEventPayload public function toArray(): array { return [ - 'event_type' => $this->eventType->value, + 'event_type' => NotificationEventType::TicketCreate->value, 'project' => [ 'id' => $this->projectId, 'key' => $this->projectKey, diff --git a/app/Actions/Notifications/TicketStatusChanged.php b/app/Actions/Notifications/TicketStatusChanged.php index b31bb55..41e2aa5 100644 --- a/app/Actions/Notifications/TicketStatusChanged.php +++ b/app/Actions/Notifications/TicketStatusChanged.php @@ -2,6 +2,7 @@ namespace App\Actions\Notifications; +use App\Enums\NotificationEventType; use Illuminate\Contracts\Events\ShouldDispatchAfterCommit; use Illuminate\Foundation\Events\Dispatchable; @@ -13,6 +14,11 @@ final readonly class TicketStatusChanged implements NotificationEvent, ShouldDis 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 fab97e5..cc6997b 100644 --- a/app/Actions/Notifications/TicketStatusChangedPayload.php +++ b/app/Actions/Notifications/TicketStatusChangedPayload.php @@ -7,8 +7,6 @@ use Carbon\CarbonImmutable; final readonly class TicketStatusChangedPayload implements NotificationEventPayload { - public NotificationEventType $eventType; - public function __construct( public int $projectId, public string $projectKey, @@ -24,14 +22,7 @@ final readonly class TicketStatusChangedPayload implements NotificationEventPayl public int $toStatusId, public string $toStatusLabel, public CarbonImmutable $occurredAt, - ) { - $this->eventType = NotificationEventType::TicketStatusChanged; - } - - public function eventType(): NotificationEventType - { - return $this->eventType; - } + ) {} public function projectId(): int { @@ -80,7 +71,7 @@ final readonly class TicketStatusChangedPayload implements NotificationEventPayl 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/tests/Feature/Notifications/TicketCreatedNotificationDispatchTest.php b/tests/Feature/Notifications/TicketCreatedNotificationDispatchTest.php index 41690c0..b8bec34 100644 --- a/tests/Feature/Notifications/TicketCreatedNotificationDispatchTest.php +++ b/tests/Feature/Notifications/TicketCreatedNotificationDispatchTest.php @@ -116,11 +116,11 @@ class TicketCreatedNotificationDispatchTest extends TestCase && $job->event->payload()->fromStatusLabel === null && $job->event->payload()->toStatusId === $backlogStatus->id && $job->event->payload()->toStatusLabel === 'Backlog' - && $job->event->payload()->eventType() === NotificationEventType::TicketCreate + && $job->event->eventType() === NotificationEventType::TicketCreate && $job->notificationDeliveryLogId !== null; }); Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $discordAction->id - && $job->event->payload()->eventType() === NotificationEventType::TicketCreate + && $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); diff --git a/tests/Feature/Notifications/TicketStatusChangedNotificationDispatchTest.php b/tests/Feature/Notifications/TicketStatusChangedNotificationDispatchTest.php index 32893e7..0aedc19 100644 --- a/tests/Feature/Notifications/TicketStatusChangedNotificationDispatchTest.php +++ b/tests/Feature/Notifications/TicketStatusChangedNotificationDispatchTest.php @@ -117,7 +117,7 @@ class TicketStatusChangedNotificationDispatchTest extends TestCase && $job->event->payload()->fromStatusLabel === 'Backlog' && $job->event->payload()->toStatusId === $doingStatus->id && $job->event->payload()->toStatusLabel === 'Doing' - && $job->event->payload()->eventType() === NotificationEventType::TicketStatusChanged + && $job->event->eventType() === NotificationEventType::TicketStatusChanged && $job->notificationDeliveryLogId !== null; }); Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $discordAction->id