Skip to content

Commit a706e57

Browse files
committed
Track and store last received on webhook table
1 parent da72c0a commit a706e57

4 files changed

Lines changed: 249 additions & 1 deletion

File tree

resources/js/components/WebhookStatus.vue

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
<ui-table-column>Topic</ui-table-column>
1111
<ui-table-column>Callback URL</ui-table-column>
1212
<ui-table-column>Status</ui-table-column>
13+
<ui-table-column>Last received</ui-table-column>
1314
</ui-table-columns>
1415
<ui-table-rows>
1516
<ui-table-row v-for="webhook in webhooks" :key="webhook.id">
@@ -19,13 +20,15 @@
1920
<ui-badge v-if="webhook.expected" color="green" icon="check" text="Registered" />
2021
<ui-badge v-else color="amber" icon="warning" text="Unknown URL" />
2122
</ui-table-cell>
23+
<ui-table-cell class="text-xs">{{ formatLastReceived(webhook.last_received_at) }}</ui-table-cell>
2224
</ui-table-row>
2325
<ui-table-row v-for="topic in missingTopics" :key="topic">
2426
<ui-table-cell class="font-mono text-xs">{{ topic }}</ui-table-cell>
25-
<ui-table-cell class="font-mono text-xs text-gray-400">{{ expected[topic] }}</ui-table-cell>
27+
<ui-table-cell class="font-mono text-xs">{{ expected[topic] }}</ui-table-cell>
2628
<ui-table-cell>
2729
<ui-badge color="red" icon="warning-diamond" text="Not registered" />
2830
</ui-table-cell>
31+
<ui-table-cell class="text-xs">—</ui-table-cell>
2932
</ui-table-row>
3033
</ui-table-rows>
3134
</ui-table>
@@ -67,6 +70,13 @@ export default {
6770
},
6871
},
6972
73+
methods: {
74+
formatLastReceived(timestamp) {
75+
if (!timestamp) return 'Never';
76+
return new Date(timestamp).toLocaleString();
77+
},
78+
},
79+
7080
mounted() {
7181
axios.get(this.url)
7282
.then(res => {

src/Http/Controllers/CP/WebhooksStatusController.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace StatamicRadPack\Shopify\Http\Controllers\CP;
44

55
use Illuminate\Http\Request;
6+
use Illuminate\Support\Facades\Cache;
67
use Shopify\Clients\Graphql;
78
use Statamic\Http\Controllers\CP\CpController;
89
use Statamic\Support\Arr;
@@ -39,6 +40,7 @@ public function index(Request $request)
3940
$webhooks = collect($registered)->map(fn ($webhook) => array_merge($webhook, [
4041
'expected' => isset($expected[$webhook['topic']])
4142
&& $webhook['callbackUrl'] === $expected[$webhook['topic']],
43+
'last_received_at' => $this->getLastReceivedAt($webhook['topic'], $storeHandle),
4244
]))->all();
4345

4446
return response()->json([
@@ -47,6 +49,15 @@ public function index(Request $request)
4749
]);
4850
}
4951

52+
private function getLastReceivedAt(string $topic, ?string $storeHandle): ?string
53+
{
54+
$cacheKey = $storeHandle
55+
? "shopify::webhook_last_received::{$storeHandle}::{$topic}"
56+
: "shopify::webhook_last_received::{$topic}";
57+
58+
return Cache::get($cacheKey);
59+
}
60+
5061
private function fetchWebhooks(Graphql $graphql): array
5162
{
5263
$query = <<<'QUERY'

src/Http/Middleware/VerifyShopifyHeaders.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Closure;
66
use Illuminate\Http\Request;
7+
use Illuminate\Support\Facades\Cache;
78
use StatamicRadPack\Shopify\Support\StoreConfig;
89

910
class VerifyShopifyHeaders
@@ -34,9 +35,29 @@ public function handle(Request $request, Closure $next)
3435
return response()->json(['error' => true], 403);
3536
}
3637

38+
$this->recordLastReceived($request);
39+
3740
return $next($request);
3841
}
3942

43+
protected function recordLastReceived(Request $request): void
44+
{
45+
$rawTopic = $request->header('X-Shopify-Topic');
46+
47+
if (! $rawTopic) {
48+
return;
49+
}
50+
51+
$topic = str_replace('/', '_', strtoupper($rawTopic));
52+
$storeHandle = $request->attributes->get('shopify_store_handle');
53+
54+
$cacheKey = $storeHandle
55+
? "shopify::webhook_last_received::{$storeHandle}::{$topic}"
56+
: "shopify::webhook_last_received::{$topic}";
57+
58+
Cache::forever($cacheKey, now()->toIso8601String());
59+
}
60+
4061
/**
4162
* Verify integrity
4263
*/
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
<?php
2+
3+
namespace StatamicRadPack\Shopify\Tests\Unit;
4+
5+
use Illuminate\Support\Facades\Cache;
6+
use Mockery\MockInterface;
7+
use PHPUnit\Framework\Attributes\Test;
8+
use Shopify\Clients\Graphql;
9+
use Shopify\Clients\HttpResponse;
10+
use Statamic\Facades\User;
11+
use StatamicRadPack\Shopify\Tests\TestCase;
12+
13+
class WebhookLastReceivedTest extends TestCase
14+
{
15+
protected function setUp(): void
16+
{
17+
parent::setUp();
18+
19+
config()->set('shopify.ignore_webhook_integrity_check', true);
20+
}
21+
22+
private function actingAsSuperUser()
23+
{
24+
$user = User::make()->email('admin@example.com')->makeSuper()->save();
25+
26+
return $this->actingAs($user);
27+
}
28+
29+
private function shopifyWebhooksResponse(array $nodes = []): string
30+
{
31+
return json_encode([
32+
'data' => [
33+
'webhookSubscriptions' => [
34+
'nodes' => $nodes,
35+
],
36+
],
37+
]);
38+
}
39+
40+
#[Test]
41+
public function records_last_received_timestamp_on_webhook_request()
42+
{
43+
Cache::flush();
44+
45+
$this->mock(Graphql::class, function (MockInterface $mock) {
46+
$mock->shouldReceive('query')->andReturn(new HttpResponse(status: 200, body: '{}'));
47+
});
48+
49+
$this->postJson('/!/shopify/webhook/product/create', ['id' => 1], [
50+
'X-Shopify-Topic' => 'products/create',
51+
]);
52+
53+
$this->assertNotNull(Cache::get('shopify::webhook_last_received::PRODUCTS_CREATE'));
54+
}
55+
56+
#[Test]
57+
public function normalises_topic_header_to_enum_format()
58+
{
59+
Cache::flush();
60+
61+
$this->mock(Graphql::class, function (MockInterface $mock) {
62+
$mock->shouldReceive('query')->andReturn(new HttpResponse(status: 200, body: '{}'));
63+
});
64+
65+
$this->postJson('/!/shopify/webhook/collection/update', ['id' => 1], [
66+
'X-Shopify-Topic' => 'collections/update',
67+
]);
68+
69+
$this->assertNotNull(Cache::get('shopify::webhook_last_received::COLLECTIONS_UPDATE'));
70+
}
71+
72+
#[Test]
73+
public function does_not_record_timestamp_when_verification_fails()
74+
{
75+
Cache::flush();
76+
config()->set('shopify.ignore_webhook_integrity_check', false);
77+
config()->set('shopify.webhook_secret', 'secret');
78+
79+
$this->postJson('/!/shopify/webhook/product/create', ['id' => 1], [
80+
'X-Shopify-Topic' => 'products/create',
81+
'X-Shopify-Hmac-Sha256' => 'invalid',
82+
]);
83+
84+
$this->assertNull(Cache::get('shopify::webhook_last_received::PRODUCTS_CREATE'));
85+
}
86+
87+
#[Test]
88+
public function records_timestamp_per_store_in_multi_store_mode()
89+
{
90+
Cache::flush();
91+
92+
config(['shopify.multi_store' => [
93+
'enabled' => true,
94+
'mode' => 'unified',
95+
'stores' => [
96+
'uk' => [
97+
'url' => 'uk.myshopify.com',
98+
'webhook_secret' => 'secret',
99+
],
100+
],
101+
]]);
102+
103+
$this->mock(Graphql::class, function (MockInterface $mock) {
104+
$mock->shouldReceive('query')->andReturn(new HttpResponse(status: 200, body: '{}'));
105+
});
106+
107+
$this->postJson('/!/shopify/webhook/product/update', ['id' => 1], [
108+
'X-Shopify-Topic' => 'products/update',
109+
'X-Shopify-Shop-Domain' => 'uk.myshopify.com',
110+
]);
111+
112+
$this->assertNotNull(Cache::get('shopify::webhook_last_received::uk::PRODUCTS_UPDATE'));
113+
$this->assertNull(Cache::get('shopify::webhook_last_received::PRODUCTS_UPDATE'));
114+
}
115+
116+
#[Test]
117+
public function status_controller_includes_last_received_at_for_each_webhook()
118+
{
119+
$callbackUrl = route('statamic.shopify.webhook.product.create', [], true);
120+
121+
Cache::forever('shopify::webhook_last_received::PRODUCTS_CREATE', '2025-06-01T12:00:00+00:00');
122+
123+
$this->mock(Graphql::class, function (MockInterface $mock) use ($callbackUrl) {
124+
$mock->shouldReceive('query')
125+
->once()
126+
->andReturn(new HttpResponse(status: 200, body: $this->shopifyWebhooksResponse([
127+
[
128+
'id' => 'gid://shopify/WebhookSubscription/1',
129+
'topic' => 'PRODUCTS_CREATE',
130+
'endpoint' => ['callbackUrl' => $callbackUrl],
131+
'createdAt' => '2025-01-01T00:00:00Z',
132+
],
133+
])));
134+
});
135+
136+
$response = $this->actingAsSuperUser()
137+
->getJson(cp_route('shopify.webhooks.status'));
138+
139+
$response->assertOk();
140+
$this->assertSame('2025-06-01T12:00:00+00:00', $response->json('webhooks.0.last_received_at'));
141+
}
142+
143+
#[Test]
144+
public function status_controller_returns_null_last_received_at_when_never_fired()
145+
{
146+
Cache::flush();
147+
148+
$callbackUrl = route('statamic.shopify.webhook.product.create', [], true);
149+
150+
$this->mock(Graphql::class, function (MockInterface $mock) use ($callbackUrl) {
151+
$mock->shouldReceive('query')
152+
->once()
153+
->andReturn(new HttpResponse(status: 200, body: $this->shopifyWebhooksResponse([
154+
[
155+
'id' => 'gid://shopify/WebhookSubscription/1',
156+
'topic' => 'PRODUCTS_CREATE',
157+
'endpoint' => ['callbackUrl' => $callbackUrl],
158+
'createdAt' => '2025-01-01T00:00:00Z',
159+
],
160+
])));
161+
});
162+
163+
$response = $this->actingAsSuperUser()
164+
->getJson(cp_route('shopify.webhooks.status'));
165+
166+
$response->assertOk();
167+
$this->assertNull($response->json('webhooks.0.last_received_at'));
168+
}
169+
170+
#[Test]
171+
public function status_controller_uses_store_scoped_cache_key_in_multi_store_mode()
172+
{
173+
Cache::flush();
174+
175+
config(['shopify.multi_store' => [
176+
'enabled' => true,
177+
'mode' => 'unified',
178+
'stores' => [
179+
'uk' => ['url' => 'uk.myshopify.com', 'admin_token' => 'tok'],
180+
],
181+
]]);
182+
183+
$callbackUrl = route('statamic.shopify.webhook.product.create', [], true);
184+
185+
Cache::forever('shopify::webhook_last_received::uk::PRODUCTS_CREATE', '2025-06-01T12:00:00+00:00');
186+
187+
$this->app->instance('shopify.graphql.uk', tap($this->mock(Graphql::class), function (MockInterface $mock) use ($callbackUrl) {
188+
$mock->shouldReceive('query')
189+
->once()
190+
->andReturn(new HttpResponse(status: 200, body: $this->shopifyWebhooksResponse([
191+
[
192+
'id' => 'gid://shopify/WebhookSubscription/1',
193+
'topic' => 'PRODUCTS_CREATE',
194+
'endpoint' => ['callbackUrl' => $callbackUrl],
195+
'createdAt' => '2025-01-01T00:00:00Z',
196+
],
197+
])));
198+
}));
199+
200+
$response = $this->actingAsSuperUser()
201+
->getJson(cp_route('shopify.webhooks.status').'?store=uk');
202+
203+
$response->assertOk();
204+
$this->assertSame('2025-06-01T12:00:00+00:00', $response->json('webhooks.0.last_received_at'));
205+
}
206+
}

0 commit comments

Comments
 (0)