From 55db0ac6423f7c373aab6a06428de6085d4229d5 Mon Sep 17 00:00:00 2001 From: Koda Date: Fri, 24 Apr 2026 17:01:34 +0000 Subject: [PATCH 01/31] Add Discord allowed mentions configuration --- .../DeliverNotificationAction.php | 49 +++++- .../StoreProjectNotificationActionRequest.php | 20 +++ ...UpdateProjectNotificationActionRequest.php | 20 +++ .../pages/projects/settings/Notifications.vue | 151 ++++++++++++++++++ .../DeliverNotificationActionTest.php | 38 +++++ ...jectNotificationSettingsControllerTest.php | 23 +++ 6 files changed, 300 insertions(+), 1 deletion(-) diff --git a/app/Actions/Notifications/DeliverNotificationAction.php b/app/Actions/Notifications/DeliverNotificationAction.php index 8cdaef5..a5d229f 100644 --- a/app/Actions/Notifications/DeliverNotificationAction.php +++ b/app/Actions/Notifications/DeliverNotificationAction.php @@ -21,6 +21,11 @@ class DeliverNotificationAction implements ShouldQueue private const int DISCORD_CONTENT_LIMIT = 2000; + /** + * @var list + */ + private const array DISCORD_ALLOWED_MENTION_TYPES = ['everyone', 'roles', 'users']; + public int $tries = 3; public int $timeout = 15; @@ -280,11 +285,53 @@ class DeliverNotificationAction implements ShouldQueue ->post($notificationAction->delivery_url, [ 'content' => $message, 'allowed_mentions' => [ - 'parse' => [], + 'parse' => $this->discordAllowedMentions($notificationAction), ], ]); } + /** + * @return list + */ + private function discordAllowedMentions(NotificationAction $notificationAction): array + { + $configuredAllowedMentions = data_get( + $notificationAction->channel_configuration, + 'allowed_mentions', + [], + ); + + if (! is_array($configuredAllowedMentions)) { + return []; + } + + $allowedMentions = []; + + foreach ($configuredAllowedMentions as $allowedMention) { + if (! is_string($allowedMention)) { + continue; + } + + $allowedMention = Str::lower(trim($allowedMention)); + + if ($allowedMention === '') { + continue; + } + + if (! in_array($allowedMention, self::DISCORD_ALLOWED_MENTION_TYPES, true)) { + continue; + } + + if (in_array($allowedMention, $allowedMentions, true)) { + continue; + } + + $allowedMentions[] = $allowedMention; + } + + return $allowedMentions; + } + private function sendWebhookRequest(NotificationAction $notificationAction): Response { return Http::acceptJson() diff --git a/app/Http/Requests/StoreProjectNotificationActionRequest.php b/app/Http/Requests/StoreProjectNotificationActionRequest.php index ed7f636..be63954 100644 --- a/app/Http/Requests/StoreProjectNotificationActionRequest.php +++ b/app/Http/Requests/StoreProjectNotificationActionRequest.php @@ -10,6 +10,11 @@ use Illuminate\Validation\Rule; class StoreProjectNotificationActionRequest extends FormRequest { + /** + * @var list + */ + private const array DISCORD_ALLOWED_MENTION_TYPES = ['everyone', 'roles', 'users']; + /** * Determine if the user is authorized to make this request. */ @@ -29,6 +34,9 @@ class StoreProjectNotificationActionRequest extends FormRequest 'name' => ['required', 'string', 'max:255'], 'event_type' => ['required', 'string', Rule::in($this->eventTypes())], 'channel' => ['required', 'string', Rule::in($this->channels())], + 'channel_configuration' => ['nullable', 'array'], + 'channel_configuration.allowed_mentions' => ['nullable', 'array'], + 'channel_configuration.allowed_mentions.*' => $this->allowedMentionRules(), 'enabled' => ['required', 'boolean'], 'delivery_url' => ['required', 'string', 'max:2048', 'url'], 'message_template' => $this->messageTemplateRules(), @@ -68,4 +76,16 @@ class StoreProjectNotificationActionRequest extends FormRequest return ['nullable', 'string', 'max:2000']; } + + /** + * @return array + */ + private function allowedMentionRules(): array + { + return [ + 'nullable', + 'string', + Rule::in(self::DISCORD_ALLOWED_MENTION_TYPES), + ]; + } } diff --git a/app/Http/Requests/UpdateProjectNotificationActionRequest.php b/app/Http/Requests/UpdateProjectNotificationActionRequest.php index 0e281bf..d2ea824 100644 --- a/app/Http/Requests/UpdateProjectNotificationActionRequest.php +++ b/app/Http/Requests/UpdateProjectNotificationActionRequest.php @@ -10,6 +10,11 @@ use Illuminate\Validation\Rule; class UpdateProjectNotificationActionRequest extends FormRequest { + /** + * @var list + */ + private const array DISCORD_ALLOWED_MENTION_TYPES = ['everyone', 'roles', 'users']; + /** * Determine if the user is authorized to make this request. */ @@ -29,6 +34,9 @@ class UpdateProjectNotificationActionRequest extends FormRequest 'name' => ['required', 'string', 'max:255'], 'event_type' => ['required', 'string', Rule::in($this->eventTypes())], 'channel' => ['required', 'string', Rule::in($this->channels())], + 'channel_configuration' => ['nullable', 'array'], + 'channel_configuration.allowed_mentions' => ['nullable', 'array'], + 'channel_configuration.allowed_mentions.*' => $this->allowedMentionRules(), 'enabled' => ['required', 'boolean'], 'delivery_url' => ['nullable', 'string', 'max:2048', 'url'], 'message_template' => $this->messageTemplateRules(), @@ -68,4 +76,16 @@ class UpdateProjectNotificationActionRequest extends FormRequest return ['nullable', 'string', 'max:2000']; } + + /** + * @return array + */ + private function allowedMentionRules(): array + { + return [ + 'nullable', + 'string', + Rule::in(self::DISCORD_ALLOWED_MENTION_TYPES), + ]; + } } diff --git a/resources/js/pages/projects/settings/Notifications.vue b/resources/js/pages/projects/settings/Notifications.vue index 45fba9c..ceca8a0 100644 --- a/resources/js/pages/projects/settings/Notifications.vue +++ b/resources/js/pages/projects/settings/Notifications.vue @@ -44,6 +44,23 @@ const timestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short', }); +const discordAllowedMentionOptions: Array<{ + value: string; + label: string; +}> = [ + { + value: 'users', + label: 'User mentions (@username)', + }, + { + value: 'roles', + label: 'Role mentions (@role)', + }, + { + value: 'everyone', + label: '@everyone and @here', + }, +]; const breadcrumbs: BreadcrumbItem[] = [ { @@ -67,6 +84,41 @@ props.notificationActions.forEach((notificationAction) => { const selectedChannel = (notificationAction: NotificationActionModel): string => editChannelState[notificationAction.id] ?? notificationAction.channel; +const notificationActionAllowedMentions = ( + notificationAction: NotificationActionModel, +): string[] => { + const allowedMentions = + notificationAction.channel_configuration['allowed_mentions']; + + if (!Array.isArray(allowedMentions)) { + return []; + } + + return allowedMentions + .filter( + (allowedMention): allowedMention is string => + typeof allowedMention === 'string', + ) + .map((allowedMention) => allowedMention.trim().toLowerCase()) + .filter((allowedMention) => allowedMention !== ''); +}; + +const allowedMentionsError = ( + errors: Record, +): string | undefined => { + const generalError = errors['channel_configuration.allowed_mentions']; + + if (generalError !== undefined) { + return generalError; + } + + const itemErrorKey = Object.keys(errors).find((key) => + key.startsWith('channel_configuration.allowed_mentions.'), + ); + + return itemErrorKey === undefined ? undefined : errors[itemErrorKey]; +}; + const notificationActionTimestamp = ( notificationAction: NotificationActionModel, ): string => { @@ -249,6 +301,52 @@ const notificationActionTimestamp = ( +
+ +
+ +
+

+ {{ + createChannelState.value === + 'discord' + ? 'Pick which Discord mention types are allowed. Leave all unchecked to keep mentions fully disabled.' + : 'Allowed mentions are only used when the channel is Discord.' + }} +

+ +
+
+
+ +
+ +
+

+ {{ + selectedChannel(notificationAction) === + 'discord' + ? 'Pick which Discord mention types are allowed. Leave all unchecked to keep mentions fully disabled.' + : 'Allowed mentions are only used when the channel is Discord.' + }} +

+ +
+
@@ -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 c54df44ebe44d2584eb7540e6d37fdad00f9b512 Mon Sep 17 00:00:00 2001 From: Koda Date: Fri, 24 Apr 2026 20:02:22 +0000 Subject: [PATCH 04/31] Add notification filters --- ...uildProjectNotificationSettingsPayload.php | 4 + .../MatchNotificationFilters.php | 208 +++++++++ .../NormalizeNotificationFilters.php | 107 +++++ ...ketStatusChangedNotificationDeliveries.php | 10 +- .../StoreProjectNotificationAction.php | 6 + .../UpdateProjectNotificationAction.php | 11 + app/Enums/NotificationFilterAttribute.php | 64 +++ app/Enums/NotificationFilterOperator.php | 48 ++ .../InteractsWithNotificationFilters.php | 124 +++++ .../StoreProjectNotificationActionRequest.php | 15 + ...UpdateProjectNotificationActionRequest.php | 15 + .../Resources/NotificationActionResource.php | 1 + app/Models/NotificationAction.php | 2 + .../factories/NotificationActionFactory.php | 1 + ..._filters_to_notification_actions_table.php | 28 ++ .../pages/projects/settings/Notifications.vue | 425 +++++++++++++++++- .../notification-action-filter-model.ts | 5 + .../types/models/notification-action-model.ts | 3 + resources/js/types/page-props.ts | 2 + .../Feature/Models/NotificationActionTest.php | 2 + ...rojectNotificationActionManagementTest.php | 28 ++ .../TicketCreatedNotificationDispatchTest.php | 43 +- ...tStatusChangedNotificationDispatchTest.php | 95 +++- ...jectNotificationSettingsControllerTest.php | 48 ++ 24 files changed, 1288 insertions(+), 7 deletions(-) create mode 100644 app/Actions/Notifications/MatchNotificationFilters.php create mode 100644 app/Actions/Notifications/NormalizeNotificationFilters.php create mode 100644 app/Enums/NotificationFilterAttribute.php create mode 100644 app/Enums/NotificationFilterOperator.php create mode 100644 app/Http/Requests/Concerns/InteractsWithNotificationFilters.php create mode 100644 database/migrations/2026_04_24_194800_add_filters_to_notification_actions_table.php create mode 100644 resources/js/types/models/notification-action-filter-model.ts diff --git a/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php b/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php index 46323b1..21053a2 100644 --- a/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php +++ b/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php @@ -4,6 +4,8 @@ namespace App\Actions\Notifications; use App\Enums\NotificationChannel; use App\Enums\NotificationEventType; +use App\Enums\NotificationFilterAttribute; +use App\Enums\NotificationFilterOperator; use App\Http\Resources\NotificationActionResource; use App\Http\Resources\ProjectResource; use App\Models\Project; @@ -22,6 +24,8 @@ class BuildProjectNotificationSettingsPayload return [ 'channelOptions' => NotificationChannel::options(), 'eventTypeOptions' => NotificationEventType::options(), + 'filterAttributeOptions' => NotificationFilterAttribute::options(), + 'filterOperatorOptions' => NotificationFilterOperator::options(), 'notificationActions' => NotificationActionResource::collection( $this->listProjectNotificationActions->handle($project), )->resolve(), diff --git a/app/Actions/Notifications/MatchNotificationFilters.php b/app/Actions/Notifications/MatchNotificationFilters.php new file mode 100644 index 0000000..e273931 --- /dev/null +++ b/app/Actions/Notifications/MatchNotificationFilters.php @@ -0,0 +1,208 @@ + $filters + */ + public function handle(array $filters, TicketStatusChangedPayload $payload): bool + { + if ($filters === []) { + return true; + } + + $payloadContext = $this->payloadContext($payload); + + foreach ($filters as $filter) { + if (! $this->matchesFilter($filter, $payloadContext)) { + return false; + } + } + + return true; + } + + /** + * @param array{attribute?: mixed, operator?: mixed, value?: mixed} $filter + * @param array $payloadContext + */ + private function matchesFilter(array $filter, array $payloadContext): bool + { + $attribute = trim((string) Arr::get($filter, 'attribute', '')); + + if ($attribute === '') { + return false; + } + + $operator = NotificationFilterOperator::tryFrom((string) Arr::get($filter, 'operator', '')); + + if ($operator === null) { + return false; + } + + $actualValue = data_get($payloadContext, $attribute); + + return match ($operator) { + NotificationFilterOperator::Equals => $this->matchesEquals($actualValue, Arr::get($filter, 'value')), + NotificationFilterOperator::NotEquals => $this->matchesNotEquals($actualValue, Arr::get($filter, 'value')), + NotificationFilterOperator::Exists => $actualValue !== null, + NotificationFilterOperator::NotExists => $actualValue === null, + NotificationFilterOperator::In => $this->matchesIn($actualValue, Arr::get($filter, 'value')), + }; + } + + /** + * @param array|string|null $expectedValue + */ + private function matchesEquals(mixed $actualValue, array|string|null $expectedValue): bool + { + if ($actualValue === null) { + return false; + } + + $normalizedExpectedValue = $this->normalizeComparable($expectedValue); + + if ($normalizedExpectedValue === null) { + return false; + } + + return $this->normalizeComparable($actualValue) === $normalizedExpectedValue; + } + + /** + * @param array|string|null $expectedValue + */ + private function matchesNotEquals(mixed $actualValue, array|string|null $expectedValue): bool + { + $normalizedExpectedValue = $this->normalizeComparable($expectedValue); + + if ($normalizedExpectedValue === null) { + return false; + } + + if ($actualValue === null) { + return true; + } + + return $this->normalizeComparable($actualValue) !== $normalizedExpectedValue; + } + + /** + * @param array|string|null $expectedValues + */ + private function matchesIn(mixed $actualValue, array|string|null $expectedValues): bool + { + if ($actualValue === null) { + return false; + } + + $normalizedActualValue = $this->normalizeComparable($actualValue); + + if ($normalizedActualValue === null) { + return false; + } + + $normalizedExpectedValues = $this->normalizeComparableList($expectedValues); + + if ($normalizedExpectedValues === []) { + return false; + } + + return in_array($normalizedActualValue, $normalizedExpectedValues, true); + } + + /** + * @return array + */ + private function normalizeComparableList(array|string|null $value): array + { + if (is_array($value)) { + $normalized = []; + + foreach ($value as $candidate) { + $normalizedCandidate = $this->normalizeComparable($candidate); + + if ($normalizedCandidate !== null) { + $normalized[] = $normalizedCandidate; + } + } + + return array_values(array_unique($normalized)); + } + + if (! is_string($value)) { + return []; + } + + $chunks = preg_split('/[,\r\n]+/', $value) ?: []; + $normalized = []; + + foreach ($chunks as $chunk) { + $normalizedChunk = $this->normalizeComparable($chunk); + + if ($normalizedChunk !== null) { + $normalized[] = $normalizedChunk; + } + } + + return array_values(array_unique($normalized)); + } + + private function normalizeComparable(mixed $value): ?string + { + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + if (is_int($value) || is_float($value)) { + return trim((string) $value); + } + + if (! is_string($value)) { + return null; + } + + $trimmedValue = trim($value); + + return $trimmedValue === '' ? null : $trimmedValue; + } + + /** + * @return array + */ + private function payloadContext(TicketStatusChangedPayload $payload): array + { + return [ + 'project' => [ + 'id' => $payload->projectId, + 'key' => $payload->projectKey, + 'name' => $payload->projectName, + ], + 'ticket' => [ + 'id' => $payload->ticketId, + 'key' => $payload->ticketKey, + 'title' => $payload->ticketTitle, + 'url' => $payload->ticketUrl, + ], + 'actor' => [ + 'id' => $payload->actorId, + 'name' => $payload->actorName, + ], + 'status' => [ + 'from' => [ + 'id' => $payload->fromStatusId, + 'label' => $payload->fromStatusLabel, + ], + 'to' => [ + 'id' => $payload->toStatusId, + 'label' => $payload->toStatusLabel, + ], + ], + ]; + } +} diff --git a/app/Actions/Notifications/NormalizeNotificationFilters.php b/app/Actions/Notifications/NormalizeNotificationFilters.php new file mode 100644 index 0000000..a1afdc2 --- /dev/null +++ b/app/Actions/Notifications/NormalizeNotificationFilters.php @@ -0,0 +1,107 @@ +|string|null}> + */ + public function handle(mixed $filters): array + { + if (! is_array($filters)) { + return []; + } + + $normalizedFilters = []; + + foreach ($filters as $filter) { + if (! is_array($filter)) { + continue; + } + + $attribute = trim((string) Arr::get($filter, 'attribute', '')); + $operator = NotificationFilterOperator::tryFrom((string) Arr::get($filter, 'operator', '')); + + if ($attribute === '' || $operator === null) { + continue; + } + + $normalizedFilters[] = [ + 'attribute' => $attribute, + 'operator' => $operator->value, + 'value' => $this->normalizeValue($operator, Arr::get($filter, 'value')), + ]; + } + + return $normalizedFilters; + } + + /** + * @return array|string|null + */ + private function normalizeValue(NotificationFilterOperator $operator, mixed $value): array|string|null + { + return match ($operator) { + NotificationFilterOperator::Equals, + NotificationFilterOperator::NotEquals => $this->normalizeScalarValue($value), + NotificationFilterOperator::Exists, + NotificationFilterOperator::NotExists => null, + NotificationFilterOperator::In => $this->normalizeListValue($value), + }; + } + + private function normalizeScalarValue(mixed $value): ?string + { + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + if (is_int($value) || is_float($value)) { + return trim((string) $value); + } + + if (! is_string($value)) { + return null; + } + + $trimmedValue = trim($value); + + return $trimmedValue === '' ? null : $trimmedValue; + } + + /** + * @return array + */ + private function normalizeListValue(mixed $value): array + { + $normalizedValues = []; + + if (is_string($value)) { + $chunks = preg_split('/[,\r\n]+/', $value) ?: []; + + foreach ($chunks as $chunk) { + $trimmedValue = trim($chunk); + + if ($trimmedValue !== '') { + $normalizedValues[] = $trimmedValue; + } + } + } + + if (is_array($value)) { + foreach ($value as $candidate) { + $normalizedCandidate = $this->normalizeScalarValue($candidate); + + if ($normalizedCandidate !== null) { + $normalizedValues[] = $normalizedCandidate; + } + } + } + + return array_values(array_unique($normalizedValues)); + } +} diff --git a/app/Actions/Notifications/QueueTicketStatusChangedNotificationDeliveries.php b/app/Actions/Notifications/QueueTicketStatusChangedNotificationDeliveries.php index 42abca3..1458ef5 100644 --- a/app/Actions/Notifications/QueueTicketStatusChangedNotificationDeliveries.php +++ b/app/Actions/Notifications/QueueTicketStatusChangedNotificationDeliveries.php @@ -9,14 +9,22 @@ use App\Models\NotificationDeliveryLog; class QueueTicketStatusChangedNotificationDeliveries { + public function __construct( + private readonly MatchNotificationFilters $matchNotificationFilters, + ) {} + public function handle(TicketStatusChanged|TicketCreated $event): void { NotificationAction::query() ->enabled() ->where('project_id', $event->payload->projectId) ->where('event_type', $event->payload->eventType->value) - ->get(['channel', 'id']) + ->get(['channel', 'filters', 'id']) ->each(function (NotificationAction $notificationAction) use ($event): void { + if (! $this->matchNotificationFilters->handle($notificationAction->filters ?? [], $event->payload)) { + return; + } + $notificationDeliveryLogId = null; if (in_array($notificationAction->channel, [NotificationChannel::Discord, NotificationChannel::Webhook], true)) { diff --git a/app/Actions/Notifications/StoreProjectNotificationAction.php b/app/Actions/Notifications/StoreProjectNotificationAction.php index 17d3270..3ab1564 100644 --- a/app/Actions/Notifications/StoreProjectNotificationAction.php +++ b/app/Actions/Notifications/StoreProjectNotificationAction.php @@ -8,6 +8,10 @@ use Illuminate\Support\Arr; class StoreProjectNotificationAction { + public function __construct( + private readonly NormalizeNotificationFilters $normalizeNotificationFilters, + ) {} + /** * @param array{ * channel: mixed, @@ -15,6 +19,7 @@ class StoreProjectNotificationAction * delivery_url: string, * enabled: bool, * event_type: mixed, + * filters?: mixed, * message_template?: string|null, * name: string * } $validated @@ -27,6 +32,7 @@ class StoreProjectNotificationAction 'delivery_url' => trim((string) Arr::get($validated, 'delivery_url')), 'enabled' => (bool) Arr::get($validated, 'enabled'), 'event_type' => Arr::get($validated, 'event_type'), + 'filters' => $this->normalizeNotificationFilters->handle(Arr::get($validated, 'filters')), 'message_template' => $this->normalizeMessageTemplate(Arr::get($validated, 'message_template')), 'name' => trim((string) Arr::get($validated, 'name')), ]); diff --git a/app/Actions/Notifications/UpdateProjectNotificationAction.php b/app/Actions/Notifications/UpdateProjectNotificationAction.php index 59faabd..0a9c18d 100644 --- a/app/Actions/Notifications/UpdateProjectNotificationAction.php +++ b/app/Actions/Notifications/UpdateProjectNotificationAction.php @@ -8,6 +8,10 @@ use Illuminate\Support\Arr; class UpdateProjectNotificationAction { + public function __construct( + private readonly NormalizeNotificationFilters $normalizeNotificationFilters, + ) {} + /** * @param array{ * channel?: mixed, @@ -15,6 +19,7 @@ class UpdateProjectNotificationAction * delivery_url?: string|null, * enabled?: bool, * event_type?: mixed, + * filters?: mixed, * message_template?: string|null, * name?: string * } $validated @@ -54,6 +59,12 @@ class UpdateProjectNotificationAction ); } + if (array_key_exists('filters', $validated)) { + $attributes['filters'] = $this->normalizeNotificationFilters->handle( + Arr::get($validated, 'filters'), + ); + } + $deliveryUrl = Arr::get($validated, 'delivery_url'); if (is_string($deliveryUrl) && trim($deliveryUrl) !== '') { diff --git a/app/Enums/NotificationFilterAttribute.php b/app/Enums/NotificationFilterAttribute.php new file mode 100644 index 0000000..bcd9305 --- /dev/null +++ b/app/Enums/NotificationFilterAttribute.php @@ -0,0 +1,64 @@ + 'Project ID', + self::ProjectKey => 'Project key', + self::ProjectName => 'Project name', + self::TicketId => 'Ticket ID', + self::TicketKey => 'Ticket key', + self::TicketTitle => 'Ticket title', + self::TicketUrl => 'Ticket URL', + self::ActorId => 'Actor ID', + self::ActorName => 'Actor name', + self::StatusFromId => 'From status ID', + self::StatusFromLabel => 'From status label', + self::StatusToId => 'To status ID', + self::StatusToLabel => 'To status label', + }; + } + + /** + * @return array + */ + public static function options(): array + { + return array_map( + static fn (self $attribute): array => [ + 'label' => $attribute->label(), + 'value' => $attribute->value, + ], + self::cases(), + ); + } + + /** + * @return array + */ + public static function values(): array + { + return array_map( + static fn (self $attribute): string => $attribute->value, + self::cases(), + ); + } +} diff --git a/app/Enums/NotificationFilterOperator.php b/app/Enums/NotificationFilterOperator.php new file mode 100644 index 0000000..6d6cc7f --- /dev/null +++ b/app/Enums/NotificationFilterOperator.php @@ -0,0 +1,48 @@ + 'Equals', + self::NotEquals => 'Not equals', + self::Exists => 'Exists', + self::NotExists => 'Not exists', + self::In => 'In list', + }; + } + + /** + * @return array + */ + public static function options(): array + { + return array_map( + static fn (self $operator): array => [ + 'label' => $operator->label(), + 'value' => $operator->value, + ], + self::cases(), + ); + } + + /** + * @return array + */ + public static function values(): array + { + return array_map( + static fn (self $operator): string => $operator->value, + self::cases(), + ); + } +} diff --git a/app/Http/Requests/Concerns/InteractsWithNotificationFilters.php b/app/Http/Requests/Concerns/InteractsWithNotificationFilters.php new file mode 100644 index 0000000..0cdfcbc --- /dev/null +++ b/app/Http/Requests/Concerns/InteractsWithNotificationFilters.php @@ -0,0 +1,124 @@ +input('filters'); + + if (! is_string($rawFilters)) { + return; + } + + $decodedFilters = json_decode($rawFilters, true); + + if (json_last_error() === JSON_ERROR_NONE) { + $this->merge([ + 'filters' => $decodedFilters, + ]); + } + } + + /** + * @return array + */ + protected function notificationFilterRules(): array + { + return [ + 'filters' => ['nullable', 'array', 'max:25'], + 'filters.*' => ['required', 'array'], + 'filters.*.attribute' => ['required', 'string', Rule::in(NotificationFilterAttribute::values())], + 'filters.*.operator' => ['required', 'string', Rule::in(NotificationFilterOperator::values())], + 'filters.*.value' => ['nullable'], + ]; + } + + protected function withNotificationFilterValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + $filters = $this->input('filters'); + + if (! is_array($filters)) { + return; + } + + $normalizer = app(NormalizeNotificationFilters::class); + + foreach ($filters as $index => $filter) { + if (! is_array($filter)) { + continue; + } + + $operator = NotificationFilterOperator::tryFrom((string) ($filter['operator'] ?? null)); + + if ($operator === null) { + continue; + } + + $normalizedValue = $normalizer->handle([$filter])[0]['value'] ?? null; + + if (in_array($operator, [NotificationFilterOperator::Equals, NotificationFilterOperator::NotEquals], true) + && ! is_string($normalizedValue) + ) { + $validator->errors()->add( + "filters.{$index}.value", + 'A non-empty value is required for equals and not equals operators.', + ); + } + + if ($operator === NotificationFilterOperator::In + && (! is_array($normalizedValue) || $normalizedValue === []) + ) { + $validator->errors()->add( + "filters.{$index}.value", + 'The in operator requires at least one value.', + ); + } + + if (in_array($operator, [NotificationFilterOperator::Exists, NotificationFilterOperator::NotExists], true) + && $this->hasProvidedFilterValue($filter['value'] ?? null) + ) { + $validator->errors()->add( + "filters.{$index}.value", + 'The exists and not exists operators do not accept a value.', + ); + } + } + }); + } + + private function hasProvidedFilterValue(mixed $value): bool + { + if ($value === null) { + return false; + } + + if (is_string($value)) { + return trim($value) !== ''; + } + + if (is_array($value)) { + foreach ($value as $candidate) { + if (is_string($candidate) && trim($candidate) !== '') { + return true; + } + + if (! is_string($candidate) && $candidate !== null) { + return true; + } + } + + return false; + } + + return true; + } +} diff --git a/app/Http/Requests/StoreProjectNotificationActionRequest.php b/app/Http/Requests/StoreProjectNotificationActionRequest.php index ed7f636..715716d 100644 --- a/app/Http/Requests/StoreProjectNotificationActionRequest.php +++ b/app/Http/Requests/StoreProjectNotificationActionRequest.php @@ -4,12 +4,16 @@ namespace App\Http\Requests; use App\Enums\NotificationChannel; use App\Enums\NotificationEventType; +use App\Http\Requests\Concerns\InteractsWithNotificationFilters; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; +use Illuminate\Validation\Validator; class StoreProjectNotificationActionRequest extends FormRequest { + use InteractsWithNotificationFilters; + /** * Determine if the user is authorized to make this request. */ @@ -32,9 +36,20 @@ class StoreProjectNotificationActionRequest extends FormRequest 'enabled' => ['required', 'boolean'], 'delivery_url' => ['required', 'string', 'max:2048', 'url'], 'message_template' => $this->messageTemplateRules(), + ...$this->notificationFilterRules(), ]; } + protected function prepareForValidation(): void + { + $this->prepareNotificationFiltersForValidation(); + } + + public function withValidator(Validator $validator): void + { + $this->withNotificationFilterValidator($validator); + } + /** * @return array */ diff --git a/app/Http/Requests/UpdateProjectNotificationActionRequest.php b/app/Http/Requests/UpdateProjectNotificationActionRequest.php index 0e281bf..f4fb6f2 100644 --- a/app/Http/Requests/UpdateProjectNotificationActionRequest.php +++ b/app/Http/Requests/UpdateProjectNotificationActionRequest.php @@ -4,12 +4,16 @@ namespace App\Http\Requests; use App\Enums\NotificationChannel; use App\Enums\NotificationEventType; +use App\Http\Requests\Concerns\InteractsWithNotificationFilters; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; +use Illuminate\Validation\Validator; class UpdateProjectNotificationActionRequest extends FormRequest { + use InteractsWithNotificationFilters; + /** * Determine if the user is authorized to make this request. */ @@ -32,9 +36,20 @@ class UpdateProjectNotificationActionRequest extends FormRequest 'enabled' => ['required', 'boolean'], 'delivery_url' => ['nullable', 'string', 'max:2048', 'url'], 'message_template' => $this->messageTemplateRules(), + ...$this->notificationFilterRules(), ]; } + protected function prepareForValidation(): void + { + $this->prepareNotificationFiltersForValidation(); + } + + public function withValidator(Validator $validator): void + { + $this->withNotificationFilterValidator($validator); + } + /** * @return array */ diff --git a/app/Http/Resources/NotificationActionResource.php b/app/Http/Resources/NotificationActionResource.php index d510ae2..eae6c76 100644 --- a/app/Http/Resources/NotificationActionResource.php +++ b/app/Http/Resources/NotificationActionResource.php @@ -25,6 +25,7 @@ class NotificationActionResource extends JsonResource 'channel' => $this->channel->value, 'channel_label' => $this->channel->label(), 'channel_configuration' => $this->channel_configuration ?? [], + 'filters' => $this->filters ?? [], 'has_delivery_url' => $this->resource instanceof NotificationAction && filled($this->delivery_url), 'message_template' => $this->message_template, 'created_at' => $this->created_at?->toISOString(), diff --git a/app/Models/NotificationAction.php b/app/Models/NotificationAction.php index 3e42278..e9e6cc2 100644 --- a/app/Models/NotificationAction.php +++ b/app/Models/NotificationAction.php @@ -19,6 +19,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; 'delivery_url', 'enabled', 'event_type', + 'filters', 'message_template', 'name', 'project_id', @@ -40,6 +41,7 @@ class NotificationAction extends Model 'delivery_url' => 'encrypted', 'enabled' => 'boolean', 'event_type' => NotificationEventType::class, + 'filters' => 'array', ]; } diff --git a/database/factories/NotificationActionFactory.php b/database/factories/NotificationActionFactory.php index 59daa68..70cc628 100644 --- a/database/factories/NotificationActionFactory.php +++ b/database/factories/NotificationActionFactory.php @@ -26,6 +26,7 @@ class NotificationActionFactory extends Factory 'delivery_url' => 'https://hooks.'.fake()->domainName().'/kanbangit/'.fake()->uuid(), 'enabled' => true, 'event_type' => NotificationEventType::TicketStatusChanged, + 'filters' => [], 'message_template' => null, 'name' => fake()->unique()->sentence(3), 'project_id' => Project::factory(), diff --git a/database/migrations/2026_04_24_194800_add_filters_to_notification_actions_table.php b/database/migrations/2026_04_24_194800_add_filters_to_notification_actions_table.php new file mode 100644 index 0000000..6139f3d --- /dev/null +++ b/database/migrations/2026_04_24_194800_add_filters_to_notification_actions_table.php @@ -0,0 +1,28 @@ +json('filters')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('notification_actions', function (Blueprint $table): void { + $table->dropColumn('filters'); + }); + } +}; diff --git a/resources/js/pages/projects/settings/Notifications.vue b/resources/js/pages/projects/settings/Notifications.vue index 3ab2780..410a323 100644 --- a/resources/js/pages/projects/settings/Notifications.vue +++ b/resources/js/pages/projects/settings/Notifications.vue @@ -1,6 +1,6 @@ + + diff --git a/resources/js/pages/admin/users/Index.vue b/resources/js/pages/admin/users/Index.vue index aaf2775..cbf1efc 100644 --- a/resources/js/pages/admin/users/Index.vue +++ b/resources/js/pages/admin/users/Index.vue @@ -1,6 +1,6 @@ + + diff --git a/resources/js/pages/admin/users/Index.vue b/resources/js/pages/admin/users/Index.vue index cbf1efc..9c1ce93 100644 --- a/resources/js/pages/admin/users/Index.vue +++ b/resources/js/pages/admin/users/Index.vue @@ -1,6 +1,6 @@ + + diff --git a/routes/web/settings.php b/routes/web/settings.php index 21827fc..78233a9 100644 --- a/routes/web/settings.php +++ b/routes/web/settings.php @@ -19,6 +19,13 @@ Route::middleware('auth') }); }); +Route::middleware(['auth', 'verified']) + ->controller(SecurityController::class) + ->group(function (): void { + Route::get('user/force-password-change', 'editForcedPassword') + ->name('password.force-change.edit'); + }); + Route::middleware(['auth', 'verified']) ->prefix('settings') ->group(function (): void { diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index 607166e..1ed655b 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -33,7 +33,7 @@ class AuthenticationTest extends TestCase $response->assertRedirect(route('dashboard', absolute: false)); } - public function test_users_who_must_change_their_password_are_redirected_to_security_settings_after_login() + public function test_users_who_must_change_their_password_are_redirected_to_the_dedicated_force_change_page_after_login() { $user = User::factory()->create([ 'must_change_password_on_next_login' => true, @@ -46,7 +46,8 @@ class AuthenticationTest extends TestCase $this->assertAuthenticated(); $response->assertInertia(fn (Assert $page) => $page - ->component('auth/ConfirmPassword'), + ->component('auth/ForcePasswordChange') + ->where('status', 'You must change your password before continuing.'), ); } diff --git a/tests/Feature/Settings/SecurityTest.php b/tests/Feature/Settings/SecurityTest.php index 7783609..9928d22 100644 --- a/tests/Feature/Settings/SecurityTest.php +++ b/tests/Feature/Settings/SecurityTest.php @@ -97,7 +97,40 @@ class SecurityTest extends TestCase $this->actingAs($user) ->get(route('dashboard')) - ->assertRedirect(route('security.edit')); + ->assertRedirect(route('password.force-change.edit')); + } + + public function test_users_who_must_change_their_password_can_view_the_dedicated_force_change_page() + { + $user = User::factory()->create([ + 'must_change_password_on_next_login' => true, + ]); + + $this->actingAs($user) + ->get(route('password.force-change.edit')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('auth/ForcePasswordChange'), + ); + } + + public function test_users_who_must_change_their_password_are_not_sent_to_the_normal_security_page() + { + $this->skipUnlessFortifyFeature(Features::twoFactorAuthentication()); + + Features::twoFactorAuthentication([ + 'confirm' => true, + 'confirmPassword' => true, + ]); + + $user = User::factory()->create([ + 'must_change_password_on_next_login' => true, + ]); + + $this->actingAs($user) + ->withSession(['auth.password_confirmed_at' => time()]) + ->get(route('security.edit')) + ->assertRedirect(route('password.force-change.edit')); } public function test_password_can_be_updated() @@ -135,11 +168,34 @@ class SecurityTest extends TestCase 'password_confirmation' => 'new-password', ]) ->assertSessionHasNoErrors() - ->assertRedirect(route('security.edit')); + ->assertRedirect(route('dashboard')); $this->assertFalse((bool) $user->refresh()->must_change_password_on_next_login); } + public function test_forced_password_change_returns_the_user_to_their_intended_page_after_updating() + { + $user = User::factory()->create([ + 'must_change_password_on_next_login' => true, + ]); + + $this->actingAs($user) + ->get(route('dashboard')); + + $this->actingAs($user) + ->from(route('password.force-change.edit')) + ->put(route('user-password.update'), [ + 'current_password' => 'password', + 'password' => 'new-password', + 'password_confirmation' => 'new-password', + ]) + ->assertSessionHasNoErrors() + ->assertRedirect(route('dashboard')); + + $this->assertFalse((bool) $user->refresh()->must_change_password_on_next_login); + $this->assertTrue(Hash::check('new-password', $user->password)); + } + public function test_correct_password_must_be_provided_to_update_password() { $user = User::factory()->create(); From 704dd6c26e8524d81d62dedeb2def3e3ef616e41 Mon Sep 17 00:00:00 2001 From: Koda Date: Fri, 22 May 2026 21:10:14 +0000 Subject: [PATCH 29/31] Polish forced password change flow --- .../Settings/PasswordUpdateRequest.php | 14 +++++++- .../js/pages/auth/ForcePasswordChange.vue | 13 ++++++- tests/Feature/Settings/SecurityTest.php | 36 +++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/app/Http/Requests/Settings/PasswordUpdateRequest.php b/app/Http/Requests/Settings/PasswordUpdateRequest.php index 0e5ff2f..6536ae5 100644 --- a/app/Http/Requests/Settings/PasswordUpdateRequest.php +++ b/app/Http/Requests/Settings/PasswordUpdateRequest.php @@ -19,7 +19,19 @@ class PasswordUpdateRequest extends FormRequest { return [ 'current_password' => $this->currentPasswordRules(), - 'password' => $this->passwordRules(), + 'password' => [...$this->passwordRules(), 'different:current_password'], + ]; + } + + /** + * Get the custom validation messages for the request. + * + * @return array + */ + public function messages(): array + { + return [ + 'password.different' => 'The new password must be different from your current password.', ]; } } diff --git a/resources/js/pages/auth/ForcePasswordChange.vue b/resources/js/pages/auth/ForcePasswordChange.vue index 9c27640..29f9e79 100644 --- a/resources/js/pages/auth/ForcePasswordChange.vue +++ b/resources/js/pages/auth/ForcePasswordChange.vue @@ -1,5 +1,6 @@ diff --git a/resources/js/pages/auth/Login.vue b/resources/js/pages/auth/Login.vue index 6ed12ae..47b96bf 100644 --- a/resources/js/pages/auth/Login.vue +++ b/resources/js/pages/auth/Login.vue @@ -9,14 +9,12 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Spinner } from '@/components/ui/spinner'; import AuthBase from '@/layouts/AuthLayout.vue'; -import { register } from '@/routes/index'; import { store } from '@/routes/login'; import { request } from '@/routes/password'; defineProps<{ status?: string; canResetPassword: boolean; - canRegister: boolean; }>(); @@ -97,14 +95,6 @@ defineProps<{ Log in - -

- Don't have an account? - Sign up -
diff --git a/resources/js/pages/auth/Register.vue b/resources/js/pages/auth/Register.vue deleted file mode 100644 index 95d5894..0000000 --- a/resources/js/pages/auth/Register.vue +++ /dev/null @@ -1,107 +0,0 @@ - - - diff --git a/routes/web.php b/routes/web.php index ec9722b..430c0e4 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,11 +3,8 @@ use Illuminate\Http\Response; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Route; -use Laravel\Fortify\Features; -Route::inertia('/', 'Welcome', [ - 'canRegister' => Features::enabled(Features::registration()), -])->name('home'); +Route::inertia('/', 'Welcome')->name('home'); Route::get('api/docs/openapi.json', function (): Response { $path = base_path('docs/openapi/kanbangit-api.openapi.json');