Files
KanbanGit/app/Actions/Notifications/DeliverNotificationAction.php
T

368 lines
12 KiB
PHP

<?php
namespace App\Actions\Notifications;
use App\Enums\NotificationChannel;
use App\Enums\NotificationDeliveryStatus;
use App\Models\NotificationAction;
use App\Models\NotificationDeliveryLog;
use Carbon\CarbonImmutable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Throwable;
class DeliverNotificationAction implements ShouldQueue
{
use Queueable;
private const int DISCORD_CONTENT_LIMIT = 2000;
public int $tries = 3;
public int $timeout = 15;
public function __construct(
public int $notificationActionId,
public TicketStatusChangedPayload $payload,
public ?int $notificationDeliveryLogId,
) {}
/**
* @return list<int>
*/
public function backoff(): array
{
return [15, 60, 300];
}
public function handle(): void
{
$notificationAction = NotificationAction::query()->find($this->notificationActionId);
if ($notificationAction === null) {
return;
}
if (! in_array($notificationAction->channel, [NotificationChannel::Discord, NotificationChannel::Webhook], true)) {
return;
}
$deliveryLog = $this->resolveDeliveryLog($notificationAction);
$attemptCount = $this->attempts();
$attemptedAt = CarbonImmutable::now();
$discordMessage = null;
if ($notificationAction->channel === NotificationChannel::Discord) {
$discordMessage = app(RenderDiscordMessageTemplate::class)->handle(
$notificationAction->message_template,
$this->payload,
);
$validationError = $this->discordMessageValidationError($discordMessage);
if ($validationError !== null) {
$this->markFailedWithoutResponse($deliveryLog, $attemptCount, $attemptedAt, $validationError);
$this->fail(new \RuntimeException($validationError));
return;
}
}
try {
$response = match ($notificationAction->channel) {
NotificationChannel::Discord => $this->sendDiscordRequest($notificationAction, $discordMessage ?? ''),
NotificationChannel::Webhook => $this->sendWebhookRequest($notificationAction),
};
} catch (ConnectionException $exception) {
$this->handleConnectionFailure($deliveryLog, $notificationAction->channel, $attemptCount, $attemptedAt, $exception);
return;
}
if ($response->successful()) {
$this->markSucceeded($deliveryLog, $response, $attemptCount, $attemptedAt);
return;
}
if ($this->shouldRetry($response) && $this->hasAttemptsRemaining($attemptCount)) {
$this->markRetryable($deliveryLog, $notificationAction->channel, $response, $attemptCount, $attemptedAt);
$this->release($this->backoffDelay($attemptCount));
return;
}
$this->markFailed($deliveryLog, $notificationAction->channel, $response, $attemptCount, $attemptedAt);
$this->fail(new \RuntimeException($this->responseFailureDescription($notificationAction->channel, $response)));
}
public function failed(?Throwable $exception): void
{
$deliveryLog = $this->loadDeliveryLog();
if ($deliveryLog === null || $deliveryLog->delivery_status === NotificationDeliveryStatus::Succeeded) {
return;
}
$deliveryLog->update([
'attempt_count' => max($deliveryLog->attempt_count, $this->attempts()),
'delivery_status' => NotificationDeliveryStatus::Failed,
'delivered_at' => null,
'error_excerpt' => $deliveryLog->error_excerpt ?? $this->excerpt($exception?->getMessage()),
'last_attempted_at' => $deliveryLog->last_attempted_at ?? CarbonImmutable::now(),
]);
}
private function handleConnectionFailure(
NotificationDeliveryLog $deliveryLog,
NotificationChannel $channel,
int $attemptCount,
CarbonImmutable $attemptedAt,
ConnectionException $exception,
): void {
$errorExcerpt = $this->excerpt($exception->getMessage()) ?? $this->requestFailureDescription($channel);
if ($this->hasAttemptsRemaining($attemptCount)) {
$deliveryLog->update([
'attempt_count' => $attemptCount,
'delivery_status' => NotificationDeliveryStatus::Retryable,
'delivered_at' => null,
'error_excerpt' => $errorExcerpt,
'http_status' => null,
'last_attempted_at' => $attemptedAt,
'response_excerpt' => null,
]);
$this->release($this->backoffDelay($attemptCount));
return;
}
$deliveryLog->update([
'attempt_count' => $attemptCount,
'delivery_status' => NotificationDeliveryStatus::Failed,
'delivered_at' => null,
'error_excerpt' => $errorExcerpt,
'http_status' => null,
'last_attempted_at' => $attemptedAt,
'response_excerpt' => null,
]);
$this->fail($exception);
}
private function markSucceeded(
NotificationDeliveryLog $deliveryLog,
Response $response,
int $attemptCount,
CarbonImmutable $attemptedAt,
): void {
$deliveryLog->update([
'attempt_count' => $attemptCount,
'delivery_status' => NotificationDeliveryStatus::Succeeded,
'delivered_at' => $attemptedAt,
'error_excerpt' => null,
'http_status' => $response->status(),
'last_attempted_at' => $attemptedAt,
'response_excerpt' => $this->responseExcerpt($response),
]);
}
private function markRetryable(
NotificationDeliveryLog $deliveryLog,
NotificationChannel $channel,
Response $response,
int $attemptCount,
CarbonImmutable $attemptedAt,
): void {
$deliveryLog->update([
'attempt_count' => $attemptCount,
'delivery_status' => NotificationDeliveryStatus::Retryable,
'delivered_at' => null,
'error_excerpt' => $this->responseFailureDescription($channel, $response),
'http_status' => $response->status(),
'last_attempted_at' => $attemptedAt,
'response_excerpt' => $this->responseExcerpt($response),
]);
}
private function markFailed(
NotificationDeliveryLog $deliveryLog,
NotificationChannel $channel,
Response $response,
int $attemptCount,
CarbonImmutable $attemptedAt,
): void {
$deliveryLog->update([
'attempt_count' => $attemptCount,
'delivery_status' => NotificationDeliveryStatus::Failed,
'delivered_at' => null,
'error_excerpt' => $this->responseFailureDescription($channel, $response),
'http_status' => $response->status(),
'last_attempted_at' => $attemptedAt,
'response_excerpt' => $this->responseExcerpt($response),
]);
}
private function markFailedWithoutResponse(
NotificationDeliveryLog $deliveryLog,
int $attemptCount,
CarbonImmutable $attemptedAt,
string $errorExcerpt,
): void {
$deliveryLog->update([
'attempt_count' => $attemptCount,
'delivery_status' => NotificationDeliveryStatus::Failed,
'delivered_at' => null,
'error_excerpt' => $errorExcerpt,
'http_status' => null,
'last_attempted_at' => $attemptedAt,
'response_excerpt' => null,
]);
}
private function resolveDeliveryLog(NotificationAction $notificationAction): NotificationDeliveryLog
{
$deliveryLog = $this->loadDeliveryLog();
if ($deliveryLog !== null) {
return $deliveryLog;
}
$deliveryLog = NotificationDeliveryLog::query()->create([
'attempt_count' => 0,
'channel' => $notificationAction->channel,
'delivery_status' => NotificationDeliveryStatus::Pending,
'event_type' => $this->payload->eventType,
'notification_action_id' => $notificationAction->id,
'payload_metadata' => $this->payloadMetadata(),
]);
$this->notificationDeliveryLogId = $deliveryLog->id;
return $deliveryLog;
}
private function loadDeliveryLog(): ?NotificationDeliveryLog
{
if ($this->notificationDeliveryLogId === null) {
return null;
}
return NotificationDeliveryLog::query()
->whereKey($this->notificationDeliveryLogId)
->where('notification_action_id', $this->notificationActionId)
->first();
}
private function shouldRetry(Response $response): bool
{
return $response->serverError() || in_array($response->status(), [408, 429], true);
}
private function sendDiscordRequest(NotificationAction $notificationAction, string $message): Response
{
return Http::acceptJson()
->asJson()
->connectTimeout(5)
->timeout(10)
->post($notificationAction->delivery_url, [
'content' => $message,
]);
}
private function sendWebhookRequest(NotificationAction $notificationAction): Response
{
return Http::acceptJson()
->asJson()
->connectTimeout(5)
->timeout(10)
->post($notificationAction->delivery_url, $this->payload->toArray());
}
private function hasAttemptsRemaining(int $attemptCount): bool
{
return $attemptCount < $this->tries;
}
private function backoffDelay(int $attemptCount): int
{
$backoff = $this->backoff();
$index = min($attemptCount - 1, count($backoff) - 1);
return $backoff[$index];
}
private function responseExcerpt(Response $response): ?string
{
return $this->excerpt($response->body()) ?? $this->excerpt($response->reason());
}
private function responseFailureDescription(NotificationChannel $channel, Response $response): string
{
$reason = $this->excerpt($response->reason());
$subject = $this->deliverySubject($channel);
if ($reason === null) {
return "{$subject} returned HTTP {$response->status()}.";
}
return "{$subject} returned HTTP {$response->status()} {$reason}.";
}
private function requestFailureDescription(NotificationChannel $channel): string
{
return match ($channel) {
NotificationChannel::Discord => 'Discord webhook request failed.',
NotificationChannel::Webhook => 'Webhook request failed.',
};
}
private function deliverySubject(NotificationChannel $channel): string
{
return match ($channel) {
NotificationChannel::Discord => 'Discord webhook',
NotificationChannel::Webhook => 'Webhook',
};
}
private function discordMessageValidationError(string $message): ?string
{
if (trim($message) === '') {
return 'Discord message content cannot be empty.';
}
if (Str::length($message) > self::DISCORD_CONTENT_LIMIT) {
return 'Discord message content exceeds the 2000 character limit.';
}
return null;
}
/**
* @return array{project_key: string, ticket_key: string}
*/
private function payloadMetadata(): array
{
return [
'project_key' => $this->payload->projectKey,
'ticket_key' => $this->payload->ticketKey,
];
}
private function excerpt(?string $value): ?string
{
if ($value === null) {
return null;
}
$excerpt = (string) Str::of($value)->trim()->squish()->limit(500);
return $excerpt === '' ? null : $excerpt;
}
}