Implements spec `099-alerts-v1-teams-email`. - Monitoring navigation: Alerts as a cluster under Monitoring; default landing is Alert deliveries. - Tenant panel: Alerts points to `/admin/alerts` and the cluster navigation is hidden in tenant panel. - Guard compliance: removes direct `Gate::` usage from Alert resources so `NoAdHocFilamentAuthPatternsTest` passes. Verification: - Full suite: `1348 passed, 7 skipped` (EXIT=0). Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #121
193 lines
5.8 KiB
PHP
193 lines
5.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Alerts;
|
|
|
|
use App\Models\AlertDelivery;
|
|
use App\Models\AlertRule;
|
|
use App\Models\Tenant;
|
|
use App\Models\Workspace;
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Support\Arr;
|
|
|
|
class AlertDispatchService
|
|
{
|
|
public function __construct(
|
|
private readonly AlertFingerprintService $fingerprintService,
|
|
private readonly AlertQuietHoursService $quietHoursService,
|
|
) {}
|
|
|
|
/**
|
|
* @param array<string, mixed> $event
|
|
*/
|
|
public function dispatchEvent(Workspace $workspace, array $event): int
|
|
{
|
|
$workspaceId = (int) $workspace->getKey();
|
|
$tenantId = (int) ($event['tenant_id'] ?? 0);
|
|
$eventType = trim((string) ($event['event_type'] ?? ''));
|
|
|
|
if ($workspaceId <= 0 || $tenantId <= 0 || $eventType === '') {
|
|
return 0;
|
|
}
|
|
|
|
$tenant = Tenant::query()
|
|
->whereKey($tenantId)
|
|
->where('workspace_id', $workspaceId)
|
|
->first();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
return 0;
|
|
}
|
|
|
|
$now = CarbonImmutable::now('UTC');
|
|
$eventSeverity = $this->normalizeSeverity((string) ($event['severity'] ?? ''));
|
|
|
|
$rules = AlertRule::query()
|
|
->with(['destinations' => fn ($query) => $query->where('is_enabled', true)])
|
|
->where('workspace_id', $workspaceId)
|
|
->where('is_enabled', true)
|
|
->where('event_type', $eventType)
|
|
->orderBy('id')
|
|
->get();
|
|
|
|
$createdDeliveries = 0;
|
|
|
|
foreach ($rules as $rule) {
|
|
if (! $rule->appliesToTenant($tenantId)) {
|
|
continue;
|
|
}
|
|
|
|
if (! $this->meetsMinimumSeverity($eventSeverity, (string) $rule->minimum_severity)) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($rule->destinations as $destination) {
|
|
$fingerprintHash = $this->fingerprintService->hash($rule, $destination, $tenantId, $event);
|
|
|
|
$isSuppressed = $this->shouldSuppress(
|
|
workspaceId: $workspaceId,
|
|
ruleId: (int) $rule->getKey(),
|
|
destinationId: (int) $destination->getKey(),
|
|
fingerprintHash: $fingerprintHash,
|
|
cooldownSeconds: (int) ($rule->cooldown_seconds ?? 0),
|
|
now: $now,
|
|
);
|
|
|
|
$sendAfter = null;
|
|
$status = AlertDelivery::STATUS_QUEUED;
|
|
|
|
if ($isSuppressed) {
|
|
$status = AlertDelivery::STATUS_SUPPRESSED;
|
|
} else {
|
|
$deferUntil = $this->quietHoursService->deferUntil($rule, $workspace, $now);
|
|
|
|
if ($deferUntil instanceof CarbonImmutable) {
|
|
$status = AlertDelivery::STATUS_DEFERRED;
|
|
$sendAfter = $deferUntil;
|
|
}
|
|
}
|
|
|
|
AlertDelivery::query()->create([
|
|
'workspace_id' => $workspaceId,
|
|
'tenant_id' => $tenantId,
|
|
'alert_rule_id' => (int) $rule->getKey(),
|
|
'alert_destination_id' => (int) $destination->getKey(),
|
|
'event_type' => $eventType,
|
|
'severity' => $eventSeverity,
|
|
'status' => $status,
|
|
'fingerprint_hash' => $fingerprintHash,
|
|
'send_after' => $sendAfter,
|
|
'attempt_count' => 0,
|
|
'payload' => $this->buildPayload($event),
|
|
]);
|
|
|
|
$createdDeliveries++;
|
|
}
|
|
}
|
|
|
|
return $createdDeliveries;
|
|
}
|
|
|
|
private function normalizeSeverity(string $severity): string
|
|
{
|
|
$severity = strtolower(trim($severity));
|
|
|
|
return in_array($severity, ['low', 'medium', 'high', 'critical'], true)
|
|
? $severity
|
|
: 'high';
|
|
}
|
|
|
|
private function meetsMinimumSeverity(string $eventSeverity, string $minimumSeverity): bool
|
|
{
|
|
$rank = [
|
|
'low' => 1,
|
|
'medium' => 2,
|
|
'high' => 3,
|
|
'critical' => 4,
|
|
];
|
|
|
|
$eventRank = $rank[$eventSeverity] ?? 0;
|
|
$minimumRank = $rank[strtolower(trim($minimumSeverity))] ?? 0;
|
|
|
|
return $eventRank >= $minimumRank;
|
|
}
|
|
|
|
private function shouldSuppress(
|
|
int $workspaceId,
|
|
int $ruleId,
|
|
int $destinationId,
|
|
string $fingerprintHash,
|
|
int $cooldownSeconds,
|
|
CarbonImmutable $now,
|
|
): bool {
|
|
if ($cooldownSeconds <= 0) {
|
|
return false;
|
|
}
|
|
|
|
$cutoff = $now->subSeconds($cooldownSeconds);
|
|
|
|
return AlertDelivery::query()
|
|
->where('workspace_id', $workspaceId)
|
|
->where('alert_rule_id', $ruleId)
|
|
->where('alert_destination_id', $destinationId)
|
|
->where('fingerprint_hash', $fingerprintHash)
|
|
->whereNotIn('status', [
|
|
AlertDelivery::STATUS_SUPPRESSED,
|
|
AlertDelivery::STATUS_CANCELED,
|
|
])
|
|
->where('created_at', '>=', $cutoff)
|
|
->exists();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $event
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function buildPayload(array $event): array
|
|
{
|
|
$title = trim((string) ($event['title'] ?? 'Alert'));
|
|
$body = trim((string) ($event['body'] ?? 'A matching alert event was detected.'));
|
|
|
|
if ($title === '') {
|
|
$title = 'Alert';
|
|
}
|
|
|
|
if ($body === '') {
|
|
$body = 'A matching alert event was detected.';
|
|
}
|
|
|
|
$metadata = Arr::get($event, 'metadata', []);
|
|
|
|
if (! is_array($metadata)) {
|
|
$metadata = [];
|
|
}
|
|
|
|
return [
|
|
'title' => $title,
|
|
'body' => $body,
|
|
'metadata' => $metadata,
|
|
];
|
|
}
|
|
}
|