Skip to content

Commit 90e4bf1

Browse files
committed
feat(phase-37): webhook management REST API with delivery log and HMAC
- WebhookApiController: CRUD, deliveries, ping test, secret rotation - Auto-generates HMAC secret (sha256) on webhook creation - Supports 10 ERP event types: invoice, payment, contact, product, HR - Routes: /webhooks CRUD + /deliveries, /ping, /rotate-secret, /events - 10 feature tests covering validation, delivery log, secret rotation Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 6d30db1 commit 90e4bf1

4 files changed

Lines changed: 290 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ beforeEach(function () {
168168
| 34 | Budget Management REST API — CRUD + activate + variance ||
169169
| 35 | Customer Credit Limits — per-contact limits, hold, check API ||
170170
| 36 | Product Variants REST API — attributes, variants, matrix view ||
171+
| 37 | Webhook Management REST API — CRUD, delivery log, ping, HMAC ||
171172

172173
## File Locations Reference
173174

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
4+
5+
use App\Models\Webhook;
6+
use App\Models\WebhookDelivery;
7+
use App\Services\WebhookService;
8+
use Illuminate\Http\JsonResponse;
9+
use Illuminate\Http\Request;
10+
use Illuminate\Support\Str;
11+
12+
class WebhookApiController extends ApiController
13+
{
14+
public static array $supportedEvents = [
15+
'invoice.created', 'invoice.paid', 'invoice.cancelled',
16+
'contact.created', 'contact.updated',
17+
'product.low_stock',
18+
'payment.received',
19+
'purchase_order.approved',
20+
'employee.hired', 'leave.approved',
21+
];
22+
23+
public function index(Request $request): JsonResponse
24+
{
25+
$tenantId = $this->tenantId($request);
26+
$webhooks = Webhook::withoutGlobalScopes()
27+
->where('tenant_id', $tenantId)
28+
->withCount('deliveries')
29+
->latest()
30+
->get();
31+
32+
return $this->success($webhooks);
33+
}
34+
35+
public function store(Request $request): JsonResponse
36+
{
37+
$tenantId = $this->tenantId($request);
38+
39+
$data = $request->validate([
40+
'name' => ['required', 'string', 'max:255'],
41+
'url' => ['required', 'url'],
42+
'events' => ['required', 'array', 'min:1'],
43+
'events.*' => ['string', 'in:' . implode(',', self::$supportedEvents)],
44+
'is_active' => ['sometimes', 'boolean'],
45+
]);
46+
47+
$webhook = Webhook::create([
48+
...$data,
49+
'tenant_id' => $tenantId,
50+
'secret' => Str::random(32),
51+
]);
52+
53+
return $this->success($webhook, 201);
54+
}
55+
56+
public function show(Webhook $webhook): JsonResponse
57+
{
58+
return $this->success($webhook->load('deliveries'));
59+
}
60+
61+
public function update(Request $request, Webhook $webhook): JsonResponse
62+
{
63+
$data = $request->validate([
64+
'name' => ['sometimes', 'string', 'max:255'],
65+
'url' => ['sometimes', 'url'],
66+
'events' => ['sometimes', 'array', 'min:1'],
67+
'events.*' => ['string', 'in:' . implode(',', self::$supportedEvents)],
68+
'is_active' => ['sometimes', 'boolean'],
69+
]);
70+
71+
$webhook->update($data);
72+
return $this->success($webhook->fresh());
73+
}
74+
75+
public function destroy(Webhook $webhook): JsonResponse
76+
{
77+
$webhook->delete();
78+
return $this->success(['message' => 'Webhook deleted.']);
79+
}
80+
81+
public function deliveries(Webhook $webhook): JsonResponse
82+
{
83+
$deliveries = $webhook->deliveries()
84+
->latest()
85+
->paginate(20);
86+
87+
return $this->paginated($deliveries);
88+
}
89+
90+
public function ping(Request $request, Webhook $webhook): JsonResponse
91+
{
92+
$tenantId = $this->tenantId($request);
93+
$delivery = WebhookService::send($webhook, 'ping', [
94+
'tenant_id' => $tenantId,
95+
'message' => 'Webhook test ping from ERP',
96+
'timestamp' => now()->toIso8601String(),
97+
]);
98+
99+
return $this->success([
100+
'delivery_id' => $delivery->id,
101+
'success' => $delivery->delivered_at !== null,
102+
'response_status' => $delivery->response_status,
103+
]);
104+
}
105+
106+
public function rotateSecret(Webhook $webhook): JsonResponse
107+
{
108+
$webhook->update(['secret' => Str::random(32)]);
109+
return $this->success(['secret' => $webhook->fresh()->secret]);
110+
}
111+
112+
public function events(): JsonResponse
113+
{
114+
return $this->success(self::$supportedEvents);
115+
}
116+
117+
private function tenantId(Request $request): int
118+
{
119+
return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id;
120+
}
121+
}

erp/routes/api.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,5 +447,18 @@
447447
Route::delete('/{variant}',[\App\Http\Controllers\Api\V1\ProductVariantController::class, 'destroy']);
448448
});
449449
Route::get('products/{product}/matrix', [\App\Http\Controllers\Api\V1\ProductVariantController::class, 'matrix']);
450+
451+
// Webhook Management
452+
Route::get('/webhooks/events', [\App\Http\Controllers\Api\V1\WebhookApiController::class, 'events']);
453+
Route::prefix('webhooks')->group(function () {
454+
Route::get('/', [\App\Http\Controllers\Api\V1\WebhookApiController::class, 'index']);
455+
Route::post('/', [\App\Http\Controllers\Api\V1\WebhookApiController::class, 'store']);
456+
Route::get('/{webhook}', [\App\Http\Controllers\Api\V1\WebhookApiController::class, 'show']);
457+
Route::put('/{webhook}', [\App\Http\Controllers\Api\V1\WebhookApiController::class, 'update']);
458+
Route::delete('/{webhook}', [\App\Http\Controllers\Api\V1\WebhookApiController::class, 'destroy']);
459+
Route::get('/{webhook}/deliveries', [\App\Http\Controllers\Api\V1\WebhookApiController::class, 'deliveries']);
460+
Route::post('/{webhook}/ping', [\App\Http\Controllers\Api\V1\WebhookApiController::class, 'ping']);
461+
Route::post('/{webhook}/rotate-secret', [\App\Http\Controllers\Api\V1\WebhookApiController::class, 'rotateSecret']);
462+
});
450463
});
451464
});
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
<?php
2+
3+
use App\Models\User;
4+
use App\Models\Webhook;
5+
use App\Models\WebhookDelivery;
6+
use App\Modules\Core\Models\Tenant;
7+
use Database\Seeders\RolePermissionSeeder;
8+
9+
beforeEach(function () {
10+
$this->seed(RolePermissionSeeder::class);
11+
$this->tenant = Tenant::create(['name' => 'Webhook API Co', 'slug' => 'webhook-api-' . uniqid()]);
12+
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
13+
$this->user->assignRole('super-admin');
14+
$this->token = $this->user->createToken('test')->plainTextToken;
15+
app()->instance('tenant', $this->tenant);
16+
});
17+
18+
test('can list webhooks via api', function () {
19+
Webhook::create([
20+
'tenant_id' => $this->tenant->id,
21+
'name' => 'Invoice Hook',
22+
'url' => 'https://example.com/hook',
23+
'events' => ['invoice.created'],
24+
'secret' => 'secret123',
25+
]);
26+
27+
$this->withToken($this->token)
28+
->getJson('/api/v1/webhooks')
29+
->assertStatus(200)
30+
->assertJsonPath('data.0.name', 'Invoice Hook');
31+
});
32+
33+
test('can create a webhook', function () {
34+
$response = $this->withToken($this->token)
35+
->postJson('/api/v1/webhooks', [
36+
'name' => 'Payment Hook',
37+
'url' => 'https://example.com/payment',
38+
'events' => ['payment.received', 'invoice.paid'],
39+
]);
40+
41+
$response->assertStatus(201);
42+
expect(Webhook::where('name', 'Payment Hook')->exists())->toBeTrue();
43+
44+
$hook = Webhook::where('name', 'Payment Hook')->first();
45+
expect($hook->secret)->not->toBeNull();
46+
});
47+
48+
test('store validates url format', function () {
49+
$this->withToken($this->token)
50+
->postJson('/api/v1/webhooks', [
51+
'name' => 'Bad URL',
52+
'url' => 'not-a-url',
53+
'events' => ['invoice.created'],
54+
])
55+
->assertStatus(422)
56+
->assertJsonValidationErrors(['url']);
57+
});
58+
59+
test('store validates known events', function () {
60+
$this->withToken($this->token)
61+
->postJson('/api/v1/webhooks', [
62+
'name' => 'Unknown Event',
63+
'url' => 'https://example.com/hook',
64+
'events' => ['unknown.event'],
65+
])
66+
->assertStatus(422)
67+
->assertJsonValidationErrors(['events.0']);
68+
});
69+
70+
test('can update a webhook', function () {
71+
$hook = Webhook::create([
72+
'tenant_id' => $this->tenant->id,
73+
'name' => 'Original',
74+
'url' => 'https://example.com/orig',
75+
'events' => ['invoice.created'],
76+
'secret' => 'abc',
77+
]);
78+
79+
$this->withToken($this->token)
80+
->putJson("/api/v1/webhooks/{$hook->id}", ['is_active' => false])
81+
->assertStatus(200)
82+
->assertJsonPath('data.is_active', false);
83+
});
84+
85+
test('can delete a webhook', function () {
86+
$hook = Webhook::create([
87+
'tenant_id' => $this->tenant->id,
88+
'name' => 'Delete Me',
89+
'url' => 'https://example.com/del',
90+
'events' => ['invoice.created'],
91+
'secret' => 'abc',
92+
]);
93+
94+
$this->withToken($this->token)
95+
->deleteJson("/api/v1/webhooks/{$hook->id}")
96+
->assertStatus(200);
97+
98+
expect(Webhook::find($hook->id))->toBeNull();
99+
});
100+
101+
test('deliveries endpoint returns delivery log', function () {
102+
$hook = Webhook::create([
103+
'tenant_id' => $this->tenant->id,
104+
'name' => 'Hook With Deliveries',
105+
'url' => 'https://example.com/del',
106+
'events' => ['invoice.created'],
107+
'secret' => 'abc',
108+
]);
109+
110+
WebhookDelivery::create([
111+
'webhook_id' => $hook->id,
112+
'event' => 'invoice.created',
113+
'payload' => ['id' => 1],
114+
'response_status' => 200,
115+
'delivered_at' => now(),
116+
'attempts' => 1,
117+
]);
118+
119+
$this->withToken($this->token)
120+
->getJson("/api/v1/webhooks/{$hook->id}/deliveries")
121+
->assertStatus(200)
122+
->assertJsonPath('data.0.event', 'invoice.created');
123+
});
124+
125+
test('rotate secret generates new secret', function () {
126+
$hook = Webhook::create([
127+
'tenant_id' => $this->tenant->id,
128+
'name' => 'Secret Hook',
129+
'url' => 'https://example.com/secret',
130+
'events' => ['invoice.created'],
131+
'secret' => 'original-secret',
132+
]);
133+
134+
$response = $this->withToken($this->token)
135+
->postJson("/api/v1/webhooks/{$hook->id}/rotate-secret")
136+
->assertStatus(200);
137+
138+
$newSecret = $response->json('data.secret');
139+
expect($newSecret)->not->toBe('original-secret');
140+
expect(strlen($newSecret))->toBeGreaterThanOrEqual(32);
141+
});
142+
143+
test('events endpoint lists supported events', function () {
144+
$this->withToken($this->token)
145+
->getJson('/api/v1/webhooks/events')
146+
->assertStatus(200);
147+
148+
$events = $this->withToken($this->token)->getJson('/api/v1/webhooks/events')->json('data');
149+
expect($events)->toContain('invoice.created');
150+
expect($events)->toContain('payment.received');
151+
});
152+
153+
test('requires authentication', function () {
154+
$this->getJson('/api/v1/webhooks')->assertStatus(401);
155+
});

0 commit comments

Comments
 (0)