@open-mercato/webhooks 0.6.4-develop.4382.1.6b4f656b77 → 0.6.4

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.
Files changed (50) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/webhooks/__integration__/TC-LOCK-OSS-043.spec.js +238 -0
  3. package/dist/modules/webhooks/__integration__/TC-LOCK-OSS-043.spec.js.map +7 -0
  4. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-004.spec.js +46 -0
  5. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-004.spec.js.map +7 -0
  6. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-005.spec.js +92 -0
  7. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-005.spec.js.map +7 -0
  8. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-006.spec.js +61 -0
  9. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-006.spec.js.map +7 -0
  10. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-007.spec.js +76 -0
  11. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-007.spec.js.map +7 -0
  12. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-008.spec.js +81 -0
  13. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-008.spec.js.map +7 -0
  14. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-009.spec.js +68 -0
  15. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-009.spec.js.map +7 -0
  16. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-010.spec.js +64 -0
  17. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-010.spec.js.map +7 -0
  18. package/dist/modules/webhooks/__integration__/TC-WH-CRUDFORM-001.spec.js +95 -0
  19. package/dist/modules/webhooks/__integration__/TC-WH-CRUDFORM-001.spec.js.map +7 -0
  20. package/dist/modules/webhooks/__integration__/helpers/fixtures.js +50 -1
  21. package/dist/modules/webhooks/__integration__/helpers/fixtures.js.map +2 -2
  22. package/dist/modules/webhooks/api/webhooks/[id]/route.js +24 -0
  23. package/dist/modules/webhooks/api/webhooks/[id]/route.js.map +2 -2
  24. package/dist/modules/webhooks/backend/webhooks/[id]/page.js +9 -2
  25. package/dist/modules/webhooks/backend/webhooks/[id]/page.js.map +3 -3
  26. package/dist/modules/webhooks/backend/webhooks/page.js +13 -1
  27. package/dist/modules/webhooks/backend/webhooks/page.js.map +2 -2
  28. package/dist/modules/webhooks/subscribers/outbound-dispatch.js +1 -1
  29. package/dist/modules/webhooks/subscribers/outbound-dispatch.js.map +2 -2
  30. package/package.json +8 -9
  31. package/src/modules/webhooks/__integration__/TC-LOCK-OSS-043.spec.ts +352 -0
  32. package/src/modules/webhooks/__integration__/TC-WEBHOOK-004.spec.ts +63 -0
  33. package/src/modules/webhooks/__integration__/TC-WEBHOOK-005.spec.ts +126 -0
  34. package/src/modules/webhooks/__integration__/TC-WEBHOOK-006.spec.ts +82 -0
  35. package/src/modules/webhooks/__integration__/TC-WEBHOOK-007.spec.ts +102 -0
  36. package/src/modules/webhooks/__integration__/TC-WEBHOOK-008.spec.ts +109 -0
  37. package/src/modules/webhooks/__integration__/TC-WEBHOOK-009.spec.ts +94 -0
  38. package/src/modules/webhooks/__integration__/TC-WEBHOOK-010.spec.ts +89 -0
  39. package/src/modules/webhooks/__integration__/TC-WH-CRUDFORM-001.spec.ts +126 -0
  40. package/src/modules/webhooks/__integration__/helpers/fixtures.ts +101 -1
  41. package/src/modules/webhooks/api/webhooks/[id]/__tests__/optimistic-lock.test.ts +101 -0
  42. package/src/modules/webhooks/api/webhooks/[id]/route.ts +26 -0
  43. package/src/modules/webhooks/backend/webhooks/[id]/page.tsx +9 -2
  44. package/src/modules/webhooks/backend/webhooks/page.tsx +12 -1
  45. package/src/modules/webhooks/i18n/de.json +1 -0
  46. package/src/modules/webhooks/i18n/en.json +1 -0
  47. package/src/modules/webhooks/i18n/es.json +1 -0
  48. package/src/modules/webhooks/i18n/pl.json +1 -0
  49. package/src/modules/webhooks/subscribers/__tests__/outbound-dispatch.test.ts +31 -0
  50. package/src/modules/webhooks/subscribers/outbound-dispatch.ts +1 -1
@@ -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,12 +8,15 @@ 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'
14
16
  import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
15
17
  import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
16
18
  import { Alert, AlertDescription, AlertTitle } from '@open-mercato/ui/primitives/alert'
19
+ import { ListEmptyState } from '@open-mercato/ui/backend/filters/ListEmptyState'
17
20
  import { useWebhookFeatureAccess } from './useWebhookFeatureAccess'
18
21
 
19
22
  type Row = {
@@ -107,10 +110,11 @@ export default function WebhooksListPage() {
107
110
  try {
108
111
  const call = await apiCall<{ error?: string }>(
109
112
  `/api/webhooks/${encodeURIComponent(row.id)}`,
110
- { method: 'DELETE' },
113
+ { method: 'DELETE', headers: buildOptimisticLockHeader(row.updatedAt) },
111
114
  { fallback: null },
112
115
  )
113
116
  if (!call.ok) {
117
+ if (surfaceRecordConflict({ status: call.status, body: call.result }, t)) return
114
118
  const errorPayload = call.result as { error?: string } | undefined
115
119
  const message = typeof errorPayload?.error === 'string' ? errorPayload.error : t('webhooks.list.deleteError')
116
120
  flash(message, 'error')
@@ -276,6 +280,13 @@ export default function WebhooksListPage() {
276
280
 
277
281
  return <RowActions items={items} />
278
282
  }}
283
+ emptyState={(
284
+ <ListEmptyState
285
+ entityName={t('webhooks.list.title')}
286
+ createHref={access.canManage ? '/backend/webhooks/create' : undefined}
287
+ createLabel={access.canManage ? t('webhooks.nav.create') : undefined}
288
+ />
289
+ )}
279
290
  pagination={{ page, pageSize: 20, total, totalPages, onPageChange: setPage }}
280
291
  isLoading={isLoading}
281
292
  />
@@ -29,6 +29,7 @@
29
29
  "webhooks.detail.actions.deactivate": "Deactivate",
30
30
  "webhooks.detail.actions.rotateSecret": "Rotate Secret",
31
31
  "webhooks.detail.actions.test": "Send Test",
32
+ "webhooks.detail.backToList": "Zurück zu Webhooks",
32
33
  "webhooks.detail.consecutiveFailures": "Consecutive Failures",
33
34
  "webhooks.detail.deliveryTip": "Start with a test delivery before enabling a broad event pattern. The delivery log below shows exactly what your consumer returned.",
34
35
  "webhooks.detail.loadError": "Failed to load webhook.",
@@ -29,6 +29,7 @@
29
29
  "webhooks.detail.actions.deactivate": "Deactivate",
30
30
  "webhooks.detail.actions.rotateSecret": "Rotate Secret",
31
31
  "webhooks.detail.actions.test": "Send Test",
32
+ "webhooks.detail.backToList": "Back to webhooks",
32
33
  "webhooks.detail.consecutiveFailures": "Consecutive Failures",
33
34
  "webhooks.detail.deliveryTip": "Start with a test delivery before enabling a broad event pattern. The delivery log below shows exactly what your consumer returned.",
34
35
  "webhooks.detail.loadError": "Failed to load webhook.",
@@ -29,6 +29,7 @@
29
29
  "webhooks.detail.actions.deactivate": "Deactivate",
30
30
  "webhooks.detail.actions.rotateSecret": "Rotate Secret",
31
31
  "webhooks.detail.actions.test": "Send Test",
32
+ "webhooks.detail.backToList": "Volver a webhooks",
32
33
  "webhooks.detail.consecutiveFailures": "Consecutive Failures",
33
34
  "webhooks.detail.deliveryTip": "Start with a test delivery before enabling a broad event pattern. The delivery log below shows exactly what your consumer returned.",
34
35
  "webhooks.detail.loadError": "Failed to load webhook.",
@@ -29,6 +29,7 @@
29
29
  "webhooks.detail.actions.deactivate": "Deactivate",
30
30
  "webhooks.detail.actions.rotateSecret": "Rotate Secret",
31
31
  "webhooks.detail.actions.test": "Send Test",
32
+ "webhooks.detail.backToList": "Wróć do webhooków",
32
33
  "webhooks.detail.consecutiveFailures": "Consecutive Failures",
33
34
  "webhooks.detail.deliveryTip": "Start with a test delivery before enabling a broad event pattern. The delivery log below shows exactly what your consumer returned.",
34
35
  "webhooks.detail.loadError": "Failed to load webhook.",
@@ -102,6 +102,37 @@ describe('webhooks outbound dispatch subscriber', () => {
102
102
  })
103
103
  })
104
104
 
105
+ it('normalizes a missing organizationId to null in the decryption scope', async () => {
106
+ const { rootEm } = createDispatchEntityManagers()
107
+
108
+ ;(findWithDecryption as jest.Mock).mockResolvedValue([])
109
+
110
+ await handler(
111
+ {
112
+ id: 'product-1',
113
+ tenantId: 'tenant-1',
114
+ },
115
+ {
116
+ eventName: 'catalog.product.deleted',
117
+ resolve: <T,>(name: string): T => {
118
+ if (name === 'em') return rootEm as T
119
+ throw new Error(`Unexpected dependency: ${name}`)
120
+ },
121
+ },
122
+ )
123
+
124
+ expect(findWithDecryption).toHaveBeenCalledWith(
125
+ expect.anything(),
126
+ expect.anything(),
127
+ expect.objectContaining({ tenantId: 'tenant-1' }),
128
+ expect.anything(),
129
+ { tenantId: 'tenant-1', organizationId: null },
130
+ )
131
+ const decryptionScope = (findWithDecryption as jest.Mock).mock.calls[0][4]
132
+ expect(decryptionScope.organizationId).toBeNull()
133
+ expect(decryptionScope.organizationId).not.toBe('')
134
+ })
135
+
105
136
  it('checks integration state once per organization when multiple webhooks match', async () => {
106
137
  const { rootEm, handlerEm } = createDispatchEntityManagers()
107
138
 
@@ -67,7 +67,7 @@ export default async function handler(
67
67
  ...(organizationId ? { organizationId } : {}),
68
68
  },
69
69
  {},
70
- { tenantId, organizationId: organizationId ?? '' },
70
+ { tenantId, organizationId: organizationId ?? null },
71
71
  )
72
72
 
73
73
  if (!webhooks.length) return