@open-mercato/webhooks 0.6.5-develop.4534.1.b459babe6d → 0.6.5-develop.4544.1.71c003c861

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,352 @@
1
+ import { test, expect, type APIRequestContext } from '@playwright/test'
2
+ import { getAuthToken, apiRequest } from '@open-mercato/core/modules/core/__integration__/helpers/api'
3
+ import { login } from '@open-mercato/core/modules/core/__integration__/helpers/auth'
4
+ import {
5
+ putWithLock,
6
+ expectConflictBody,
7
+ expectConflictBanner,
8
+ resolveApiUrl,
9
+ } from '@open-mercato/core/modules/core/__integration__/helpers/optimisticLockUi'
10
+ import { OPTIMISTIC_LOCK_HEADER_NAME } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
11
+
12
+ /**
13
+ * TC-LOCK-OSS-043 — webhooks + inbox settings + data-sync schedule
14
+ * optimistic-lock conflict contract (WHK-01, INB-01, SYNC-01).
15
+ *
16
+ * All three surfaces guard writes with `enforceCommandOptimisticLock` inside
17
+ * pure command/API routes — none expose a dedicated `data-crud-field-id`
18
+ * CrudForm edit page for the locked record, so the conflict contract is proven
19
+ * at the API level: capture the record's `updated_at`, advance it out-of-band
20
+ * via a header-less write (the strictly-additive path always succeeds and bumps
21
+ * `updated_at`), then replay the now-stale write with the original
22
+ * expected-version header → 409 `optimistic_lock_conflict`.
23
+ *
24
+ * - WHK-01: `PUT /api/webhooks/<id>` and `DELETE /api/webhooks/<id>`
25
+ * (`packages/webhooks/src/modules/webhooks/api/webhooks/[id]/route.ts`,
26
+ * `resourceKind: 'webhooks.endpoint'`).
27
+ * - INB-01: `PATCH /api/inbox_ops/settings`
28
+ * (`packages/core/src/modules/inbox_ops/api/settings/route.ts`,
29
+ * `resourceKind: 'inbox_ops.settings'`). The settings row is a per-tenant
30
+ * singleton, so the test bumps + restores `workingLanguage` instead of
31
+ * creating/deleting a fixture.
32
+ * - SYNC-01: `POST /api/data_sync/schedules`
33
+ * (`packages/core/src/modules/data_sync/api/schedules/route.ts` →
34
+ * `SyncScheduleService.saveSchedule`, `resourceKind: 'data_sync.schedule'`).
35
+ * The collection POST reads the expected-version header; the service enforces
36
+ * the lock when a row with the same (integrationId, entityType, direction)
37
+ * key already exists.
38
+ */
39
+
40
+ const WEBHOOKS_API = '/api/webhooks'
41
+ const INBOX_SETTINGS_API = '/api/inbox_ops/settings'
42
+ const SCHEDULES_API = '/api/data_sync/schedules'
43
+
44
+ type InboxSettings = { id: string; workingLanguage: string; updatedAt: string }
45
+
46
+ async function createWebhook(
47
+ request: APIRequestContext,
48
+ token: string,
49
+ stamp: number,
50
+ ): Promise<{ id: string; updatedAt: string }> {
51
+ const created = await apiRequest(request, 'POST', WEBHOOKS_API, {
52
+ token,
53
+ data: {
54
+ name: `QA Lock 043 ${stamp}`,
55
+ url: `https://example.com/qa-lock-043-${stamp}`,
56
+ subscribedEvents: ['qa.lock.test'],
57
+ },
58
+ })
59
+ expect(created.status(), 'POST webhook should be 201').toBe(201)
60
+ const body = (await created.json()) as { id?: string }
61
+ expect(typeof body.id, 'webhook creation should return an id').toBe('string')
62
+ const detail = await apiRequest(request, 'GET', `${WEBHOOKS_API}/${body.id}`, { token })
63
+ expect(detail.status(), 'GET webhook detail should be 200').toBe(200)
64
+ const detailBody = (await detail.json()) as { updatedAt?: string }
65
+ expect(typeof detailBody.updatedAt, 'webhook should expose updatedAt').toBe('string')
66
+ return { id: body.id as string, updatedAt: detailBody.updatedAt as string }
67
+ }
68
+
69
+ async function readWebhookUpdatedAt(
70
+ request: APIRequestContext,
71
+ token: string,
72
+ id: string,
73
+ ): Promise<string> {
74
+ const detail = await apiRequest(request, 'GET', `${WEBHOOKS_API}/${id}`, { token })
75
+ expect(detail.status(), 'GET webhook detail should be 200').toBe(200)
76
+ const body = (await detail.json()) as { updatedAt?: string }
77
+ return body.updatedAt as string
78
+ }
79
+
80
+ async function deleteWebhook(
81
+ request: APIRequestContext,
82
+ token: string,
83
+ id: string,
84
+ ): Promise<void> {
85
+ const current = await readWebhookUpdatedAt(request, token, id).catch(() => undefined)
86
+ await request
87
+ .fetch(resolveApiUrl(`${WEBHOOKS_API}/${id}`), {
88
+ method: 'DELETE',
89
+ headers: {
90
+ Authorization: `Bearer ${token}`,
91
+ 'Content-Type': 'application/json',
92
+ ...(current ? { [OPTIMISTIC_LOCK_HEADER_NAME]: current } : {}),
93
+ },
94
+ })
95
+ .catch(() => undefined)
96
+ }
97
+
98
+ async function patchInboxSettings(
99
+ request: APIRequestContext,
100
+ token: string,
101
+ body: Record<string, unknown>,
102
+ lockValue?: string,
103
+ ) {
104
+ return request.fetch(resolveApiUrl(INBOX_SETTINGS_API), {
105
+ method: 'PATCH',
106
+ headers: {
107
+ Authorization: `Bearer ${token}`,
108
+ 'Content-Type': 'application/json',
109
+ ...(lockValue !== undefined ? { [OPTIMISTIC_LOCK_HEADER_NAME]: lockValue } : {}),
110
+ },
111
+ data: body,
112
+ })
113
+ }
114
+
115
+ async function readInboxSettings(
116
+ request: APIRequestContext,
117
+ token: string,
118
+ ): Promise<InboxSettings> {
119
+ const response = await apiRequest(request, 'GET', INBOX_SETTINGS_API, { token })
120
+ expect(response.status(), 'GET inbox settings should be 200').toBe(200)
121
+ const body = (await response.json()) as { settings?: InboxSettings | null }
122
+ expect(body.settings, 'tenant inbox settings singleton should exist').toBeTruthy()
123
+ return body.settings as InboxSettings
124
+ }
125
+
126
+ async function postSchedule(
127
+ request: APIRequestContext,
128
+ token: string,
129
+ scheduleValue: string,
130
+ integrationId: string,
131
+ lockValue?: string,
132
+ ) {
133
+ return request.fetch(resolveApiUrl(SCHEDULES_API), {
134
+ method: 'POST',
135
+ headers: {
136
+ Authorization: `Bearer ${token}`,
137
+ 'Content-Type': 'application/json',
138
+ ...(lockValue !== undefined ? { [OPTIMISTIC_LOCK_HEADER_NAME]: lockValue } : {}),
139
+ },
140
+ data: {
141
+ integrationId,
142
+ entityType: 'products',
143
+ direction: 'import',
144
+ scheduleType: 'cron',
145
+ scheduleValue,
146
+ timezone: 'UTC',
147
+ fullSync: false,
148
+ isEnabled: true,
149
+ },
150
+ })
151
+ }
152
+
153
+ test.describe('TC-LOCK-OSS-043: webhooks + inbox settings + data-sync schedule optimistic lock', () => {
154
+ test('WHK-01 stale webhook PUT is refused with a 409 conflict', async ({ page }) => {
155
+ const token = await getAuthToken(page.request, 'admin')
156
+ const stamp = Date.now()
157
+ let webhookId: string | null = null
158
+ try {
159
+ const webhook = await createWebhook(page.request, token, stamp)
160
+ webhookId = webhook.id
161
+ const staleUpdatedAt = webhook.updatedAt
162
+
163
+ // Advance updated_at out-of-band via a header-less PUT.
164
+ const bump = await apiRequest(page.request, 'PUT', `${WEBHOOKS_API}/${webhookId}`, {
165
+ token,
166
+ data: { name: `QA Lock 043 bumped ${stamp}` },
167
+ })
168
+ expect(bump.status(), 'out-of-band webhook PUT should succeed').toBeLessThan(300)
169
+
170
+ // Replay the now-stale write → 409.
171
+ const conflict = await putWithLock(
172
+ page.request,
173
+ token,
174
+ `${WEBHOOKS_API}/${webhookId}`,
175
+ { name: `QA Lock 043 stale ${stamp}` },
176
+ staleUpdatedAt,
177
+ )
178
+ await expectConflictBody(conflict)
179
+ } finally {
180
+ if (webhookId) await deleteWebhook(page.request, token, webhookId)
181
+ }
182
+ })
183
+
184
+ test('WHK-01 stale webhook DELETE is refused with a 409 conflict', async ({ page }) => {
185
+ const token = await getAuthToken(page.request, 'admin')
186
+ const stamp = Date.now()
187
+ let webhookId: string | null = null
188
+ try {
189
+ const webhook = await createWebhook(page.request, token, stamp)
190
+ webhookId = webhook.id
191
+ const staleUpdatedAt = webhook.updatedAt
192
+
193
+ const bump = await apiRequest(page.request, 'PUT', `${WEBHOOKS_API}/${webhookId}`, {
194
+ token,
195
+ data: { name: `QA Lock 043 bumped ${stamp}` },
196
+ })
197
+ expect(bump.status(), 'out-of-band webhook PUT should succeed').toBeLessThan(300)
198
+
199
+ const conflict = await page.request.fetch(resolveApiUrl(`${WEBHOOKS_API}/${webhookId}`), {
200
+ method: 'DELETE',
201
+ headers: {
202
+ Authorization: `Bearer ${token}`,
203
+ 'Content-Type': 'application/json',
204
+ [OPTIMISTIC_LOCK_HEADER_NAME]: staleUpdatedAt,
205
+ },
206
+ })
207
+ await expectConflictBody(conflict)
208
+ } finally {
209
+ if (webhookId) await deleteWebhook(page.request, token, webhookId)
210
+ }
211
+ })
212
+
213
+ // WHK-01 (browser UI): now ACTIVE. The product fix is committed/verified — the webhooks
214
+ // server DELETE returns the structured 409 (proven by the active "WHK-01 stale webhook
215
+ // DELETE is refused with a 409" API test) and the list page sends the lock header
216
+ // (`buildOptimisticLockHeader(row.updatedAt)`) + routes the resulting 409 through
217
+ // surfaceRecordConflict. The RowActions menu opens on CLICK of the "Open actions" kebab and
218
+ // renders the items in a portal on document.body (role="menuitem"); the confirm dialog is a
219
+ // role="alertdialog" with a "Confirm" button. We drive that choreography robustly here:
220
+ // wait for the filtered list GET to settle, open the kebab, click Delete, confirm, then
221
+ // assert the unified conflict bar (never the success toast).
222
+ test('WHK-01 stale webhook DELETE in the list surfaces the conflict bar', async ({ page }) => {
223
+ const token = await getAuthToken(page.request, 'admin')
224
+ const stamp = Date.now()
225
+ const webhookName = `QA Lock 043 ${stamp}`
226
+ let webhookId: string | null = null
227
+ try {
228
+ const webhook = await createWebhook(page.request, token, stamp)
229
+ webhookId = webhook.id
230
+
231
+ await login(page, 'admin')
232
+ await page.goto('/backend/webhooks')
233
+ // Wait for the list GET to settle before interacting. The list reflects the
234
+ // freshly created fixture immediately (newest-first), so a unique name makes
235
+ // the row unambiguous.
236
+ await page
237
+ .waitForResponse(
238
+ (response) =>
239
+ response.url().includes('/api/webhooks') &&
240
+ response.request().method() === 'GET' &&
241
+ response.ok(),
242
+ { timeout: 20_000 },
243
+ )
244
+ .catch(() => undefined)
245
+
246
+ const row = page.getByRole('row', { name: new RegExp(`${webhookName}\\b`) }).first()
247
+ await expect(row, 'created webhook should appear in the list').toBeVisible({ timeout: 20_000 })
248
+
249
+ // Advance updated_at out-of-band → the in-page row token is now stale.
250
+ const bump = await apiRequest(page.request, 'PUT', `${WEBHOOKS_API}/${webhookId}`, {
251
+ token,
252
+ data: { name: `QA Lock 043 bumped ${stamp}` },
253
+ })
254
+ expect(bump.status(), 'out-of-band webhook PUT should succeed').toBeLessThan(300)
255
+
256
+ // Open the row's RowActions kebab (opens on click) and trigger Delete. The
257
+ // menu renders in a portal on document.body, so query it at the page level.
258
+ const kebab = row.getByRole('button', { name: /open actions/i })
259
+ await expect(kebab, 'row should expose an Open actions trigger').toBeVisible()
260
+ await kebab.click()
261
+ const deleteItem = page.getByRole('menuitem', { name: /^delete$/i })
262
+ await expect(deleteItem, 'delete menu item should be visible').toBeVisible({ timeout: 10_000 })
263
+ await deleteItem.click()
264
+
265
+ // Confirm the destructive alertdialog (the row still holds the stale
266
+ // updated_at captured at render time, so the DELETE 409s).
267
+ const dialog = page.getByRole('alertdialog')
268
+ await expect(dialog, 'confirm dialog should open').toBeVisible({ timeout: 10_000 })
269
+ await dialog.getByRole('button', { name: /^(confirm|delete)$/i }).click()
270
+
271
+ // The client must route the 409 to the unified conflict bar, never the
272
+ // success toast.
273
+ await expectConflictBanner(page)
274
+ } finally {
275
+ if (webhookId) await deleteWebhook(page.request, token, webhookId)
276
+ }
277
+ })
278
+
279
+ test('INB-01 stale inbox-settings PATCH is refused with a 409 conflict', async ({ page }) => {
280
+ const token = await getAuthToken(page.request, 'admin')
281
+ const before = await readInboxSettings(page.request, token)
282
+ const originalLanguage = before.workingLanguage
283
+ const staleUpdatedAt = before.updatedAt
284
+ const bumpLanguage = originalLanguage === 'de' ? 'en' : 'de'
285
+ const staleLanguage = originalLanguage === 'es' ? 'pl' : 'es'
286
+ try {
287
+ // Advance updated_at out-of-band via a header-less PATCH.
288
+ const bump = await patchInboxSettings(page.request, token, { workingLanguage: bumpLanguage })
289
+ expect(bump.status(), 'out-of-band inbox PATCH should succeed').toBeLessThan(300)
290
+
291
+ // Replay the now-stale write with the original expected-version → 409.
292
+ const conflict = await patchInboxSettings(
293
+ page.request,
294
+ token,
295
+ { workingLanguage: staleLanguage },
296
+ staleUpdatedAt,
297
+ )
298
+ await expectConflictBody(conflict)
299
+ } finally {
300
+ // Restore the original working language with a fresh lock token.
301
+ const current = await readInboxSettings(page.request, token).catch(() => null)
302
+ if (current) {
303
+ await patchInboxSettings(
304
+ page.request,
305
+ token,
306
+ { workingLanguage: originalLanguage },
307
+ current.updatedAt,
308
+ ).catch(() => undefined)
309
+ }
310
+ }
311
+ })
312
+
313
+ test('SYNC-01 stale data-sync schedule save is refused with a 409 conflict', async ({ page }) => {
314
+ const token = await getAuthToken(page.request, 'admin')
315
+ const stamp = Date.now()
316
+ const integrationId = `qa-lock-043-${stamp}`
317
+ let scheduleId: string | null = null
318
+ try {
319
+ const created = await postSchedule(page.request, token, '0 * * * *', integrationId)
320
+ expect(created.status(), 'POST schedule should be 201').toBe(201)
321
+ const createdBody = (await created.json()) as { id?: string; updatedAt?: string }
322
+ scheduleId = createdBody.id ?? null
323
+ const staleUpdatedAt = createdBody.updatedAt
324
+ expect(typeof scheduleId, 'schedule creation should return an id').toBe('string')
325
+ expect(typeof staleUpdatedAt, 'schedule should expose updatedAt').toBe('string')
326
+
327
+ // Advance updated_at out-of-band via the header-less [id] PUT route.
328
+ const bump = await apiRequest(page.request, 'PUT', `${SCHEDULES_API}/${scheduleId}`, {
329
+ token,
330
+ data: { scheduleValue: '5 * * * *' },
331
+ })
332
+ expect(bump.status(), 'out-of-band schedule PUT should succeed').toBeLessThan(300)
333
+
334
+ // Replay the save for the same (integrationId, entityType, direction) key
335
+ // with the now-stale expected-version → 409.
336
+ const conflict = await postSchedule(
337
+ page.request,
338
+ token,
339
+ '9 * * * *',
340
+ integrationId,
341
+ staleUpdatedAt as string,
342
+ )
343
+ await expectConflictBody(conflict)
344
+ } finally {
345
+ if (scheduleId) {
346
+ await apiRequest(page.request, 'DELETE', `${SCHEDULES_API}/${scheduleId}`, { token }).catch(
347
+ () => undefined,
348
+ )
349
+ }
350
+ }
351
+ })
352
+ })
@@ -0,0 +1,101 @@
1
+ /** @jest-environment node */
2
+
3
+ import { OPTIMISTIC_LOCK_HEADER_NAME } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
4
+
5
+ const WEBHOOK_ID = '123e4567-e89b-12d3-a456-426614174070'
6
+ const CURRENT_VERSION = '2026-06-01T10:00:00.000Z'
7
+ const STALE_VERSION = '2026-06-01T09:00:00.000Z'
8
+
9
+ const webhookRecord = {
10
+ id: WEBHOOK_ID,
11
+ name: 'Hook',
12
+ description: null,
13
+ url: 'https://example.com/hook',
14
+ subscribedEvents: ['a.b.c'],
15
+ httpMethod: 'POST',
16
+ isActive: true,
17
+ organizationId: 'org-1',
18
+ tenantId: 'tenant-1',
19
+ updatedAt: new Date(CURRENT_VERSION),
20
+ deletedAt: null as Date | null,
21
+ }
22
+
23
+ const mockEm = {
24
+ fork: jest.fn(() => mockEm),
25
+ flush: jest.fn(async () => undefined),
26
+ }
27
+
28
+ const mockEmitWebhooksEvent = jest.fn(async () => undefined)
29
+
30
+ jest.mock('../../../../events', () => ({
31
+ emitWebhooksEvent: (...args: unknown[]) => mockEmitWebhooksEvent(...args),
32
+ }))
33
+
34
+ jest.mock('../../../helpers', () => ({
35
+ json: (payload: unknown, init: ResponseInit = { status: 200 }) =>
36
+ new Response(JSON.stringify(payload), {
37
+ ...init,
38
+ headers: { 'content-type': 'application/json' },
39
+ }),
40
+ resolveWebhookRequestScope: jest.fn(async () => ({ em: mockEm, tenantId: 'tenant-1', organizationId: 'org-1' })),
41
+ findScopedWebhook: jest.fn(async () => (webhookRecord.deletedAt ? null : webhookRecord)),
42
+ serializeWebhookDetail: (item: { id: string; updatedAt: Date }) => ({
43
+ id: item.id,
44
+ updatedAt: item.updatedAt.toISOString(),
45
+ }),
46
+ }))
47
+
48
+ jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
49
+ resolveTranslations: jest.fn(async () => ({ translate: (_key: string, fallback?: string) => fallback ?? '' })),
50
+ }))
51
+
52
+ import { PUT, DELETE } from '../route'
53
+
54
+ function request(method: string, headerVersion: string | null, body?: unknown) {
55
+ const headers: Record<string, string> = { 'content-type': 'application/json' }
56
+ if (headerVersion) headers[OPTIMISTIC_LOCK_HEADER_NAME] = headerVersion
57
+ return new Request(`http://localhost/api/webhooks/${WEBHOOK_ID}`, {
58
+ method,
59
+ headers,
60
+ body: body !== undefined ? JSON.stringify(body) : undefined,
61
+ })
62
+ }
63
+
64
+ const context = { params: Promise.resolve({ id: WEBHOOK_ID }) }
65
+
66
+ describe('webhook endpoint PUT/DELETE optimistic locking', () => {
67
+ beforeEach(() => {
68
+ jest.clearAllMocks()
69
+ webhookRecord.deletedAt = null
70
+ delete process.env.OM_OPTIMISTIC_LOCK
71
+ })
72
+
73
+ it('PUT returns 409 with the structured conflict body when the expected version is stale', async () => {
74
+ const res = await PUT(request('PUT', STALE_VERSION, { name: 'X' }), context)
75
+ expect(res.status).toBe(409)
76
+ const body = await res.json()
77
+ expect(body.code).toBe('optimistic_lock_conflict')
78
+ expect(body.currentUpdatedAt).toBe(CURRENT_VERSION)
79
+ expect(mockEm.flush).not.toHaveBeenCalled()
80
+ })
81
+
82
+ it('PUT succeeds when the expected version matches', async () => {
83
+ const res = await PUT(request('PUT', CURRENT_VERSION, { name: 'X' }), context)
84
+ expect(res.status).toBe(200)
85
+ expect(mockEm.flush).toHaveBeenCalled()
86
+ })
87
+
88
+ it('PUT is a no-op (no 409) when the client sends no expected-version header', async () => {
89
+ const res = await PUT(request('PUT', null, { name: 'X' }), context)
90
+ expect(res.status).toBe(200)
91
+ expect(mockEm.flush).toHaveBeenCalled()
92
+ })
93
+
94
+ it('DELETE returns 409 when the expected version is stale', async () => {
95
+ const res = await DELETE(request('DELETE', STALE_VERSION), context)
96
+ expect(res.status).toBe(409)
97
+ const body = await res.json()
98
+ expect(body.code).toBe('optimistic_lock_conflict')
99
+ expect(mockEm.flush).not.toHaveBeenCalled()
100
+ })
101
+ })
@@ -4,6 +4,8 @@ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
4
4
  import { emitWebhooksEvent } from '../../../events'
5
5
  import { findScopedWebhook, json, resolveWebhookRequestScope, serializeWebhookDetail } from '../../helpers'
6
6
  import { webhookUpdateSchema } from '../../../data/validators'
7
+ import { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
8
+ import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
7
9
 
8
10
  export const metadata = {
9
11
  GET: { requireAuth: true, requireFeatures: ['webhooks.view'] },
@@ -69,6 +71,18 @@ export async function PUT(request: Request, context: RouteContext): Promise<Resp
69
71
  return json({ error: 'Webhook not found' }, { status: 404 })
70
72
  }
71
73
 
74
+ try {
75
+ enforceCommandOptimisticLock({
76
+ resourceKind: 'webhooks.endpoint',
77
+ resourceId: webhook.id,
78
+ current: webhook.updatedAt ?? null,
79
+ request,
80
+ })
81
+ } catch (err) {
82
+ if (isCrudHttpError(err)) return json(err.body, { status: err.status })
83
+ throw err
84
+ }
85
+
72
86
  const parsed = webhookUpdateSchema.safeParse(await request.json().catch(() => null))
73
87
  if (!parsed.success) {
74
88
  return json({ error: 'Invalid request payload' }, { status: 400 })
@@ -114,6 +128,18 @@ export async function DELETE(request: Request, context: RouteContext): Promise<R
114
128
  return json({ error: translate('webhooks.errors.notFound', 'Webhook not found') }, { status: 404 })
115
129
  }
116
130
 
131
+ try {
132
+ enforceCommandOptimisticLock({
133
+ resourceKind: 'webhooks.endpoint',
134
+ resourceId: webhook.id,
135
+ current: webhook.updatedAt ?? null,
136
+ request,
137
+ })
138
+ } catch (err) {
139
+ if (isCrudHttpError(err)) return json(err.body, { status: err.status })
140
+ throw err
141
+ }
142
+
117
143
  webhook.deletedAt = new Date()
118
144
  await em.flush()
119
145
 
@@ -16,6 +16,8 @@ import { FormHeader } from '@open-mercato/ui/backend/forms'
16
16
  import { RowActions } from '@open-mercato/ui/backend/RowActions'
17
17
  import { CrudForm } from '@open-mercato/ui/backend/CrudForm'
18
18
  import { deleteCrud, updateCrud } from '@open-mercato/ui/backend/utils/crud'
19
+ import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
20
+ import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
19
21
  import { Alert, AlertDescription } from '@open-mercato/ui/primitives/alert'
20
22
  import {
21
23
  buildWebhookFormContentHeader,
@@ -362,10 +364,14 @@ export default function WebhookDetailPage() {
362
364
  const handleDelete = React.useCallback(async () => {
363
365
  if (!webhook) return
364
366
  try {
365
- await deleteCrud(`webhooks/${encodeURIComponent(webhook.id)}`, { fallbackResult: null })
367
+ await deleteCrud(`webhooks/${encodeURIComponent(webhook.id)}`, {
368
+ fallbackResult: null,
369
+ headers: buildOptimisticLockHeader(webhook.updatedAt),
370
+ })
366
371
  flash(t('webhooks.list.deleteSuccess'), 'success')
367
372
  router.push('/backend/webhooks')
368
- } catch {
373
+ } catch (error) {
374
+ if (surfaceRecordConflict(error, t)) return
369
375
  flash(t('webhooks.list.deleteError'), 'error')
370
376
  }
371
377
  }, [router, t, webhook])
@@ -497,6 +503,7 @@ export default function WebhookDetailPage() {
497
503
  fields={fields}
498
504
  groups={groups}
499
505
  initialValues={createWebhookInitialValues(webhook)}
506
+ optimisticLockUpdatedAt={webhook.updatedAt}
500
507
  submitLabel={t('common.save')}
501
508
  cancelHref={`/backend/webhooks/${webhook.id}`}
502
509
  contentHeader={contentHeader}
@@ -8,6 +8,8 @@ import type { ColumnDef } from '@tanstack/react-table'
8
8
  import { Button } from '@open-mercato/ui/primitives/button'
9
9
  import { RowActions } from '@open-mercato/ui/backend/RowActions'
10
10
  import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
11
+ import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
12
+ import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
11
13
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
12
14
  import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
13
15
  import { useT } from '@open-mercato/shared/lib/i18n/context'
@@ -107,10 +109,11 @@ export default function WebhooksListPage() {
107
109
  try {
108
110
  const call = await apiCall<{ error?: string }>(
109
111
  `/api/webhooks/${encodeURIComponent(row.id)}`,
110
- { method: 'DELETE' },
112
+ { method: 'DELETE', headers: buildOptimisticLockHeader(row.updatedAt) },
111
113
  { fallback: null },
112
114
  )
113
115
  if (!call.ok) {
116
+ if (surfaceRecordConflict({ status: call.status, body: call.result }, t)) return
114
117
  const errorPayload = call.result as { error?: string } | undefined
115
118
  const message = typeof errorPayload?.error === 'string' ? errorPayload.error : t('webhooks.list.deleteError')
116
119
  flash(message, 'error')