Add notification filters #66
@@ -5,6 +5,8 @@ namespace App\Actions\Notifications;
|
|||||||
use App\Enums\DiscordAllowedMentionType;
|
use App\Enums\DiscordAllowedMentionType;
|
||||||
use App\Enums\NotificationChannel;
|
use App\Enums\NotificationChannel;
|
||||||
use App\Enums\NotificationEventType;
|
use App\Enums\NotificationEventType;
|
||||||
|
use App\Enums\NotificationFilterAttribute;
|
||||||
|
use App\Enums\NotificationFilterOperator;
|
||||||
use App\Http\Resources\NotificationActionResource;
|
use App\Http\Resources\NotificationActionResource;
|
||||||
use App\Http\Resources\ProjectResource;
|
use App\Http\Resources\ProjectResource;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
@@ -23,6 +25,8 @@ class BuildProjectNotificationSettingsPayload
|
|||||||
return [
|
return [
|
||||||
'channelOptions' => NotificationChannel::options(),
|
'channelOptions' => NotificationChannel::options(),
|
||||||
'eventTypeOptions' => NotificationEventType::options(),
|
'eventTypeOptions' => NotificationEventType::options(),
|
||||||
|
'filterAttributeOptions' => NotificationFilterAttribute::options(),
|
||||||
|
'filterOperatorOptions' => NotificationFilterOperator::options(),
|
||||||
'discordAllowedMentionOptions' => DiscordAllowedMentionType::options(),
|
'discordAllowedMentionOptions' => DiscordAllowedMentionType::options(),
|
||||||
'notificationActions' => NotificationActionResource::collection(
|
'notificationActions' => NotificationActionResource::collection(
|
||||||
$this->listProjectNotificationActions->handle($project),
|
$this->listProjectNotificationActions->handle($project),
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Notifications;
|
||||||
|
|
||||||
|
use App\Enums\NotificationFilterOperator;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
|
class MatchNotificationFilters
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, array{attribute?: mixed, operator?: mixed, value?: mixed}> $filters
|
||||||
|
*/
|
||||||
|
public function handle(array $filters, NotificationEventPayload $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<string, mixed> $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<int, string>|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<int, string>|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<int, string>|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<int, string>
|
||||||
|
*/
|
||||||
|
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<string, mixed>
|
||||||
|
*/
|
||||||
|
private function payloadContext(NotificationEventPayload $payload): array
|
||||||
|
{
|
||||||
|
$projectId = property_exists($payload, 'projectId') ? $payload->projectId : $payload->projectId();
|
||||||
|
$projectKey = property_exists($payload, 'projectKey') ? $payload->projectKey : null;
|
||||||
|
$projectName = property_exists($payload, 'projectName') ? $payload->projectName : null;
|
||||||
|
$ticketId = property_exists($payload, 'ticketId') ? $payload->ticketId : null;
|
||||||
|
$ticketKey = property_exists($payload, 'ticketKey') ? $payload->ticketKey : null;
|
||||||
|
$ticketTitle = property_exists($payload, 'ticketTitle') ? $payload->ticketTitle : null;
|
||||||
|
$ticketUrl = property_exists($payload, 'ticketUrl') ? $payload->ticketUrl : null;
|
||||||
|
$actorId = property_exists($payload, 'actorId') ? $payload->actorId : null;
|
||||||
|
$actorName = property_exists($payload, 'actorName') ? $payload->actorName : null;
|
||||||
|
$fromStatusId = property_exists($payload, 'fromStatusId') ? $payload->fromStatusId : null;
|
||||||
|
$fromStatusLabel = property_exists($payload, 'fromStatusLabel') ? $payload->fromStatusLabel : null;
|
||||||
|
$toStatusId = property_exists($payload, 'toStatusId') ? $payload->toStatusId : null;
|
||||||
|
$toStatusLabel = property_exists($payload, 'toStatusLabel') ? $payload->toStatusLabel : null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'project' => [
|
||||||
|
'id' => $projectId,
|
||||||
|
'key' => $projectKey,
|
||||||
|
'name' => $projectName,
|
||||||
|
],
|
||||||
|
'ticket' => [
|
||||||
|
'id' => $ticketId,
|
||||||
|
'key' => $ticketKey,
|
||||||
|
'title' => $ticketTitle,
|
||||||
|
'url' => $ticketUrl,
|
||||||
|
],
|
||||||
|
'actor' => [
|
||||||
|
'id' => $actorId,
|
||||||
|
'name' => $actorName,
|
||||||
|
],
|
||||||
|
'status' => [
|
||||||
|
'from' => [
|
||||||
|
'id' => $fromStatusId,
|
||||||
|
'label' => $fromStatusLabel,
|
||||||
|
],
|
||||||
|
'to' => [
|
||||||
|
'id' => $toStatusId,
|
||||||
|
'label' => $toStatusLabel,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Notifications;
|
||||||
|
|
||||||
|
use App\Enums\NotificationFilterOperator;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
|
class NormalizeNotificationFilters
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<int, array{attribute: string, operator: string, value: array<int, string>|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<int, string>|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<int, string>
|
||||||
|
*/
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,10 @@ use App\Models\NotificationDeliveryLog;
|
|||||||
|
|
||||||
class QueueNotificationEventDeliveries
|
class QueueNotificationEventDeliveries
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly MatchNotificationFilters $matchNotificationFilters,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function handle(NotificationEvent $event): void
|
public function handle(NotificationEvent $event): void
|
||||||
{
|
{
|
||||||
$payload = $event->payload();
|
$payload = $event->payload();
|
||||||
@@ -18,8 +22,11 @@ class QueueNotificationEventDeliveries
|
|||||||
->enabled()
|
->enabled()
|
||||||
->where('project_id', $payload->projectId())
|
->where('project_id', $payload->projectId())
|
||||||
->where('event_type', $eventType->value)
|
->where('event_type', $eventType->value)
|
||||||
->get(['channel', 'id'])
|
->get(['channel', 'filters', 'id'])
|
||||||
->each(function (NotificationAction $notificationAction) use ($event, $payload): void {
|
->each(function (NotificationAction $notificationAction) use ($event, $payload): void {
|
||||||
|
if (! $this->matchNotificationFilters->handle($notificationAction->filters ?? [], $payload)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
$notificationDeliveryLogId = null;
|
$notificationDeliveryLogId = null;
|
||||||
|
|
||||||
if (in_array($notificationAction->channel, [NotificationChannel::Discord, NotificationChannel::Webhook], true)) {
|
if (in_array($notificationAction->channel, [NotificationChannel::Discord, NotificationChannel::Webhook], true)) {
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ use Illuminate\Support\Arr;
|
|||||||
|
|
||||||
class StoreProjectNotificationAction
|
class StoreProjectNotificationAction
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly NormalizeNotificationFilters $normalizeNotificationFilters,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{
|
* @param array{
|
||||||
* channel: mixed,
|
* channel: mixed,
|
||||||
@@ -15,6 +19,7 @@ class StoreProjectNotificationAction
|
|||||||
* delivery_url: string,
|
* delivery_url: string,
|
||||||
* enabled: bool,
|
* enabled: bool,
|
||||||
* event_type: mixed,
|
* event_type: mixed,
|
||||||
|
* filters?: mixed,
|
||||||
* message_template?: string|null,
|
* message_template?: string|null,
|
||||||
* name: string
|
* name: string
|
||||||
* } $validated
|
* } $validated
|
||||||
@@ -27,6 +32,7 @@ class StoreProjectNotificationAction
|
|||||||
'delivery_url' => trim((string) Arr::get($validated, 'delivery_url')),
|
'delivery_url' => trim((string) Arr::get($validated, 'delivery_url')),
|
||||||
'enabled' => (bool) Arr::get($validated, 'enabled'),
|
'enabled' => (bool) Arr::get($validated, 'enabled'),
|
||||||
'event_type' => Arr::get($validated, 'event_type'),
|
'event_type' => Arr::get($validated, 'event_type'),
|
||||||
|
'filters' => $this->normalizeNotificationFilters->handle(Arr::get($validated, 'filters')),
|
||||||
'message_template' => $this->normalizeMessageTemplate(Arr::get($validated, 'message_template')),
|
'message_template' => $this->normalizeMessageTemplate(Arr::get($validated, 'message_template')),
|
||||||
'name' => trim((string) Arr::get($validated, 'name')),
|
'name' => trim((string) Arr::get($validated, 'name')),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ use Illuminate\Support\Arr;
|
|||||||
|
|
||||||
class UpdateProjectNotificationAction
|
class UpdateProjectNotificationAction
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly NormalizeNotificationFilters $normalizeNotificationFilters,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{
|
* @param array{
|
||||||
* channel?: mixed,
|
* channel?: mixed,
|
||||||
@@ -15,6 +19,7 @@ class UpdateProjectNotificationAction
|
|||||||
* delivery_url?: string|null,
|
* delivery_url?: string|null,
|
||||||
* enabled?: bool,
|
* enabled?: bool,
|
||||||
* event_type?: mixed,
|
* event_type?: mixed,
|
||||||
|
* filters?: mixed,
|
||||||
* message_template?: string|null,
|
* message_template?: string|null,
|
||||||
* name?: string
|
* name?: string
|
||||||
* } $validated
|
* } $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');
|
$deliveryUrl = Arr::get($validated, 'delivery_url');
|
||||||
|
|
||||||
if (is_string($deliveryUrl) && trim($deliveryUrl) !== '') {
|
if (is_string($deliveryUrl) && trim($deliveryUrl) !== '') {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum NotificationFilterAttribute: string
|
||||||
|
{
|
||||||
|
case ProjectId = 'project.id';
|
||||||
|
case ProjectKey = 'project.key';
|
||||||
|
case ProjectName = 'project.name';
|
||||||
|
case TicketId = 'ticket.id';
|
||||||
|
case TicketKey = 'ticket.key';
|
||||||
|
case TicketTitle = 'ticket.title';
|
||||||
|
case TicketUrl = 'ticket.url';
|
||||||
|
case ActorId = 'actor.id';
|
||||||
|
case ActorName = 'actor.name';
|
||||||
|
case StatusFromId = 'status.from.id';
|
||||||
|
case StatusFromLabel = 'status.from.label';
|
||||||
|
case StatusToId = 'status.to.id';
|
||||||
|
case StatusToLabel = 'status.to.label';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::ProjectId => '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<int, array{label: string, value: string}>
|
||||||
|
*/
|
||||||
|
public static function options(): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
static fn (self $attribute): array => [
|
||||||
|
'label' => $attribute->label(),
|
||||||
|
'value' => $attribute->value,
|
||||||
|
],
|
||||||
|
self::cases(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public static function values(): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
static fn (self $attribute): string => $attribute->value,
|
||||||
|
self::cases(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum NotificationFilterOperator: string
|
||||||
|
{
|
||||||
|
case Equals = 'equals';
|
||||||
|
case NotEquals = 'not_equals';
|
||||||
|
case Exists = 'exists';
|
||||||
|
case NotExists = 'not_exists';
|
||||||
|
case In = 'in';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Equals => 'Equals',
|
||||||
|
self::NotEquals => 'Not equals',
|
||||||
|
self::Exists => 'Exists',
|
||||||
|
self::NotExists => 'Not exists',
|
||||||
|
self::In => 'In list',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{label: string, value: string}>
|
||||||
|
*/
|
||||||
|
public static function options(): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
static fn (self $operator): array => [
|
||||||
|
'label' => $operator->label(),
|
||||||
|
'value' => $operator->value,
|
||||||
|
],
|
||||||
|
self::cases(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public static function values(): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
static fn (self $operator): string => $operator->value,
|
||||||
|
self::cases(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Concerns;
|
||||||
|
|
||||||
|
use App\Actions\Notifications\NormalizeNotificationFilters;
|
||||||
|
use App\Enums\NotificationFilterAttribute;
|
||||||
|
use App\Enums\NotificationFilterOperator;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
|
trait InteractsWithNotificationFilters
|
||||||
|
{
|
||||||
|
protected function prepareNotificationFiltersForValidation(): void
|
||||||
|
{
|
||||||
|
$rawFilters = $this->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<string, mixed>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,12 +5,16 @@ namespace App\Http\Requests;
|
|||||||
use App\Enums\DiscordAllowedMentionType;
|
use App\Enums\DiscordAllowedMentionType;
|
||||||
use App\Enums\NotificationChannel;
|
use App\Enums\NotificationChannel;
|
||||||
use App\Enums\NotificationEventType;
|
use App\Enums\NotificationEventType;
|
||||||
|
use App\Http\Requests\Concerns\InteractsWithNotificationFilters;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
class StoreProjectNotificationActionRequest extends FormRequest
|
class StoreProjectNotificationActionRequest extends FormRequest
|
||||||
{
|
{
|
||||||
|
use InteractsWithNotificationFilters;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine if the user is authorized to make this request.
|
* Determine if the user is authorized to make this request.
|
||||||
*/
|
*/
|
||||||
@@ -39,7 +43,52 @@ class StoreProjectNotificationActionRequest extends FormRequest
|
|||||||
'channel_configuration.allowed_mentions.*' => ['nullable', 'string', Rule::in(DiscordAllowedMentionType::values())],
|
'channel_configuration.allowed_mentions.*' => ['nullable', 'string', Rule::in(DiscordAllowedMentionType::values())],
|
||||||
'enabled' => ['required', 'boolean'],
|
'enabled' => ['required', 'boolean'],
|
||||||
'delivery_url' => ['required', 'string', 'max:2048', 'url'],
|
'delivery_url' => ['required', 'string', 'max:2048', 'url'],
|
||||||
'message_template' => $messageTemplateRules,
|
'message_template' => $this->messageTemplateRules(),
|
||||||
|
...$this->notificationFilterRules(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$this->prepareNotificationFiltersForValidation();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withValidator(Validator $validator): void
|
||||||
|
{
|
||||||
|
$this->withNotificationFilterValidator($validator);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function channels(): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
static fn (NotificationChannel $channel): string => $channel->value,
|
||||||
|
NotificationChannel::cases(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function eventTypes(): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
static fn (NotificationEventType $eventType): string => $eventType->value,
|
||||||
|
NotificationEventType::cases(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, ValidationRule|string>
|
||||||
|
*/
|
||||||
|
private function messageTemplateRules(): array
|
||||||
|
{
|
||||||
|
if ($this->input('channel') === NotificationChannel::Discord->value) {
|
||||||
|
return ['required', 'string', 'max:2000'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['nullable', 'string', 'max:2000'];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ namespace App\Http\Requests;
|
|||||||
use App\Enums\DiscordAllowedMentionType;
|
use App\Enums\DiscordAllowedMentionType;
|
||||||
use App\Enums\NotificationChannel;
|
use App\Enums\NotificationChannel;
|
||||||
use App\Enums\NotificationEventType;
|
use App\Enums\NotificationEventType;
|
||||||
|
use App\Http\Requests\Concerns\InteractsWithNotificationFilters;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
class UpdateProjectNotificationActionRequest extends FormRequest
|
class UpdateProjectNotificationActionRequest extends FormRequest
|
||||||
{
|
{
|
||||||
|
use InteractsWithNotificationFilters;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine if the user is authorized to make this request.
|
* Determine if the user is authorized to make this request.
|
||||||
*/
|
*/
|
||||||
@@ -39,7 +43,52 @@ class UpdateProjectNotificationActionRequest extends FormRequest
|
|||||||
'channel_configuration.allowed_mentions.*' => ['nullable', 'string', Rule::in(DiscordAllowedMentionType::values())],
|
'channel_configuration.allowed_mentions.*' => ['nullable', 'string', Rule::in(DiscordAllowedMentionType::values())],
|
||||||
'enabled' => ['required', 'boolean'],
|
'enabled' => ['required', 'boolean'],
|
||||||
'delivery_url' => ['nullable', 'string', 'max:2048', 'url'],
|
'delivery_url' => ['nullable', 'string', 'max:2048', 'url'],
|
||||||
'message_template' => $messageTemplateRules,
|
'message_template' => $this->messageTemplateRules(),
|
||||||
|
...$this->notificationFilterRules(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$this->prepareNotificationFiltersForValidation();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withValidator(Validator $validator): void
|
||||||
|
{
|
||||||
|
$this->withNotificationFilterValidator($validator);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function channels(): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
static fn (NotificationChannel $channel): string => $channel->value,
|
||||||
|
NotificationChannel::cases(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function eventTypes(): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
static fn (NotificationEventType $eventType): string => $eventType->value,
|
||||||
|
NotificationEventType::cases(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, ValidationRule|string>
|
||||||
|
*/
|
||||||
|
private function messageTemplateRules(): array
|
||||||
|
{
|
||||||
|
if ($this->input('channel') === NotificationChannel::Discord->value) {
|
||||||
|
return ['required', 'string', 'max:2000'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['nullable', 'string', 'max:2000'];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class NotificationActionResource extends JsonResource
|
|||||||
'channel' => $this->channel->value,
|
'channel' => $this->channel->value,
|
||||||
'channel_label' => $this->channel->label(),
|
'channel_label' => $this->channel->label(),
|
||||||
'channel_configuration' => $this->channel_configuration ?? [],
|
'channel_configuration' => $this->channel_configuration ?? [],
|
||||||
|
'filters' => $this->filters ?? [],
|
||||||
'has_delivery_url' => $this->resource instanceof NotificationAction && filled($this->delivery_url),
|
'has_delivery_url' => $this->resource instanceof NotificationAction && filled($this->delivery_url),
|
||||||
'message_template' => $this->message_template,
|
'message_template' => $this->message_template,
|
||||||
'created_at' => $this->created_at?->toISOString(),
|
'created_at' => $this->created_at?->toISOString(),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
'delivery_url',
|
'delivery_url',
|
||||||
'enabled',
|
'enabled',
|
||||||
'event_type',
|
'event_type',
|
||||||
|
'filters',
|
||||||
'message_template',
|
'message_template',
|
||||||
'name',
|
'name',
|
||||||
'project_id',
|
'project_id',
|
||||||
@@ -40,6 +41,7 @@ class NotificationAction extends Model
|
|||||||
'delivery_url' => 'encrypted',
|
'delivery_url' => 'encrypted',
|
||||||
'enabled' => 'boolean',
|
'enabled' => 'boolean',
|
||||||
'event_type' => NotificationEventType::class,
|
'event_type' => NotificationEventType::class,
|
||||||
|
'filters' => 'array',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class NotificationActionFactory extends Factory
|
|||||||
'delivery_url' => 'https://hooks.'.fake()->domainName().'/kanbangit/'.fake()->uuid(),
|
'delivery_url' => 'https://hooks.'.fake()->domainName().'/kanbangit/'.fake()->uuid(),
|
||||||
'enabled' => true,
|
'enabled' => true,
|
||||||
'event_type' => NotificationEventType::TicketStatusChanged,
|
'event_type' => NotificationEventType::TicketStatusChanged,
|
||||||
|
'filters' => [],
|
||||||
'message_template' => null,
|
'message_template' => null,
|
||||||
'name' => fake()->unique()->sentence(3),
|
'name' => fake()->unique()->sentence(3),
|
||||||
'project_id' => Project::factory(),
|
'project_id' => Project::factory(),
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('notification_actions', function (Blueprint $table): void {
|
||||||
|
$table->json('filters')->nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('notification_actions', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('filters');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||||
import { reactive } from 'vue';
|
import { computed, reactive } from 'vue';
|
||||||
import ProjectNotificationSettingsController from '@/actions/App/Http/Controllers/ProjectNotificationSettingsController';
|
import ProjectNotificationSettingsController from '@/actions/App/Http/Controllers/ProjectNotificationSettingsController';
|
||||||
import Heading from '@/components/Heading.vue';
|
import Heading from '@/components/Heading.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
@@ -28,18 +28,45 @@ import type {
|
|||||||
BreadcrumbItem,
|
BreadcrumbItem,
|
||||||
ProjectNotificationSettingsPageProps,
|
ProjectNotificationSettingsPageProps,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
|
import type { NotificationActionFilterModel } from '@/types/models/notification-action-filter-model';
|
||||||
import type { NotificationActionModel } from '@/types/models/notification-action-model';
|
import type { NotificationActionModel } from '@/types/models/notification-action-model';
|
||||||
|
|
||||||
|
type NotificationFilterFormState = {
|
||||||
|
attribute: string;
|
||||||
|
id: string;
|
||||||
|
operator: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NotificationFilterPayload = {
|
||||||
|
attribute: string;
|
||||||
|
operator: string;
|
||||||
|
value: string | string[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
const props = defineProps<ProjectNotificationSettingsPageProps>();
|
const props = defineProps<ProjectNotificationSettingsPageProps>();
|
||||||
|
|
||||||
const defaultCreateChannel =
|
const defaultCreateChannel =
|
||||||
props.channelOptions.find((option) => option.value === 'webhook')?.value ??
|
props.channelOptions.find((option) => option.value === 'webhook')?.value ??
|
||||||
props.channelOptions[0]?.value ??
|
props.channelOptions[0]?.value ??
|
||||||
'';
|
'';
|
||||||
|
const defaultFilterAttribute = props.filterAttributeOptions[0]?.value ?? '';
|
||||||
|
const defaultFilterOperator =
|
||||||
|
props.filterOperatorOptions.find((option) => option.value === 'equals')
|
||||||
|
?.value ??
|
||||||
|
props.filterOperatorOptions[0]?.value ??
|
||||||
|
'';
|
||||||
|
|
||||||
const createChannelState = reactive<{ value: string }>({
|
const createChannelState = reactive<{ value: string }>({
|
||||||
value: defaultCreateChannel,
|
value: defaultCreateChannel,
|
||||||
});
|
});
|
||||||
|
const createFilterState = reactive<{ filters: NotificationFilterFormState[] }>({
|
||||||
|
filters: [],
|
||||||
|
});
|
||||||
const editChannelState = reactive<Record<number, string>>({});
|
const editChannelState = reactive<Record<number, string>>({});
|
||||||
|
const editFilterState = reactive<Record<number, NotificationFilterFormState[]>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
const timestampFormatter = new Intl.DateTimeFormat(undefined, {
|
const timestampFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
dateStyle: 'medium',
|
dateStyle: 'medium',
|
||||||
timeStyle: 'short',
|
timeStyle: 'short',
|
||||||
@@ -60,8 +87,106 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
let filterStateCounter = 0;
|
||||||
|
|
||||||
|
const nextFilterStateId = (): string => `notification-filter-${filterStateCounter++}`;
|
||||||
|
|
||||||
|
const parseInFilterValues = (value: string): string[] =>
|
||||||
|
value
|
||||||
|
.split(/[\n,]/)
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter((entry) => entry.length > 0);
|
||||||
|
|
||||||
|
const supportsFilterValue = (operator: string): boolean =>
|
||||||
|
!['exists', 'not_exists'].includes(operator);
|
||||||
|
|
||||||
|
const supportsArrayFilterValue = (operator: string): boolean => operator === 'in';
|
||||||
|
|
||||||
|
const createFilter = (): NotificationFilterFormState => ({
|
||||||
|
attribute: defaultFilterAttribute,
|
||||||
|
id: nextFilterStateId(),
|
||||||
|
operator: defaultFilterOperator,
|
||||||
|
value: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const toFilterFormState = (
|
||||||
|
filter: NotificationActionFilterModel,
|
||||||
|
): NotificationFilterFormState => ({
|
||||||
|
attribute: filter.attribute,
|
||||||
|
id: nextFilterStateId(),
|
||||||
|
operator: filter.operator,
|
||||||
|
value: Array.isArray(filter.value)
|
||||||
|
? filter.value.join(', ')
|
||||||
|
: (filter.value ?? ''),
|
||||||
|
});
|
||||||
|
|
||||||
|
const serializeFilters = (
|
||||||
|
filters: NotificationFilterFormState[],
|
||||||
|
): NotificationFilterPayload[] =>
|
||||||
|
filters.map((filter) => ({
|
||||||
|
attribute: filter.attribute,
|
||||||
|
operator: filter.operator,
|
||||||
|
value: supportsArrayFilterValue(filter.operator)
|
||||||
|
? parseInFilterValues(filter.value)
|
||||||
|
: supportsFilterValue(filter.operator)
|
||||||
|
? filter.value.trim()
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const notificationFilterError = (
|
||||||
|
errors: Record<string, string>,
|
||||||
|
): string | undefined => {
|
||||||
|
if (errors.filters) {
|
||||||
|
return errors.filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestedErrorKey = Object.keys(errors).find((key) =>
|
||||||
|
key.startsWith('filters.'),
|
||||||
|
);
|
||||||
|
|
||||||
|
return nestedErrorKey === undefined ? undefined : errors[nestedErrorKey];
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCreateFilter = (): void => {
|
||||||
|
createFilterState.filters.push(createFilter());
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCreateFilter = (index: number): void => {
|
||||||
|
createFilterState.filters.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetCreateFilters = (): void => {
|
||||||
|
createFilterState.filters = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const editFilters = (notificationActionId: number): NotificationFilterFormState[] =>
|
||||||
|
editFilterState[notificationActionId] ?? [];
|
||||||
|
|
||||||
|
const addEditFilter = (notificationActionId: number): void => {
|
||||||
|
editFilterState[notificationActionId] = [
|
||||||
|
...editFilters(notificationActionId),
|
||||||
|
createFilter(),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeEditFilter = (notificationActionId: number, index: number): void => {
|
||||||
|
editFilterState[notificationActionId] = editFilters(notificationActionId).filter(
|
||||||
|
(_, candidateIndex) => candidateIndex !== index,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createSerializedFilters = computed<string>(() =>
|
||||||
|
JSON.stringify(serializeFilters(createFilterState.filters)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const editSerializedFilters = (notificationActionId: number): string =>
|
||||||
|
JSON.stringify(serializeFilters(editFilters(notificationActionId)));
|
||||||
|
|
||||||
props.notificationActions.forEach((notificationAction) => {
|
props.notificationActions.forEach((notificationAction) => {
|
||||||
editChannelState[notificationAction.id] = notificationAction.channel;
|
editChannelState[notificationAction.id] = notificationAction.channel;
|
||||||
|
editFilterState[notificationAction.id] = (
|
||||||
|
notificationAction.filters ?? []
|
||||||
|
).map(toFilterFormState);
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedChannel = (notificationAction: NotificationActionModel): string =>
|
const selectedChannel = (notificationAction: NotificationActionModel): string =>
|
||||||
@@ -202,7 +327,8 @@ const notificationActionTimestamp = (
|
|||||||
reset-on-success
|
reset-on-success
|
||||||
@success="
|
@success="
|
||||||
createChannelState.value =
|
createChannelState.value =
|
||||||
defaultCreateChannel
|
defaultCreateChannel;
|
||||||
|
resetCreateFilters();
|
||||||
"
|
"
|
||||||
class="space-y-4"
|
class="space-y-4"
|
||||||
v-slot="{ errors, processing }"
|
v-slot="{ errors, processing }"
|
||||||
@@ -372,6 +498,148 @@ const notificationActionTimestamp = (
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3 rounded-lg border p-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label>Notification filters</Label>
|
||||||
|
<p
|
||||||
|
class="text-muted-foreground text-sm"
|
||||||
|
>
|
||||||
|
Optional rules that must match the
|
||||||
|
ticket payload before this
|
||||||
|
notification is dispatched.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="filters"
|
||||||
|
:value="createSerializedFilters"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="createFilterState.filters.length === 0"
|
||||||
|
class="text-muted-foreground text-sm"
|
||||||
|
>
|
||||||
|
No filters configured. This action runs
|
||||||
|
for every matching event.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(
|
||||||
|
filter,
|
||||||
|
filterIndex
|
||||||
|
) in createFilterState.filters"
|
||||||
|
:key="filter.id"
|
||||||
|
class="grid gap-3 rounded-md border p-3"
|
||||||
|
>
|
||||||
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label
|
||||||
|
:for="`create-filter-attribute-${filter.id}`"
|
||||||
|
>
|
||||||
|
Attribute
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
:id="`create-filter-attribute-${filter.id}`"
|
||||||
|
v-model="filter.attribute"
|
||||||
|
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="filterAttributeOption in filterAttributeOptions"
|
||||||
|
:key="filterAttributeOption.value"
|
||||||
|
:value="filterAttributeOption.value"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
filterAttributeOption.label
|
||||||
|
}}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label
|
||||||
|
:for="`create-filter-operator-${filter.id}`"
|
||||||
|
>
|
||||||
|
Operator
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
:id="`create-filter-operator-${filter.id}`"
|
||||||
|
v-model="filter.operator"
|
||||||
|
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="filterOperatorOption in filterOperatorOptions"
|
||||||
|
:key="filterOperatorOption.value"
|
||||||
|
:value="filterOperatorOption.value"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
filterOperatorOption.label
|
||||||
|
}}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="supportsFilterValue(filter.operator)"
|
||||||
|
class="grid gap-2"
|
||||||
|
>
|
||||||
|
<Label
|
||||||
|
:for="`create-filter-value-${filter.id}`"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
supportsArrayFilterValue(
|
||||||
|
filter.operator,
|
||||||
|
)
|
||||||
|
? 'Values (comma or newline separated)'
|
||||||
|
: 'Value'
|
||||||
|
}}
|
||||||
|
</Label>
|
||||||
|
<textarea
|
||||||
|
:id="`create-filter-value-${filter.id}`"
|
||||||
|
v-model="filter.value"
|
||||||
|
rows="2"
|
||||||
|
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-h-20 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
||||||
|
:placeholder="
|
||||||
|
supportsArrayFilterValue(
|
||||||
|
filter.operator,
|
||||||
|
)
|
||||||
|
? 'CORE-1, CORE-2'
|
||||||
|
: 'Doing'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
@click="
|
||||||
|
removeCreateFilter(
|
||||||
|
filterIndex,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Remove filter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-start">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
@click="addCreateFilter"
|
||||||
|
>
|
||||||
|
Add filter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InputError
|
||||||
|
:message="notificationFilterError(errors)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label
|
<Label
|
||||||
for="create-notification-enabled"
|
for="create-notification-enabled"
|
||||||
@@ -701,6 +969,159 @@ const notificationActionTimestamp = (
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3 rounded-lg border p-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label>Notification filters</Label>
|
||||||
|
<p
|
||||||
|
class="text-muted-foreground text-sm"
|
||||||
|
>
|
||||||
|
Optional rules that must all match
|
||||||
|
this event payload before delivery.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="filters"
|
||||||
|
:value="
|
||||||
|
editSerializedFilters(
|
||||||
|
notificationAction.id,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
editFilters(notificationAction.id)
|
||||||
|
.length === 0
|
||||||
|
"
|
||||||
|
class="text-muted-foreground text-sm"
|
||||||
|
>
|
||||||
|
No filters configured. This action runs
|
||||||
|
for every matching event.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(
|
||||||
|
filter,
|
||||||
|
filterIndex
|
||||||
|
) in editFilters(notificationAction.id)"
|
||||||
|
:key="filter.id"
|
||||||
|
class="grid gap-3 rounded-md border p-3"
|
||||||
|
>
|
||||||
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label
|
||||||
|
:for="`notification-filter-attribute-${notificationAction.id}-${filter.id}`"
|
||||||
|
>
|
||||||
|
Attribute
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
:id="`notification-filter-attribute-${notificationAction.id}-${filter.id}`"
|
||||||
|
v-model="filter.attribute"
|
||||||
|
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="filterAttributeOption in filterAttributeOptions"
|
||||||
|
:key="filterAttributeOption.value"
|
||||||
|
:value="filterAttributeOption.value"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
filterAttributeOption.label
|
||||||
|
}}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label
|
||||||
|
:for="`notification-filter-operator-${notificationAction.id}-${filter.id}`"
|
||||||
|
>
|
||||||
|
Operator
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
:id="`notification-filter-operator-${notificationAction.id}-${filter.id}`"
|
||||||
|
v-model="filter.operator"
|
||||||
|
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="filterOperatorOption in filterOperatorOptions"
|
||||||
|
:key="filterOperatorOption.value"
|
||||||
|
:value="filterOperatorOption.value"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
filterOperatorOption.label
|
||||||
|
}}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="supportsFilterValue(filter.operator)"
|
||||||
|
class="grid gap-2"
|
||||||
|
>
|
||||||
|
<Label
|
||||||
|
:for="`notification-filter-value-${notificationAction.id}-${filter.id}`"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
supportsArrayFilterValue(
|
||||||
|
filter.operator,
|
||||||
|
)
|
||||||
|
? 'Values (comma or newline separated)'
|
||||||
|
: 'Value'
|
||||||
|
}}
|
||||||
|
</Label>
|
||||||
|
<textarea
|
||||||
|
:id="`notification-filter-value-${notificationAction.id}-${filter.id}`"
|
||||||
|
v-model="filter.value"
|
||||||
|
rows="2"
|
||||||
|
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-h-20 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
||||||
|
:placeholder="
|
||||||
|
supportsArrayFilterValue(
|
||||||
|
filter.operator,
|
||||||
|
)
|
||||||
|
? 'CORE-1, CORE-2'
|
||||||
|
: 'Doing'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
@click="
|
||||||
|
removeEditFilter(
|
||||||
|
notificationAction.id,
|
||||||
|
filterIndex,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Remove filter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-start">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
@click="
|
||||||
|
addEditFilter(
|
||||||
|
notificationAction.id,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Add filter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InputError
|
||||||
|
:message="notificationFilterError(errors)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex flex-col gap-3 border-t pt-4 sm:flex-row sm:items-center sm:justify-between"
|
class="flex flex-col gap-3 border-t pt-4 sm:flex-row sm:items-center sm:justify-between"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export type NotificationActionFilterModel = {
|
||||||
|
attribute: string;
|
||||||
|
operator: string;
|
||||||
|
value: string | string[] | null;
|
||||||
|
};
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { NotificationActionFilterModel } from '@/types/models/notification-action-filter-model';
|
||||||
|
|
||||||
export type NotificationActionModel = {
|
export type NotificationActionModel = {
|
||||||
id: number;
|
id: number;
|
||||||
project_id: number;
|
project_id: number;
|
||||||
@@ -8,6 +10,7 @@ export type NotificationActionModel = {
|
|||||||
channel: string;
|
channel: string;
|
||||||
channel_label: string;
|
channel_label: string;
|
||||||
channel_configuration: Record<string, unknown>;
|
channel_configuration: Record<string, unknown>;
|
||||||
|
filters: NotificationActionFilterModel[];
|
||||||
has_delivery_url: boolean;
|
has_delivery_url: boolean;
|
||||||
message_template: string | null;
|
message_template: string | null;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ export type ProjectRepositorySettingsPageProps = {
|
|||||||
export type ProjectNotificationSettingsPageProps = {
|
export type ProjectNotificationSettingsPageProps = {
|
||||||
channelOptions: NotificationActionOptionModel[];
|
channelOptions: NotificationActionOptionModel[];
|
||||||
eventTypeOptions: NotificationActionOptionModel[];
|
eventTypeOptions: NotificationActionOptionModel[];
|
||||||
|
filterAttributeOptions: NotificationActionOptionModel[];
|
||||||
|
filterOperatorOptions: NotificationActionOptionModel[];
|
||||||
discordAllowedMentionOptions: NotificationActionOptionModel[];
|
discordAllowedMentionOptions: NotificationActionOptionModel[];
|
||||||
notificationActions: NotificationActionModel[];
|
notificationActions: NotificationActionModel[];
|
||||||
project: ProjectModel;
|
project: ProjectModel;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class NotificationActionTest extends TestCase
|
|||||||
$this->assertSame(NotificationEventType::TicketStatusChanged, $notificationAction->fresh()->event_type);
|
$this->assertSame(NotificationEventType::TicketStatusChanged, $notificationAction->fresh()->event_type);
|
||||||
$this->assertSame(NotificationChannel::Webhook, $notificationAction->fresh()->channel);
|
$this->assertSame(NotificationChannel::Webhook, $notificationAction->fresh()->channel);
|
||||||
$this->assertSame([], $notificationAction->fresh()->channel_configuration);
|
$this->assertSame([], $notificationAction->fresh()->channel_configuration);
|
||||||
|
$this->assertSame([], $notificationAction->fresh()->filters ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,5 +62,6 @@ class NotificationActionTest extends TestCase
|
|||||||
$this->assertArrayNotHasKey('delivery_url', $notificationAction->fresh()->toArray());
|
$this->assertArrayNotHasKey('delivery_url', $notificationAction->fresh()->toArray());
|
||||||
$this->assertArrayNotHasKey('delivery_url', $resource);
|
$this->assertArrayNotHasKey('delivery_url', $resource);
|
||||||
$this->assertTrue($resource['has_delivery_url']);
|
$this->assertTrue($resource['has_delivery_url']);
|
||||||
|
$this->assertSame([], $resource['filters']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ class ProjectNotificationActionManagementTest extends TestCase
|
|||||||
'event_type' => NotificationEventType::TicketStatusChanged,
|
'event_type' => NotificationEventType::TicketStatusChanged,
|
||||||
'channel' => NotificationChannel::Discord,
|
'channel' => NotificationChannel::Discord,
|
||||||
'channel_configuration' => ['mentions' => ['ops']],
|
'channel_configuration' => ['mentions' => ['ops']],
|
||||||
|
'filters' => [
|
||||||
|
[
|
||||||
|
'attribute' => 'status.to.label',
|
||||||
|
'operator' => 'equals',
|
||||||
|
'value' => 'Doing',
|
||||||
|
],
|
||||||
|
],
|
||||||
'message_template' => '$ticket_key changed to $status_to',
|
'message_template' => '$ticket_key changed to $status_to',
|
||||||
'delivery_url' => 'https://discord.com/api/webhooks/123456/discord-alerts',
|
'delivery_url' => 'https://discord.com/api/webhooks/123456/discord-alerts',
|
||||||
]);
|
]);
|
||||||
@@ -58,6 +65,13 @@ class ProjectNotificationActionManagementTest extends TestCase
|
|||||||
$this->assertCount(2, $listedActions);
|
$this->assertCount(2, $listedActions);
|
||||||
$this->assertSame(['Discord alerts', 'Webhook alerts'], $listedActions->pluck('name')->all());
|
$this->assertSame(['Discord alerts', 'Webhook alerts'], $listedActions->pluck('name')->all());
|
||||||
$this->assertSame(['mentions' => ['ops']], $listedActions->first()->channel_configuration);
|
$this->assertSame(['mentions' => ['ops']], $listedActions->first()->channel_configuration);
|
||||||
|
$this->assertSame([
|
||||||
|
[
|
||||||
|
'attribute' => 'status.to.label',
|
||||||
|
'operator' => 'equals',
|
||||||
|
'value' => 'Doing',
|
||||||
|
],
|
||||||
|
], $listedActions->first()->filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,6 +88,13 @@ class ProjectNotificationActionManagementTest extends TestCase
|
|||||||
|
|
||||||
$updatedAction = $updateAction->handle($project, $notificationAction, [
|
$updatedAction = $updateAction->handle($project, $notificationAction, [
|
||||||
'enabled' => false,
|
'enabled' => false,
|
||||||
|
'filters' => [
|
||||||
|
[
|
||||||
|
'attribute' => 'ticket.key',
|
||||||
|
'operator' => 'in',
|
||||||
|
'value' => 'CORE-1, CORE-2',
|
||||||
|
],
|
||||||
|
],
|
||||||
'message_template' => '$ticket_key moved from $status_from to $status_to',
|
'message_template' => '$ticket_key moved from $status_from to $status_to',
|
||||||
'channel_configuration' => ['mentions' => ['support']],
|
'channel_configuration' => ['mentions' => ['support']],
|
||||||
'delivery_url' => '',
|
'delivery_url' => '',
|
||||||
@@ -85,6 +106,13 @@ class ProjectNotificationActionManagementTest extends TestCase
|
|||||||
$updatedAction->message_template,
|
$updatedAction->message_template,
|
||||||
);
|
);
|
||||||
$this->assertSame(['mentions' => ['support']], $updatedAction->channel_configuration);
|
$this->assertSame(['mentions' => ['support']], $updatedAction->channel_configuration);
|
||||||
|
$this->assertSame([
|
||||||
|
[
|
||||||
|
'attribute' => 'ticket.key',
|
||||||
|
'operator' => 'in',
|
||||||
|
'value' => ['CORE-1', 'CORE-2'],
|
||||||
|
],
|
||||||
|
], $updatedAction->filters);
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
'https://discord.com/api/webhooks/123456/original-secret',
|
'https://discord.com/api/webhooks/123456/original-secret',
|
||||||
$updatedAction->delivery_url,
|
$updatedAction->delivery_url,
|
||||||
|
|||||||
@@ -71,6 +71,34 @@ class TicketCreatedNotificationDispatchTest extends TestCase
|
|||||||
'name' => 'Status changed webhook',
|
'name' => 'Status changed webhook',
|
||||||
'event_type' => NotificationEventType::TicketStatusChanged,
|
'event_type' => NotificationEventType::TicketStatusChanged,
|
||||||
]);
|
]);
|
||||||
|
$filterNotExistsAction = NotificationAction::factory()
|
||||||
|
->webhook()
|
||||||
|
->for($project)
|
||||||
|
->create([
|
||||||
|
'name' => 'Ticket create without origin status',
|
||||||
|
'event_type' => NotificationEventType::TicketCreate,
|
||||||
|
'filters' => [
|
||||||
|
[
|
||||||
|
'attribute' => 'status.from.id',
|
||||||
|
'operator' => 'not_exists',
|
||||||
|
'value' => null,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$filterExistsAction = NotificationAction::factory()
|
||||||
|
->webhook()
|
||||||
|
->for($project)
|
||||||
|
->create([
|
||||||
|
'name' => 'Ticket create with origin status',
|
||||||
|
'event_type' => NotificationEventType::TicketCreate,
|
||||||
|
'filters' => [
|
||||||
|
[
|
||||||
|
'attribute' => 'status.from.id',
|
||||||
|
'operator' => 'exists',
|
||||||
|
'value' => null,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
$foreignProjectAction = NotificationAction::factory()
|
$foreignProjectAction = NotificationAction::factory()
|
||||||
->for($otherProject)
|
->for($otherProject)
|
||||||
->create([
|
->create([
|
||||||
@@ -100,7 +128,7 @@ class TicketCreatedNotificationDispatchTest extends TestCase
|
|||||||
->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
|
->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
|
||||||
->assertSessionHas('status', 'CORE-1 was created.');
|
->assertSessionHas('status', 'CORE-1 was created.');
|
||||||
|
|
||||||
Queue::assertPushed(DeliverNotificationAction::class, 2);
|
Queue::assertPushed(DeliverNotificationAction::class, 3);
|
||||||
Queue::assertPushed(DeliverNotificationAction::class, function (DeliverNotificationAction $job) use ($webhookAction, $project, $ticket, $user, $backlogStatus): bool {
|
Queue::assertPushed(DeliverNotificationAction::class, function (DeliverNotificationAction $job) use ($webhookAction, $project, $ticket, $user, $backlogStatus): bool {
|
||||||
return $job->notificationActionId === $webhookAction->id
|
return $job->notificationActionId === $webhookAction->id
|
||||||
&& $job->event->payload()->projectId === $project->id
|
&& $job->event->payload()->projectId === $project->id
|
||||||
@@ -122,11 +150,15 @@ class TicketCreatedNotificationDispatchTest extends TestCase
|
|||||||
Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $discordAction->id
|
Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $discordAction->id
|
||||||
&& $job->event->eventType() === NotificationEventType::TicketCreate
|
&& $job->event->eventType() === NotificationEventType::TicketCreate
|
||||||
&& $job->notificationDeliveryLogId !== null);
|
&& $job->notificationDeliveryLogId !== null);
|
||||||
|
Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $filterNotExistsAction->id
|
||||||
|
&& $job->event->eventType() === NotificationEventType::TicketCreate
|
||||||
|
&& $job->notificationDeliveryLogId !== null);
|
||||||
Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $disabledAction->id);
|
Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $disabledAction->id);
|
||||||
Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $statusChangedAction->id);
|
Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $statusChangedAction->id);
|
||||||
|
Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $filterExistsAction->id);
|
||||||
Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $foreignProjectAction->id);
|
Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $foreignProjectAction->id);
|
||||||
|
|
||||||
$this->assertDatabaseCount('notification_delivery_logs', 2);
|
$this->assertDatabaseCount('notification_delivery_logs', 3);
|
||||||
$this->assertDatabaseHas('notification_delivery_logs', [
|
$this->assertDatabaseHas('notification_delivery_logs', [
|
||||||
'notification_action_id' => $webhookAction->id,
|
'notification_action_id' => $webhookAction->id,
|
||||||
'delivery_status' => NotificationDeliveryStatus::Pending->value,
|
'delivery_status' => NotificationDeliveryStatus::Pending->value,
|
||||||
@@ -141,6 +173,13 @@ class TicketCreatedNotificationDispatchTest extends TestCase
|
|||||||
'attempt_count' => 0,
|
'attempt_count' => 0,
|
||||||
'http_status' => null,
|
'http_status' => null,
|
||||||
]);
|
]);
|
||||||
|
$this->assertDatabaseHas('notification_delivery_logs', [
|
||||||
|
'notification_action_id' => $filterNotExistsAction->id,
|
||||||
|
'delivery_status' => NotificationDeliveryStatus::Pending->value,
|
||||||
|
'event_type' => NotificationEventType::TicketCreate->value,
|
||||||
|
'attempt_count' => 0,
|
||||||
|
'http_status' => null,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function backlogStatus(Project $project): TicketStatus
|
private function backlogStatus(Project $project): TicketStatus
|
||||||
|
|||||||
@@ -72,6 +72,71 @@ class TicketStatusChangedNotificationDispatchTest extends TestCase
|
|||||||
->create([
|
->create([
|
||||||
'name' => 'Disabled webhook',
|
'name' => 'Disabled webhook',
|
||||||
]);
|
]);
|
||||||
|
$filterEqualsAction = NotificationAction::factory()
|
||||||
|
->webhook()
|
||||||
|
->for($project)
|
||||||
|
->create([
|
||||||
|
'name' => 'Filtered equals webhook',
|
||||||
|
'filters' => [
|
||||||
|
[
|
||||||
|
'attribute' => 'status.to.id',
|
||||||
|
'operator' => 'equals',
|
||||||
|
'value' => (string) $doingStatus->id,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$filterNotEqualsAction = NotificationAction::factory()
|
||||||
|
->webhook()
|
||||||
|
->for($project)
|
||||||
|
->create([
|
||||||
|
'name' => 'Filtered not equals webhook',
|
||||||
|
'filters' => [
|
||||||
|
[
|
||||||
|
'attribute' => 'status.to.id',
|
||||||
|
'operator' => 'not_equals',
|
||||||
|
'value' => (string) $doingStatus->id,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$filterExistsAction = NotificationAction::factory()
|
||||||
|
->webhook()
|
||||||
|
->for($project)
|
||||||
|
->create([
|
||||||
|
'name' => 'Filtered exists webhook',
|
||||||
|
'filters' => [
|
||||||
|
[
|
||||||
|
'attribute' => 'actor.id',
|
||||||
|
'operator' => 'exists',
|
||||||
|
'value' => null,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$filterNotExistsAction = NotificationAction::factory()
|
||||||
|
->webhook()
|
||||||
|
->for($project)
|
||||||
|
->create([
|
||||||
|
'name' => 'Filtered not exists webhook',
|
||||||
|
'filters' => [
|
||||||
|
[
|
||||||
|
'attribute' => 'actor.id',
|
||||||
|
'operator' => 'not_exists',
|
||||||
|
'value' => null,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$filterInAction = NotificationAction::factory()
|
||||||
|
->webhook()
|
||||||
|
->for($project)
|
||||||
|
->create([
|
||||||
|
'name' => 'Filtered in webhook',
|
||||||
|
'filters' => [
|
||||||
|
[
|
||||||
|
'attribute' => 'ticket.key',
|
||||||
|
'operator' => 'in',
|
||||||
|
'value' => ['CORE-1', 'CORE-99'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
$foreignProjectAction = NotificationAction::factory()
|
$foreignProjectAction = NotificationAction::factory()
|
||||||
->for($otherProject)
|
->for($otherProject)
|
||||||
->create([
|
->create([
|
||||||
@@ -101,7 +166,7 @@ class TicketStatusChangedNotificationDispatchTest extends TestCase
|
|||||||
->assertRedirect(route('projects.backlog.index', $project))
|
->assertRedirect(route('projects.backlog.index', $project))
|
||||||
->assertSessionHas('status', 'CORE-1 moved to Doing.');
|
->assertSessionHas('status', 'CORE-1 moved to Doing.');
|
||||||
|
|
||||||
Queue::assertPushed(DeliverNotificationAction::class, 2);
|
Queue::assertPushed(DeliverNotificationAction::class, 5);
|
||||||
Queue::assertPushed(DeliverNotificationAction::class, function (DeliverNotificationAction $job) use ($webhookAction, $project, $ticket, $user, $backlogStatus, $doingStatus): bool {
|
Queue::assertPushed(DeliverNotificationAction::class, function (DeliverNotificationAction $job) use ($webhookAction, $project, $ticket, $user, $backlogStatus, $doingStatus): bool {
|
||||||
return $job->notificationActionId === $webhookAction->id
|
return $job->notificationActionId === $webhookAction->id
|
||||||
&& $job->event->payload()->projectId === $project->id
|
&& $job->event->payload()->projectId === $project->id
|
||||||
@@ -122,10 +187,18 @@ class TicketStatusChangedNotificationDispatchTest extends TestCase
|
|||||||
});
|
});
|
||||||
Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $discordAction->id
|
Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $discordAction->id
|
||||||
&& $job->notificationDeliveryLogId !== null);
|
&& $job->notificationDeliveryLogId !== null);
|
||||||
|
Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $filterEqualsAction->id
|
||||||
|
&& $job->notificationDeliveryLogId !== null);
|
||||||
|
Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $filterExistsAction->id
|
||||||
|
&& $job->notificationDeliveryLogId !== null);
|
||||||
|
Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $filterInAction->id
|
||||||
|
&& $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 === $disabledAction->id);
|
||||||
|
Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $filterNotEqualsAction->id);
|
||||||
|
Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $filterNotExistsAction->id);
|
||||||
Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $foreignProjectAction->id);
|
Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $foreignProjectAction->id);
|
||||||
|
|
||||||
$this->assertDatabaseCount('notification_delivery_logs', 2);
|
$this->assertDatabaseCount('notification_delivery_logs', 5);
|
||||||
$this->assertDatabaseHas('notification_delivery_logs', [
|
$this->assertDatabaseHas('notification_delivery_logs', [
|
||||||
'notification_action_id' => $webhookAction->id,
|
'notification_action_id' => $webhookAction->id,
|
||||||
'delivery_status' => NotificationDeliveryStatus::Pending->value,
|
'delivery_status' => NotificationDeliveryStatus::Pending->value,
|
||||||
@@ -138,6 +211,24 @@ class TicketStatusChangedNotificationDispatchTest extends TestCase
|
|||||||
'attempt_count' => 0,
|
'attempt_count' => 0,
|
||||||
'http_status' => null,
|
'http_status' => null,
|
||||||
]);
|
]);
|
||||||
|
$this->assertDatabaseHas('notification_delivery_logs', [
|
||||||
|
'notification_action_id' => $filterEqualsAction->id,
|
||||||
|
'delivery_status' => NotificationDeliveryStatus::Pending->value,
|
||||||
|
'attempt_count' => 0,
|
||||||
|
'http_status' => null,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('notification_delivery_logs', [
|
||||||
|
'notification_action_id' => $filterExistsAction->id,
|
||||||
|
'delivery_status' => NotificationDeliveryStatus::Pending->value,
|
||||||
|
'attempt_count' => 0,
|
||||||
|
'http_status' => null,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('notification_delivery_logs', [
|
||||||
|
'notification_action_id' => $filterInAction->id,
|
||||||
|
'delivery_status' => NotificationDeliveryStatus::Pending->value,
|
||||||
|
'attempt_count' => 0,
|
||||||
|
'http_status' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
$this->assertDatabaseHas('ticket_worklog_entries', [
|
$this->assertDatabaseHas('ticket_worklog_entries', [
|
||||||
'ticket_id' => $ticket->id,
|
'ticket_id' => $ticket->id,
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ class ProjectNotificationSettingsControllerTest extends TestCase
|
|||||||
->for($project)
|
->for($project)
|
||||||
->create([
|
->create([
|
||||||
'name' => 'Discord alerts',
|
'name' => 'Discord alerts',
|
||||||
|
'filters' => [
|
||||||
|
[
|
||||||
|
'attribute' => 'status.to.label',
|
||||||
|
'operator' => 'equals',
|
||||||
|
'value' => 'Doing',
|
||||||
|
],
|
||||||
|
],
|
||||||
'channel_configuration' => [
|
'channel_configuration' => [
|
||||||
'allowed_mentions' => ['users', 'roles'],
|
'allowed_mentions' => ['users', 'roles'],
|
||||||
],
|
],
|
||||||
@@ -49,10 +56,17 @@ class ProjectNotificationSettingsControllerTest extends TestCase
|
|||||||
->where('notificationActions.0.event_type', NotificationEventType::TicketStatusChanged->value)
|
->where('notificationActions.0.event_type', NotificationEventType::TicketStatusChanged->value)
|
||||||
->where('notificationActions.0.enabled', true)
|
->where('notificationActions.0.enabled', true)
|
||||||
->where('notificationActions.0.has_delivery_url', true)
|
->where('notificationActions.0.has_delivery_url', true)
|
||||||
|
->where('notificationActions.0.filters.0.attribute', 'status.to.label')
|
||||||
|
->where('notificationActions.0.filters.0.operator', 'equals')
|
||||||
|
->where('notificationActions.0.filters.0.value', 'Doing')
|
||||||
->where('notificationActions.0.updated_at', $notificationAction->updated_at?->toISOString())
|
->where('notificationActions.0.updated_at', $notificationAction->updated_at?->toISOString())
|
||||||
->where('channelOptions.0.value', NotificationChannel::Discord->value)
|
->where('channelOptions.0.value', NotificationChannel::Discord->value)
|
||||||
->where('eventTypeOptions.0.value', NotificationEventType::TicketStatusChanged->value)
|
->where('eventTypeOptions.0.value', NotificationEventType::TicketStatusChanged->value)
|
||||||
->where('eventTypeOptions.1.value', NotificationEventType::TicketCreate->value)
|
->where('eventTypeOptions.1.value', NotificationEventType::TicketCreate->value)
|
||||||
|
->where('discordAllowedMentionOptions.0.value', 'everyone')
|
||||||
|
->where('discordAllowedMentionOptions.2.value', 'users')
|
||||||
|
->where('filterAttributeOptions.0.value', 'project.id')
|
||||||
|
->where('filterOperatorOptions.0.value', 'equals')
|
||||||
->where('templateVariables.0.value', '$url')
|
->where('templateVariables.0.value', '$url')
|
||||||
->where('templateVariables.6.value', '$actor_name')
|
->where('templateVariables.6.value', '$actor_name')
|
||||||
->missing('notificationActions.0.delivery_url'),
|
->missing('notificationActions.0.delivery_url'),
|
||||||
@@ -94,6 +108,13 @@ class ProjectNotificationSettingsControllerTest extends TestCase
|
|||||||
],
|
],
|
||||||
'enabled' => '1',
|
'enabled' => '1',
|
||||||
'delivery_url' => 'https://discord.com/api/webhooks/123456/discord-alerts',
|
'delivery_url' => 'https://discord.com/api/webhooks/123456/discord-alerts',
|
||||||
|
'filters' => json_encode([
|
||||||
|
[
|
||||||
|
'attribute' => 'status.to.label',
|
||||||
|
'operator' => 'equals',
|
||||||
|
'value' => 'Doing',
|
||||||
|
],
|
||||||
|
], JSON_THROW_ON_ERROR),
|
||||||
'message_template' => '$ticket_key moved from $status_from to $status_to by $actor_name: $url',
|
'message_template' => '$ticket_key moved from $status_from to $status_to by $actor_name: $url',
|
||||||
])
|
])
|
||||||
->assertRedirect(route('projects.settings.notifications', $project))
|
->assertRedirect(route('projects.settings.notifications', $project))
|
||||||
@@ -113,6 +134,13 @@ class ProjectNotificationSettingsControllerTest extends TestCase
|
|||||||
'https://discord.com/api/webhooks/123456/discord-alerts',
|
'https://discord.com/api/webhooks/123456/discord-alerts',
|
||||||
$notificationAction->delivery_url,
|
$notificationAction->delivery_url,
|
||||||
);
|
);
|
||||||
|
$this->assertSame([
|
||||||
|
[
|
||||||
|
'attribute' => 'status.to.label',
|
||||||
|
'operator' => 'equals',
|
||||||
|
'value' => 'Doing',
|
||||||
|
],
|
||||||
|
], $notificationAction->filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -142,6 +170,13 @@ class ProjectNotificationSettingsControllerTest extends TestCase
|
|||||||
],
|
],
|
||||||
'enabled' => '1',
|
'enabled' => '1',
|
||||||
'delivery_url' => '',
|
'delivery_url' => '',
|
||||||
|
'filters' => json_encode([
|
||||||
|
[
|
||||||
|
'attribute' => 'ticket.key',
|
||||||
|
'operator' => 'in',
|
||||||
|
'value' => ['CORE-1', 'CORE-2'],
|
||||||
|
],
|
||||||
|
], JSON_THROW_ON_ERROR),
|
||||||
'message_template' => '$ticket_key moved to $status_to: $url',
|
'message_template' => '$ticket_key moved to $status_to: $url',
|
||||||
])
|
])
|
||||||
->assertRedirect(route('projects.settings.notifications', $project))
|
->assertRedirect(route('projects.settings.notifications', $project))
|
||||||
@@ -154,10 +189,24 @@ class ProjectNotificationSettingsControllerTest extends TestCase
|
|||||||
'https://discord.com/api/webhooks/123456/original-secret',
|
'https://discord.com/api/webhooks/123456/original-secret',
|
||||||
$notificationAction->delivery_url,
|
$notificationAction->delivery_url,
|
||||||
);
|
);
|
||||||
|
$this->assertSame([
|
||||||
|
[
|
||||||
|
'attribute' => 'ticket.key',
|
||||||
|
'operator' => 'in',
|
||||||
|
'value' => ['CORE-1', 'CORE-2'],
|
||||||
|
],
|
||||||
|
], $notificationAction->filters);
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
['allowed_mentions' => ['users', 'everyone']],
|
['allowed_mentions' => ['users', 'everyone']],
|
||||||
$notificationAction->channel_configuration,
|
$notificationAction->channel_configuration,
|
||||||
);
|
);
|
||||||
|
$this->assertSame([
|
||||||
|
[
|
||||||
|
'attribute' => 'ticket.key',
|
||||||
|
'operator' => 'in',
|
||||||
|
'value' => ['CORE-1', 'CORE-2'],
|
||||||
|
],
|
||||||
|
], $notificationAction->filters);
|
||||||
$this->assertSame('$ticket_key moved to $status_to: $url', $notificationAction->message_template);
|
$this->assertSame('$ticket_key moved to $status_to: $url', $notificationAction->message_template);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,6 +326,13 @@ class ProjectNotificationSettingsControllerTest extends TestCase
|
|||||||
'channel' => NotificationChannel::Discord->value,
|
'channel' => NotificationChannel::Discord->value,
|
||||||
'enabled' => 'definitely-not-a-boolean',
|
'enabled' => 'definitely-not-a-boolean',
|
||||||
'delivery_url' => 'not-a-url',
|
'delivery_url' => 'not-a-url',
|
||||||
|
'filters' => json_encode([
|
||||||
|
[
|
||||||
|
'attribute' => 'ticket.key',
|
||||||
|
'operator' => 'in',
|
||||||
|
'value' => [],
|
||||||
|
],
|
||||||
|
], JSON_THROW_ON_ERROR),
|
||||||
'channel_configuration' => [
|
'channel_configuration' => [
|
||||||
'allowed_mentions' => ['not-valid'],
|
'allowed_mentions' => ['not-valid'],
|
||||||
],
|
],
|
||||||
@@ -288,7 +344,10 @@ class ProjectNotificationSettingsControllerTest extends TestCase
|
|||||||
'event_type',
|
'event_type',
|
||||||
'enabled',
|
'enabled',
|
||||||
'delivery_url',
|
'delivery_url',
|
||||||
|
'filters.0.value',
|
||||||
'channel_configuration.allowed_mentions.0',
|
'channel_configuration.allowed_mentions.0',
|
||||||
|
'channel_configuration.allowed_mentions.0',
|
||||||
|
'filters.0.value',
|
||||||
'message_template',
|
'message_template',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user
warum hier auf null und nich ' ' wenn das default val ist, oder ist weil das enum null gibt wenn nicht da
Enum::tryFrom(string) gibt null oder Enum zurück