390 lines
13 KiB
PHP
390 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Findings;
|
|
|
|
use App\Models\AlertRule;
|
|
use App\Models\Finding;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Notifications\Findings\FindingEventNotification;
|
|
use App\Services\Alerts\AlertDispatchService;
|
|
use App\Services\Auth\CapabilityResolver;
|
|
use App\Support\Auth\Capabilities;
|
|
use Carbon\CarbonInterface;
|
|
use InvalidArgumentException;
|
|
|
|
final class FindingNotificationService
|
|
{
|
|
public function __construct(
|
|
private readonly AlertDispatchService $alertDispatchService,
|
|
private readonly CapabilityResolver $capabilityResolver,
|
|
) {}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
* @return array{
|
|
* event_type: string,
|
|
* fingerprint_key: string,
|
|
* direct_delivery_status: 'sent'|'suppressed'|'deduped'|'no_recipient',
|
|
* external_delivery_count: int
|
|
* }
|
|
*/
|
|
public function dispatch(Finding $finding, string $eventType, array $context = []): array
|
|
{
|
|
$finding = $this->reloadFinding($finding);
|
|
$tenant = $finding->tenant;
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
return $this->dispatchResult(
|
|
eventType: $eventType,
|
|
fingerprintKey: '',
|
|
directDeliveryStatus: 'no_recipient',
|
|
externalDeliveryCount: 0,
|
|
);
|
|
}
|
|
|
|
if ($this->shouldSuppressEvent($finding, $eventType, $context)) {
|
|
return $this->dispatchResult(
|
|
eventType: $eventType,
|
|
fingerprintKey: $this->fingerprintFor($finding, $eventType, $context),
|
|
directDeliveryStatus: 'suppressed',
|
|
externalDeliveryCount: 0,
|
|
);
|
|
}
|
|
|
|
$resolution = $this->resolveRecipient($finding, $eventType, $context);
|
|
$event = $this->buildEventEnvelope($finding, $tenant, $eventType, $resolution['reason'], $context);
|
|
|
|
$directDeliveryStatus = $this->dispatchDirectNotification($finding, $tenant, $event, $resolution['user_id']);
|
|
$externalDeliveryCount = $this->dispatchExternalCopies($finding, $event);
|
|
|
|
return $this->dispatchResult(
|
|
eventType: $eventType,
|
|
fingerprintKey: (string) $event['fingerprint_key'],
|
|
directDeliveryStatus: $directDeliveryStatus,
|
|
externalDeliveryCount: $externalDeliveryCount,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
* @return array{user_id: ?int, reason: ?string}
|
|
*/
|
|
private function resolveRecipient(Finding $finding, string $eventType, array $context): array
|
|
{
|
|
return match ($eventType) {
|
|
AlertRule::EVENT_FINDINGS_ASSIGNED => [
|
|
'user_id' => $this->normalizeId($context['assignee_user_id'] ?? $finding->assignee_user_id),
|
|
'reason' => 'new_assignee',
|
|
],
|
|
AlertRule::EVENT_FINDINGS_REOPENED => $this->preferredRecipient(
|
|
preferredUserId: $this->normalizeId($finding->assignee_user_id),
|
|
preferredReason: 'current_assignee',
|
|
fallbackUserId: $this->normalizeId($finding->owner_user_id),
|
|
fallbackReason: 'current_owner',
|
|
),
|
|
AlertRule::EVENT_FINDINGS_DUE_SOON => $this->preferredRecipient(
|
|
preferredUserId: $this->normalizeId($finding->assignee_user_id),
|
|
preferredReason: 'current_assignee',
|
|
fallbackUserId: $this->normalizeId($finding->owner_user_id),
|
|
fallbackReason: 'current_owner',
|
|
),
|
|
AlertRule::EVENT_FINDINGS_OVERDUE => $this->preferredRecipient(
|
|
preferredUserId: $this->normalizeId($finding->owner_user_id),
|
|
preferredReason: 'current_owner',
|
|
fallbackUserId: $this->normalizeId($finding->assignee_user_id),
|
|
fallbackReason: 'current_assignee',
|
|
),
|
|
default => throw new InvalidArgumentException(sprintf('Unsupported finding notification event [%s].', $eventType)),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function buildEventEnvelope(
|
|
Finding $finding,
|
|
Tenant $tenant,
|
|
string $eventType,
|
|
?string $recipientReason,
|
|
array $context,
|
|
): array {
|
|
$severity = strtolower(trim((string) $finding->severity));
|
|
$summary = $finding->resolvedSubjectDisplayName() ?? 'Finding #'.(int) $finding->getKey();
|
|
$title = $this->eventLabel($eventType);
|
|
$fingerprintKey = $this->fingerprintFor($finding, $eventType, $context);
|
|
$dueCycleKey = $this->dueCycleKey($finding, $eventType);
|
|
|
|
return [
|
|
'event_type' => $eventType,
|
|
'workspace_id' => (int) $finding->workspace_id,
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'finding_id' => (int) $finding->getKey(),
|
|
'severity' => $severity,
|
|
'title' => $title,
|
|
'body' => $this->eventBody($eventType, $tenant, $summary, ucfirst($severity)),
|
|
'fingerprint_key' => $fingerprintKey,
|
|
'due_cycle_key' => $dueCycleKey,
|
|
'metadata' => [
|
|
'tenant_name' => $tenant->getFilamentName(),
|
|
'summary' => $summary,
|
|
'recipient_reason' => $recipientReason,
|
|
'owner_user_id' => $this->normalizeId($finding->owner_user_id),
|
|
'assignee_user_id' => $this->normalizeId($finding->assignee_user_id),
|
|
'due_at' => $this->optionalIso8601($finding->due_at),
|
|
'reopened_at' => $this->optionalIso8601($finding->reopened_at),
|
|
'severity_label' => ucfirst($severity),
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $event
|
|
*/
|
|
private function dispatchDirectNotification(Finding $finding, Tenant $tenant, array $event, ?int $userId): string
|
|
{
|
|
if (! is_int($userId) || $userId <= 0) {
|
|
return 'no_recipient';
|
|
}
|
|
|
|
$user = User::query()->find($userId);
|
|
|
|
if (! $user instanceof User) {
|
|
return 'no_recipient';
|
|
}
|
|
|
|
if (! $user->canAccessTenant($tenant)) {
|
|
return 'suppressed';
|
|
}
|
|
|
|
if (! $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW)) {
|
|
return 'suppressed';
|
|
}
|
|
|
|
if ($this->alreadySentDirectNotification($user, (string) $event['fingerprint_key'])) {
|
|
return 'deduped';
|
|
}
|
|
|
|
$user->notify(new FindingEventNotification($finding, $tenant, $event));
|
|
|
|
return 'sent';
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $event
|
|
*/
|
|
private function dispatchExternalCopies(Finding $finding, array $event): int
|
|
{
|
|
$workspace = Workspace::query()->whereKey((int) $finding->workspace_id)->first();
|
|
|
|
if (! $workspace instanceof Workspace) {
|
|
return 0;
|
|
}
|
|
|
|
return $this->alertDispatchService->dispatchEvent($workspace, $event);
|
|
}
|
|
|
|
private function alreadySentDirectNotification(User $user, string $fingerprintKey): bool
|
|
{
|
|
if ($fingerprintKey === '') {
|
|
return false;
|
|
}
|
|
|
|
return $user->notifications()
|
|
->where('type', FindingEventNotification::class)
|
|
->where('data->finding_event->fingerprint_key', $fingerprintKey)
|
|
->exists();
|
|
}
|
|
|
|
private function reloadFinding(Finding $finding): Finding
|
|
{
|
|
$fresh = Finding::query()
|
|
->with('tenant')
|
|
->withSubjectDisplayName()
|
|
->find($finding->getKey());
|
|
|
|
if ($fresh instanceof Finding) {
|
|
return $fresh;
|
|
}
|
|
|
|
$finding->loadMissing('tenant');
|
|
|
|
return $finding;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
*/
|
|
private function shouldSuppressEvent(Finding $finding, string $eventType, array $context): bool
|
|
{
|
|
return match ($eventType) {
|
|
AlertRule::EVENT_FINDINGS_ASSIGNED => ! $finding->hasOpenStatus()
|
|
|| $this->normalizeId($context['assignee_user_id'] ?? $finding->assignee_user_id) === null,
|
|
AlertRule::EVENT_FINDINGS_DUE_SOON, AlertRule::EVENT_FINDINGS_OVERDUE => ! $finding->hasOpenStatus()
|
|
|| ! $finding->due_at instanceof CarbonInterface,
|
|
default => false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
*/
|
|
private function fingerprintFor(Finding $finding, string $eventType, array $context): string
|
|
{
|
|
$findingId = (int) $finding->getKey();
|
|
|
|
return match ($eventType) {
|
|
AlertRule::EVENT_FINDINGS_ASSIGNED => sprintf(
|
|
'finding:%d:%s:assignee:%d:updated:%s',
|
|
$findingId,
|
|
$eventType,
|
|
$this->normalizeId($context['assignee_user_id'] ?? $finding->assignee_user_id) ?? 0,
|
|
$this->optionalIso8601($finding->updated_at) ?? 'none',
|
|
),
|
|
AlertRule::EVENT_FINDINGS_REOPENED => sprintf(
|
|
'finding:%d:%s:reopened:%s',
|
|
$findingId,
|
|
$eventType,
|
|
$this->optionalIso8601($finding->reopened_at) ?? 'none',
|
|
),
|
|
AlertRule::EVENT_FINDINGS_DUE_SOON,
|
|
AlertRule::EVENT_FINDINGS_OVERDUE => sprintf(
|
|
'finding:%d:%s:due:%s',
|
|
$findingId,
|
|
$eventType,
|
|
$this->dueCycleKey($finding, $eventType) ?? 'none',
|
|
),
|
|
default => throw new InvalidArgumentException(sprintf('Unsupported finding notification event [%s].', $eventType)),
|
|
};
|
|
}
|
|
|
|
private function dueCycleKey(Finding $finding, string $eventType): ?string
|
|
{
|
|
if (! in_array($eventType, [
|
|
AlertRule::EVENT_FINDINGS_DUE_SOON,
|
|
AlertRule::EVENT_FINDINGS_OVERDUE,
|
|
], true)) {
|
|
return null;
|
|
}
|
|
|
|
return $this->optionalIso8601($finding->due_at);
|
|
}
|
|
|
|
private function eventLabel(string $eventType): string
|
|
{
|
|
return match ($eventType) {
|
|
AlertRule::EVENT_FINDINGS_ASSIGNED => 'Finding assigned',
|
|
AlertRule::EVENT_FINDINGS_REOPENED => 'Finding reopened',
|
|
AlertRule::EVENT_FINDINGS_DUE_SOON => 'Finding due soon',
|
|
AlertRule::EVENT_FINDINGS_OVERDUE => 'Finding overdue',
|
|
default => throw new InvalidArgumentException(sprintf('Unsupported finding notification event [%s].', $eventType)),
|
|
};
|
|
}
|
|
|
|
private function eventBody(string $eventType, Tenant $tenant, string $summary, string $severityLabel): string
|
|
{
|
|
return match ($eventType) {
|
|
AlertRule::EVENT_FINDINGS_ASSIGNED => sprintf(
|
|
'%s in %s was assigned. %s severity.',
|
|
$summary,
|
|
$tenant->getFilamentName(),
|
|
$severityLabel,
|
|
),
|
|
AlertRule::EVENT_FINDINGS_REOPENED => sprintf(
|
|
'%s in %s reopened and needs follow-up. %s severity.',
|
|
$summary,
|
|
$tenant->getFilamentName(),
|
|
$severityLabel,
|
|
),
|
|
AlertRule::EVENT_FINDINGS_DUE_SOON => sprintf(
|
|
'%s in %s is due within 24 hours. %s severity.',
|
|
$summary,
|
|
$tenant->getFilamentName(),
|
|
$severityLabel,
|
|
),
|
|
AlertRule::EVENT_FINDINGS_OVERDUE => sprintf(
|
|
'%s in %s is overdue. %s severity.',
|
|
$summary,
|
|
$tenant->getFilamentName(),
|
|
$severityLabel,
|
|
),
|
|
default => throw new InvalidArgumentException(sprintf('Unsupported finding notification event [%s].', $eventType)),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return array{user_id: ?int, reason: ?string}
|
|
*/
|
|
private function preferredRecipient(
|
|
?int $preferredUserId,
|
|
string $preferredReason,
|
|
?int $fallbackUserId,
|
|
string $fallbackReason,
|
|
): array {
|
|
if (is_int($preferredUserId) && $preferredUserId > 0) {
|
|
return [
|
|
'user_id' => $preferredUserId,
|
|
'reason' => $preferredReason,
|
|
];
|
|
}
|
|
|
|
if (is_int($fallbackUserId) && $fallbackUserId > 0) {
|
|
return [
|
|
'user_id' => $fallbackUserId,
|
|
'reason' => $fallbackReason,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'user_id' => null,
|
|
'reason' => null,
|
|
];
|
|
}
|
|
|
|
private function normalizeId(mixed $value): ?int
|
|
{
|
|
if (! is_numeric($value)) {
|
|
return null;
|
|
}
|
|
|
|
$normalized = (int) $value;
|
|
|
|
return $normalized > 0 ? $normalized : null;
|
|
}
|
|
|
|
private function optionalIso8601(mixed $value): ?string
|
|
{
|
|
if (! $value instanceof CarbonInterface) {
|
|
return null;
|
|
}
|
|
|
|
return $value->toIso8601String();
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* event_type: string,
|
|
* fingerprint_key: string,
|
|
* direct_delivery_status: 'sent'|'suppressed'|'deduped'|'no_recipient',
|
|
* external_delivery_count: int
|
|
* }
|
|
*/
|
|
private function dispatchResult(
|
|
string $eventType,
|
|
string $fingerprintKey,
|
|
string $directDeliveryStatus,
|
|
int $externalDeliveryCount,
|
|
): array {
|
|
return [
|
|
'event_type' => $eventType,
|
|
'fingerprint_key' => $fingerprintKey,
|
|
'direct_delivery_status' => $directDeliveryStatus,
|
|
'external_delivery_count' => $externalDeliveryCount,
|
|
];
|
|
}
|
|
}
|