368 lines
12 KiB
PHP
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;
|
|
}
|
|
}
|