feat(webhook): add WebhookCall model, migration, event, verifier interface

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-03-12 14:21:20 +00:00
parent a0a0727c88
commit 39ede84d0e
5 changed files with 226 additions and 0 deletions

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('webhook_calls', function (Blueprint $table) {
$table->ulid('id')->primary();
$table->string('source', 64)->index();
$table->string('event_type', 128)->nullable();
$table->json('headers');
$table->json('payload');
$table->boolean('signature_valid')->nullable();
$table->timestamp('processed_at')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->index(['source', 'processed_at', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('webhook_calls');
}
};

View file

@ -0,0 +1,58 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Webhook;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Model;
class WebhookCall extends Model
{
use HasUlids;
public $timestamps = false;
protected $fillable = [
'source',
'event_type',
'headers',
'payload',
'signature_valid',
'processed_at',
];
protected function casts(): array
{
return [
'headers' => 'array',
'payload' => 'array',
'signature_valid' => 'boolean',
'processed_at' => 'datetime',
'created_at' => 'datetime',
];
}
public function scopeUnprocessed(Builder $query): Builder
{
return $query->whereNull('processed_at');
}
public function scopeForSource(Builder $query, string $source): Builder
{
return $query->where('source', $source);
}
public function markProcessed(): void
{
$this->update(['processed_at' => now()]);
}
}

View file

@ -0,0 +1,20 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Webhook;
class WebhookReceived
{
public function __construct(
public readonly string $source,
public readonly string $callId,
) {}
}

View file

@ -0,0 +1,24 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Webhook;
use Illuminate\Http\Request;
interface WebhookVerifier
{
/**
* Verify the webhook signature.
*
* Returns true if valid, false if invalid.
*/
public function verify(Request $request, string $secret): bool;
}

View file

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace Core\Tests\Feature;
use Core\Tests\TestCase;
use Core\Webhook\WebhookCall;
use Illuminate\Foundation\Testing\RefreshDatabase;
class WebhookCallTest extends TestCase
{
use RefreshDatabase;
protected function defineDatabaseMigrations(): void
{
$this->loadMigrationsFrom(__DIR__.'/../../database/migrations');
}
public function test_create_webhook_call(): void
{
$call = WebhookCall::create([
'source' => 'altum-biolinks',
'event_type' => 'link.created',
'headers' => ['webhook-id' => 'abc123'],
'payload' => ['type' => 'link.created', 'data' => ['id' => 1]],
]);
$this->assertNotNull($call->id);
$this->assertSame('altum-biolinks', $call->source);
$this->assertSame('link.created', $call->event_type);
$this->assertIsArray($call->headers);
$this->assertIsArray($call->payload);
$this->assertNull($call->signature_valid);
$this->assertNull($call->processed_at);
}
public function test_unprocessed_scope(): void
{
WebhookCall::create([
'source' => 'stripe',
'headers' => [],
'payload' => ['type' => 'invoice.paid'],
]);
WebhookCall::create([
'source' => 'stripe',
'headers' => [],
'payload' => ['type' => 'invoice.created'],
'processed_at' => now(),
]);
$unprocessed = WebhookCall::unprocessed()->get();
$this->assertCount(1, $unprocessed);
$this->assertSame('invoice.paid', $unprocessed->first()->payload['type']);
}
public function test_for_source_scope(): void
{
WebhookCall::create(['source' => 'stripe', 'headers' => [], 'payload' => []]);
WebhookCall::create(['source' => 'altum-biolinks', 'headers' => [], 'payload' => []]);
$this->assertCount(1, WebhookCall::forSource('stripe')->get());
$this->assertCount(1, WebhookCall::forSource('altum-biolinks')->get());
}
public function test_mark_processed(): void
{
$call = WebhookCall::create([
'source' => 'test',
'headers' => [],
'payload' => [],
]);
$this->assertNull($call->processed_at);
$call->markProcessed();
$this->assertNotNull($call->fresh()->processed_at);
}
public function test_signature_valid_is_nullable_boolean(): void
{
$call = WebhookCall::create([
'source' => 'test',
'headers' => [],
'payload' => [],
'signature_valid' => false,
]);
$this->assertFalse($call->signature_valid);
}
}