From ee15efe73990ff99171a38ce1d535d57a319c695 Mon Sep 17 00:00:00 2001 From: Eric Peters Date: Mon, 20 Apr 2026 22:29:46 +0200 Subject: [PATCH] Add project notification settings UI --- ...uildProjectNotificationSettingsPayload.php | 70 ++ .../ProjectNotificationSettingsController.php | 94 +++ .../StoreProjectNotificationActionRequest.php | 71 ++ ...UpdateProjectNotificationActionRequest.php | 71 ++ resources/js/pages/projects/Show.vue | 7 + .../js/pages/projects/settings/Board.vue | 14 + .../js/pages/projects/settings/General.vue | 7 + .../js/pages/projects/settings/Metadata.vue | 7 + .../pages/projects/settings/Notifications.vue | 640 ++++++++++++++++++ .../pages/projects/settings/Repositories.vue | 7 + .../types/models/notification-action-model.ts | 15 + .../notification-action-option-model.ts | 4 + ...fication-action-template-variable-model.ts | 4 + resources/js/types/page-props.ts | 12 + routes/web/project/settings.php | 10 + routes/web/project/settings/notifications.php | 7 + ...jectNotificationSettingsControllerTest.php | 334 +++++++++ 17 files changed, 1374 insertions(+) create mode 100644 app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php create mode 100644 app/Http/Controllers/ProjectNotificationSettingsController.php create mode 100644 app/Http/Requests/StoreProjectNotificationActionRequest.php create mode 100644 app/Http/Requests/UpdateProjectNotificationActionRequest.php create mode 100644 resources/js/pages/projects/settings/Notifications.vue create mode 100644 resources/js/types/models/notification-action-model.ts create mode 100644 resources/js/types/models/notification-action-option-model.ts create mode 100644 resources/js/types/models/notification-action-template-variable-model.ts create mode 100644 routes/web/project/settings/notifications.php create mode 100644 tests/Feature/Projects/ProjectNotificationSettingsControllerTest.php diff --git a/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php b/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php new file mode 100644 index 0000000..995a4f3 --- /dev/null +++ b/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php @@ -0,0 +1,70 @@ + + */ + public function handle(Project $project, mixed $status = null): array + { + return [ + 'channelOptions' => NotificationChannel::options(), + 'eventTypeOptions' => NotificationEventType::options(), + 'notificationActions' => NotificationActionResource::collection( + $this->listProjectNotificationActions->handle($project), + )->resolve(), + 'project' => ProjectResource::make($project)->resolve(), + 'status' => $status, + 'templateVariables' => $this->templateVariables(), + ]; + } + + /** + * @return array + */ + private function templateVariables(): array + { + return [ + [ + 'description' => 'Absolute ticket URL for the status change event.', + 'value' => '$url', + ], + [ + 'description' => 'Project ticket key such as CORE-42.', + 'value' => '$ticket_key', + ], + [ + 'description' => 'Ticket title from the changed ticket.', + 'value' => '$ticket_title', + ], + [ + 'description' => 'Project name that owns the ticket.', + 'value' => '$project_name', + ], + [ + 'description' => 'Origin status label before the move.', + 'value' => '$status_from', + ], + [ + 'description' => 'Destination status label after the move.', + 'value' => '$status_to', + ], + [ + 'description' => 'Actor name when the move has a known user.', + 'value' => '$actor_name', + ], + ]; + } +} diff --git a/app/Http/Controllers/ProjectNotificationSettingsController.php b/app/Http/Controllers/ProjectNotificationSettingsController.php new file mode 100644 index 0000000..8abb088 --- /dev/null +++ b/app/Http/Controllers/ProjectNotificationSettingsController.php @@ -0,0 +1,94 @@ +abortUnlessCanManage($request, $project); + + return Inertia::render( + 'projects/settings/Notifications', + $this->buildProjectNotificationSettingsPayload->handle($project, session('status')), + ); + } + + public function store( + StoreProjectNotificationActionRequest $request, + Project $project, + ): RedirectResponse { + $this->abortUnlessCanManage($request, $project); + + $notificationAction = $this->storeProjectNotificationAction->handle($project, $request->validated()); + + return to_route('projects.settings.notifications', $project) + ->with('status', "{$notificationAction->name} notification was added."); + } + + public function update( + UpdateProjectNotificationActionRequest $request, + Project $project, + NotificationAction $notificationAction, + ): RedirectResponse { + $this->abortUnlessCanManage($request, $project); + + $notificationAction = $this->updateProjectNotificationAction->handle( + $project, + $notificationAction, + $request->validated(), + ); + + return to_route('projects.settings.notifications', $project) + ->with('status', "{$notificationAction->name} notification was updated."); + } + + public function destroy( + Request $request, + Project $project, + NotificationAction $notificationAction, + ): RedirectResponse { + $this->abortUnlessCanManage($request, $project); + + $notificationAction = $project->notificationActions() + ->whereKey($notificationAction->id) + ->firstOrFail(); + $deletedName = $notificationAction->name; + + $this->deleteProjectNotificationAction->handle($project, $notificationAction); + + return to_route('projects.settings.notifications', $project) + ->with('status', "{$deletedName} notification was deleted."); + } + + private function abortUnlessCanManage(Request $request, Project $project): void + { + abort_unless( + $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), + 403, + ); + } +} diff --git a/app/Http/Requests/StoreProjectNotificationActionRequest.php b/app/Http/Requests/StoreProjectNotificationActionRequest.php new file mode 100644 index 0000000..ed7f636 --- /dev/null +++ b/app/Http/Requests/StoreProjectNotificationActionRequest.php @@ -0,0 +1,71 @@ +user() !== null; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'event_type' => ['required', 'string', Rule::in($this->eventTypes())], + 'channel' => ['required', 'string', Rule::in($this->channels())], + 'enabled' => ['required', 'boolean'], + 'delivery_url' => ['required', 'string', 'max:2048', 'url'], + 'message_template' => $this->messageTemplateRules(), + ]; + } + + /** + * @return array + */ + private function channels(): array + { + return array_map( + static fn (NotificationChannel $channel): string => $channel->value, + NotificationChannel::cases(), + ); + } + + /** + * @return array + */ + private function eventTypes(): array + { + return array_map( + static fn (NotificationEventType $eventType): string => $eventType->value, + NotificationEventType::cases(), + ); + } + + /** + * @return array + */ + private function messageTemplateRules(): array + { + if ($this->input('channel') === NotificationChannel::Discord->value) { + return ['required', 'string', 'max:2000']; + } + + return ['nullable', 'string', 'max:2000']; + } +} diff --git a/app/Http/Requests/UpdateProjectNotificationActionRequest.php b/app/Http/Requests/UpdateProjectNotificationActionRequest.php new file mode 100644 index 0000000..0e281bf --- /dev/null +++ b/app/Http/Requests/UpdateProjectNotificationActionRequest.php @@ -0,0 +1,71 @@ +user() !== null; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'event_type' => ['required', 'string', Rule::in($this->eventTypes())], + 'channel' => ['required', 'string', Rule::in($this->channels())], + 'enabled' => ['required', 'boolean'], + 'delivery_url' => ['nullable', 'string', 'max:2048', 'url'], + 'message_template' => $this->messageTemplateRules(), + ]; + } + + /** + * @return array + */ + private function channels(): array + { + return array_map( + static fn (NotificationChannel $channel): string => $channel->value, + NotificationChannel::cases(), + ); + } + + /** + * @return array + */ + private function eventTypes(): array + { + return array_map( + static fn (NotificationEventType $eventType): string => $eventType->value, + NotificationEventType::cases(), + ); + } + + /** + * @return array + */ + private function messageTemplateRules(): array + { + if ($this->input('channel') === NotificationChannel::Discord->value) { + return ['required', 'string', 'max:2000']; + } + + return ['nullable', 'string', 'max:2000']; + } +} diff --git a/resources/js/pages/projects/Show.vue b/resources/js/pages/projects/Show.vue index 1591081..46d86bd 100644 --- a/resources/js/pages/projects/Show.vue +++ b/resources/js/pages/projects/Show.vue @@ -19,6 +19,7 @@ import { board as boardSettings, general as generalSettings, metadata as metadataSettings, + notifications as notificationSettings, repositories as repositorySettings, } from '@/routes/projects/settings'; import type { BreadcrumbItem, ProjectShowPageProps } from '@/types'; @@ -128,6 +129,12 @@ const projectAreas = [ + + diff --git a/resources/js/pages/projects/settings/Board.vue b/resources/js/pages/projects/settings/Board.vue index 1d24f50..e56e69c 100644 --- a/resources/js/pages/projects/settings/Board.vue +++ b/resources/js/pages/projects/settings/Board.vue @@ -19,6 +19,8 @@ import { index, show } from '@/routes/projects/index'; import { board as boardSettings, general as generalSettings, + metadata as metadataSettings, + notifications as notificationSettings, repositories as repositorySettings, } from '@/routes/projects/settings'; import type { BreadcrumbItem, ProjectBoardSettingsPageProps } from '@/types'; @@ -105,12 +107,24 @@ const statusAllowsTarget = ( + + + + diff --git a/resources/js/pages/projects/settings/General.vue b/resources/js/pages/projects/settings/General.vue index 3ae452a..f309dd8 100644 --- a/resources/js/pages/projects/settings/General.vue +++ b/resources/js/pages/projects/settings/General.vue @@ -20,6 +20,7 @@ import { board as boardSettings, general as generalSettings, metadata as metadataSettings, + notifications as notificationSettings, repositories as repositorySettings, } from '@/routes/projects/settings'; import type { BreadcrumbItem, ProjectGeneralSettingsPageProps } from '@/types'; @@ -98,6 +99,12 @@ const submitDelete = (): void => { + + diff --git a/resources/js/pages/projects/settings/Metadata.vue b/resources/js/pages/projects/settings/Metadata.vue index 3df2aa9..5b0274c 100644 --- a/resources/js/pages/projects/settings/Metadata.vue +++ b/resources/js/pages/projects/settings/Metadata.vue @@ -20,6 +20,7 @@ import { board as boardSettings, general as generalSettings, metadata as metadataSettings, + notifications as notificationSettings, repositories as repositorySettings, } from '@/routes/projects/settings'; import type { @@ -90,6 +91,12 @@ const replacementTicketTypes = (ticketTypeId: number) => + + diff --git a/resources/js/pages/projects/settings/Notifications.vue b/resources/js/pages/projects/settings/Notifications.vue new file mode 100644 index 0000000..978e9d2 --- /dev/null +++ b/resources/js/pages/projects/settings/Notifications.vue @@ -0,0 +1,640 @@ + + +