$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 $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 $context * @return array */ 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 $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 $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 $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 $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, ]; } }