Add notification filters

This commit is contained in:
2026-04-24 20:02:22 +00:00
parent 5f74ddd964
commit c54df44ebe
24 changed files with 1288 additions and 7 deletions
@@ -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(),
@@ -0,0 +1,208 @@
<?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, 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<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(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,
],
],
];
}
}
@@ -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,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)) {
@@ -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')),
]);
@@ -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) !== '') {