TenantAtlas/app/Jobs/Alerts/DeliverAlertsJob.php
ahmido 3ed275cef3 feat(alerts): Monitoring cluster + v1 resources (spec 099) (#121)
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
2026-02-18 15:20:43 +00:00

217 lines
7.0 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Jobs\Alerts;
use App\Models\AlertDelivery;
use App\Models\OperationRun;
use App\Models\Workspace;
use App\Services\Alerts\AlertSender;
use App\Services\OperationRunService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Carbon\CarbonImmutable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
class DeliverAlertsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public int $workspaceId,
public ?int $operationRunId = null,
) {}
public function handle(AlertSender $alertSender, OperationRunService $operationRuns): void
{
$workspace = Workspace::query()->whereKey($this->workspaceId)->first();
if (! $workspace instanceof Workspace) {
return;
}
$operationRun = $this->resolveOperationRun($workspace, $operationRuns);
if (! $operationRun instanceof OperationRun) {
return;
}
if ($operationRun->status === OperationRunStatus::Completed->value) {
return;
}
$operationRuns->updateRun(
$operationRun,
status: OperationRunStatus::Running->value,
outcome: OperationRunOutcome::Pending->value,
);
$now = CarbonImmutable::now('UTC');
$batchSize = max(1, (int) config('tenantpilot.alerts.deliver_batch_size', 200));
$maxAttempts = max(1, (int) config('tenantpilot.alerts.delivery_max_attempts', 3));
$deliveries = AlertDelivery::query()
->where('workspace_id', (int) $workspace->getKey())
->whereIn('status', [
AlertDelivery::STATUS_QUEUED,
AlertDelivery::STATUS_DEFERRED,
])
->where(function ($query) use ($now): void {
$query->whereNull('send_after')
->orWhere('send_after', '<=', $now);
})
->orderBy('id')
->limit($batchSize)
->get();
$processed = 0;
$succeeded = 0;
$terminalFailures = 0;
$requeued = 0;
foreach ($deliveries as $delivery) {
if ($delivery->isTerminal()) {
continue;
}
$processed++;
try {
$alertSender->send($delivery);
$delivery->forceFill([
'attempt_count' => (int) $delivery->attempt_count + 1,
'status' => AlertDelivery::STATUS_SENT,
'send_after' => null,
'sent_at' => $now,
'last_error_code' => null,
'last_error_message' => null,
])->save();
$succeeded++;
} catch (Throwable $exception) {
$attemptCount = (int) $delivery->attempt_count + 1;
$errorCode = $this->sanitizeErrorCode($exception);
$errorMessage = $this->sanitizeErrorMessage($exception);
if ($attemptCount >= $maxAttempts) {
$delivery->forceFill([
'attempt_count' => $attemptCount,
'status' => AlertDelivery::STATUS_FAILED,
'send_after' => null,
'last_error_code' => $errorCode,
'last_error_message' => $errorMessage,
])->save();
$terminalFailures++;
continue;
}
$delivery->forceFill([
'attempt_count' => $attemptCount,
'status' => AlertDelivery::STATUS_QUEUED,
'send_after' => $now->addSeconds($this->backoffSeconds($attemptCount)),
'last_error_code' => $errorCode,
'last_error_message' => $errorMessage,
])->save();
$requeued++;
}
}
$outcome = OperationRunOutcome::Succeeded->value;
if ($terminalFailures > 0 && $succeeded === 0 && $requeued === 0) {
$outcome = OperationRunOutcome::Failed->value;
} elseif ($terminalFailures > 0 || $requeued > 0) {
$outcome = OperationRunOutcome::PartiallySucceeded->value;
}
$operationRuns->updateRun(
$operationRun,
status: OperationRunStatus::Completed->value,
outcome: $outcome,
summaryCounts: [
'total' => count($deliveries),
'processed' => $processed,
'succeeded' => $succeeded,
'failed' => $terminalFailures,
'skipped' => $requeued,
],
);
}
private function resolveOperationRun(Workspace $workspace, OperationRunService $operationRuns): ?OperationRun
{
if (is_int($this->operationRunId) && $this->operationRunId > 0) {
$operationRun = OperationRun::query()
->whereKey($this->operationRunId)
->where('workspace_id', (int) $workspace->getKey())
->where('type', 'alerts.deliver')
->first();
if ($operationRun instanceof OperationRun) {
return $operationRun;
}
}
$slotKey = CarbonImmutable::now('UTC')->format('YmdHi').'Z';
return $operationRuns->ensureWorkspaceRunWithIdentity(
workspace: $workspace,
type: 'alerts.deliver',
identityInputs: [
'slot_key' => $slotKey,
],
context: [
'trigger' => 'job',
'slot_key' => $slotKey,
],
initiator: null,
);
}
private function backoffSeconds(int $attemptCount): int
{
$baseSeconds = max(1, (int) config('tenantpilot.alerts.delivery_retry_base_seconds', 60));
$maxSeconds = max($baseSeconds, (int) config('tenantpilot.alerts.delivery_retry_max_seconds', 900));
$exponent = max(0, $attemptCount - 1);
$delaySeconds = $baseSeconds * (2 ** $exponent);
return (int) min($maxSeconds, $delaySeconds);
}
private function sanitizeErrorCode(Throwable $exception): string
{
$shortName = class_basename($exception);
$shortName = trim((string) $shortName);
if ($shortName === '') {
return 'delivery_exception';
}
return strtolower(preg_replace('/[^a-z0-9]+/i', '_', $shortName) ?? 'delivery_exception');
}
private function sanitizeErrorMessage(Throwable $exception): string
{
$message = trim($exception->getMessage());
if ($message === '') {
return 'Alert delivery failed.';
}
$message = preg_replace('/https?:\/\/\S+/i', '[redacted-url]', $message) ?? $message;
return mb_substr($message, 0, 500);
}
}