agent/php/Mod/Api/Models/WebhookDelivery.php
Snider 5385385314 feat(agent/api): RFC foundation — API keys, webhooks, rate limiting, docs split
Foundation slice for Mantis #844 php/Mod/Api RFC implementation:

* New php/Mod/Api/ package: Boot, Controllers, Documentation, Jobs,
  Middleware, Models, RateLimit, Routes, Services
* Models: ApiKey, WebhookEndpoint, WebhookDelivery
* WebhookService::dispatch() with DB::transaction + afterCommit
* DeliverWebhookJob with retry/backoff
* WebhookSignature with timing-safe verification + 5-minute tolerance +
  dual-secret rotation support
* Sliding-window rate limiter in RateLimit/RateLimitService.php
* AuthenticateApiKey middleware: hk_ prefix + Sanctum fallback
* DocsController / DocumentationController split
* 3 root migrations: api_keys, webhook_endpoints, webhook_deliveries
* Foundation tests under php/tests/Feature/Mod/Api/
* FOLLOWUP.md tracks remaining RFC scope

php -l clean across 21 PHP files. Pest unrunnable in sandbox (no vendor/).

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=844
2026-04-25 21:01:54 +01:00

169 lines
4.7 KiB
PHP

<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mod\Agentic\Mod\Api\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class WebhookDelivery extends Model
{
public const STATUS_PENDING = 'pending';
public const STATUS_QUEUED = 'queued';
public const STATUS_SUCCESS = 'success';
public const STATUS_FAILED = 'failed';
public const STATUS_RETRYING = 'retrying';
public const STATUS_CANCELLED = 'cancelled';
public const MAX_ATTEMPTS = 5;
/**
* Retry delays keyed by the failed attempt number.
*/
public const RETRY_DELAYS = [
1 => 300,
2 => 900,
3 => 3600,
4 => 14400,
5 => 86400,
];
protected $fillable = [
'webhook_endpoint_id',
'event_id',
'event_type',
'payload',
'response_code',
'response_body',
'attempt',
'status',
'delivered_at',
'next_retry_at',
];
protected $casts = [
'payload' => 'array',
'delivered_at' => 'datetime',
'next_retry_at' => 'datetime',
];
public static function createForEvent(
WebhookEndpoint $endpoint,
string $eventType,
array $data,
?int $workspaceId = null
): static {
return static::query()->create([
'webhook_endpoint_id' => $endpoint->getKey(),
'event_id' => 'evt_'.Str::random(24),
'event_type' => $eventType,
'payload' => [
'id' => 'evt_'.Str::random(24),
'type' => $eventType,
'created_at' => now()->toIso8601String(),
'workspace_id' => $workspaceId,
'data' => $data,
],
'status' => self::STATUS_PENDING,
'attempt' => 1,
]);
}
public function markSuccess(int $responseCode, ?string $responseBody = null): void
{
$this->forceFill([
'status' => self::STATUS_SUCCESS,
'response_code' => $responseCode,
'response_body' => $responseBody !== null ? Str::limit($responseBody, 10000) : null,
'delivered_at' => now(),
'next_retry_at' => null,
])->save();
$this->endpoint?->recordSuccess();
}
public function markFailed(int $responseCode, ?string $responseBody = null): void
{
$this->endpoint?->recordFailure();
if ($this->attempt >= self::MAX_ATTEMPTS) {
$this->forceFill([
'status' => self::STATUS_FAILED,
'response_code' => $responseCode,
'response_body' => $responseBody !== null ? Str::limit($responseBody, 10000) : null,
'next_retry_at' => null,
])->save();
return;
}
$delay = self::RETRY_DELAYS[$this->attempt] ?? end(self::RETRY_DELAYS);
$this->forceFill([
'status' => self::STATUS_RETRYING,
'response_code' => $responseCode,
'response_body' => $responseBody !== null ? Str::limit($responseBody, 10000) : null,
'attempt' => $this->attempt + 1,
'next_retry_at' => now()->addSeconds((int) $delay),
])->save();
}
public function canRetry(): bool
{
return $this->attempt < self::MAX_ATTEMPTS
&& $this->status !== self::STATUS_SUCCESS;
}
/**
* @return array{headers: array<string, string>, body: string}
*/
public function getDeliveryPayload(?int $timestamp = null): array
{
$timestamp ??= time();
$body = json_encode($this->payload, JSON_THROW_ON_ERROR);
return [
'headers' => [
'Content-Type' => 'application/json',
'X-Webhook-Id' => $this->event_id,
'X-Webhook-Event' => $this->event_type,
'X-Webhook-Timestamp' => (string) $timestamp,
'X-Webhook-Signature' => $this->endpoint->generateSignature($body, $timestamp),
],
'body' => $body,
];
}
/**
* @return array<int, int>
*/
public static function retrySchedule(): array
{
return array_values(self::RETRY_DELAYS);
}
public function endpoint(): BelongsTo
{
return $this->belongsTo(WebhookEndpoint::class, 'webhook_endpoint_id');
}
public function scopeNeedsDelivery($query)
{
return $query->where(function ($builder) {
$builder->where('status', self::STATUS_PENDING)
->orWhere(function ($retrying) {
$retrying->where('status', self::STATUS_RETRYING)
->where('next_retry_at', '<=', now());
});
});
}
}