@open-mercato/webhooks 0.6.5-develop.4788.1.a3e2520ec0 → 0.6.5-develop.4838.1.115785d8d7

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.
@@ -1,2 +1,2 @@
1
- [build:webhooks] found 66 entry points
1
+ [build:webhooks] found 67 entry points
2
2
  [build:webhooks] built successfully
@@ -0,0 +1,95 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
3
+ import { readJsonSafe } from "@open-mercato/core/helpers/integration/generalFixtures";
4
+ import {
5
+ runCrudFormRoundTrip,
6
+ skipIfCrudFormExtensionTestsDisabled
7
+ } from "@open-mercato/core/helpers/integration/crudFormPersistence";
8
+ const WEBHOOKS_PATH = "/api/webhooks";
9
+ async function readWebhookById(request, token, id) {
10
+ const response = await apiRequest(request, "GET", `${WEBHOOKS_PATH}/${encodeURIComponent(id)}`, { token });
11
+ if (response.status() === 404) return null;
12
+ expect(response.status(), `read-back webhook failed: ${response.status()}`).toBe(200);
13
+ const body = await readJsonSafe(response);
14
+ return body && body.id === id ? body : null;
15
+ }
16
+ test.describe("TC-WH-CRUDFORM-001: Webhook CrudForm persists scalars, events multiselect + headers JSON", () => {
17
+ test.beforeAll(() => {
18
+ skipIfCrudFormExtensionTestsDisabled();
19
+ });
20
+ test("round-trips scalars, subscribedEvents array, and customHeaders JSON on create and update", async ({ request }) => {
21
+ const token = await getAuthToken(request, "admin");
22
+ const stamp = Date.now();
23
+ const createEvents = ["catalog.product.created", "catalog.product.updated"];
24
+ const updateEvents = ["sales.quote.created", "catalog.product.deleted", "sales.order.placed"];
25
+ const createHeaders = { "X-Source": "om-qa", "X-Trace": `crudform-${stamp}` };
26
+ const updateHeaders = { "X-Env": "integration", "X-Rotated": "true" };
27
+ await runCrudFormRoundTrip({
28
+ request,
29
+ token,
30
+ collectionPath: WEBHOOKS_PATH,
31
+ recordPath: (id) => `${WEBHOOKS_PATH}/${encodeURIComponent(id)}`,
32
+ readById: (id) => readWebhookById(request, token, id),
33
+ create: {
34
+ payload: {
35
+ name: `QA CRUDFORM Webhook ${stamp}`,
36
+ description: "Original webhook description",
37
+ url: `https://example.com/hooks/crudform-${stamp}`,
38
+ subscribedEvents: createEvents,
39
+ httpMethod: "POST",
40
+ maxRetries: 3,
41
+ timeoutMs: 5e3,
42
+ rateLimitPerMinute: 0,
43
+ autoDisableThreshold: 5,
44
+ customHeaders: createHeaders
45
+ }
46
+ },
47
+ expectAfterCreate: {
48
+ scalars: {
49
+ name: `QA CRUDFORM Webhook ${stamp}`,
50
+ description: "Original webhook description",
51
+ url: `https://example.com/hooks/crudform-${stamp}`,
52
+ subscribedEvents: createEvents,
53
+ httpMethod: "POST",
54
+ isActive: true,
55
+ maxRetries: 3,
56
+ timeoutMs: 5e3,
57
+ rateLimitPerMinute: 0,
58
+ autoDisableThreshold: 5,
59
+ customHeaders: createHeaders
60
+ }
61
+ },
62
+ update: {
63
+ payload: () => ({
64
+ name: `QA CRUDFORM Webhook ${stamp} EDITED`,
65
+ description: "Updated webhook description",
66
+ url: `https://example.com/hooks/crudform-${stamp}-edited`,
67
+ subscribedEvents: updateEvents,
68
+ httpMethod: "PUT",
69
+ isActive: false,
70
+ maxRetries: 7,
71
+ timeoutMs: 2e4,
72
+ rateLimitPerMinute: 120,
73
+ autoDisableThreshold: 25,
74
+ customHeaders: updateHeaders
75
+ })
76
+ },
77
+ expectAfterUpdate: {
78
+ scalars: {
79
+ name: `QA CRUDFORM Webhook ${stamp} EDITED`,
80
+ description: "Updated webhook description",
81
+ url: `https://example.com/hooks/crudform-${stamp}-edited`,
82
+ subscribedEvents: updateEvents,
83
+ httpMethod: "PUT",
84
+ isActive: false,
85
+ maxRetries: 7,
86
+ timeoutMs: 2e4,
87
+ rateLimitPerMinute: 120,
88
+ autoDisableThreshold: 25,
89
+ customHeaders: updateHeaders
90
+ }
91
+ }
92
+ });
93
+ });
94
+ });
95
+ //# sourceMappingURL=TC-WH-CRUDFORM-001.spec.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/webhooks/__integration__/TC-WH-CRUDFORM-001.spec.ts"],
4
+ "sourcesContent": ["import { expect, test, type APIRequestContext } from '@playwright/test';\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api';\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures';\nimport {\n runCrudFormRoundTrip,\n skipIfCrudFormExtensionTestsDisabled,\n type CrudRecord,\n} from '@open-mercato/core/helpers/integration/crudFormPersistence';\n\n/**\n * TC-WH-CRUDFORM-001: Webhook CrudForm persists scalars, the events multiselect, and the\n * custom-headers JSON object (#2466, Tier B \u2014 hand-written / non-makeCrud saves).\n *\n * Webhooks is the first hand-written surface in the sweep. The backend CrudForm\n * (`backend/webhooks`) submits through the canonical, hand-written routes \u2014 `POST /api/webhooks`,\n * `PUT /api/webhooks/:id`, `DELETE /api/webhooks/:id` \u2014 NOT the makeCrud `/api/webhooks/webhooks`\n * alias, so this spec drives those exact endpoints via the harness `recordPath` seam to prove the\n * real form-submit path persists every field.\n *\n * Verified contract:\n * - Responses are camelCase (hand-written serializer), so scalar assertions use camelCase keys.\n * - `subscribedEvents` is a multiselect string[] (the form `tags` field); `customHeaders` is a JSON\n * object (the form `JsonBuilder`). Both round-trip through deep equality.\n * - `PUT /api/webhooks/:id` is a partial patch; the spec sends the full field set on update.\n * - Read-back uses the detail GET `/api/webhooks/:id` \u2014 the list GET omits `customHeaders` and has no\n * `id` filter. Cleanup soft-deletes via `DELETE /api/webhooks/:id` in the harness `finally`.\n * - The webhook entity has no custom fields, so the rich coverage here is the multiselect + JSON.\n * - Self-contained: a single webhook fixture, created and deleted within the round-trip.\n *\n * Gated by `OM_INTEGRATION_CRUDFORM_EXTENSION_TESTS_DISABLED` (default off \u2192 runs).\n */\nconst WEBHOOKS_PATH = '/api/webhooks';\n\nasync function readWebhookById(\n request: APIRequestContext,\n token: string,\n id: string,\n): Promise<CrudRecord | null> {\n const response = await apiRequest(request, 'GET', `${WEBHOOKS_PATH}/${encodeURIComponent(id)}`, { token });\n if (response.status() === 404) return null;\n expect(response.status(), `read-back webhook failed: ${response.status()}`).toBe(200);\n const body = await readJsonSafe<CrudRecord>(response);\n return body && body.id === id ? body : null;\n}\n\ntest.describe('TC-WH-CRUDFORM-001: Webhook CrudForm persists scalars, events multiselect + headers JSON', () => {\n test.beforeAll(() => {\n skipIfCrudFormExtensionTestsDisabled();\n });\n\n test('round-trips scalars, subscribedEvents array, and customHeaders JSON on create and update', async ({ request }) => {\n const token = await getAuthToken(request, 'admin');\n const stamp = Date.now();\n const createEvents = ['catalog.product.created', 'catalog.product.updated'];\n const updateEvents = ['sales.quote.created', 'catalog.product.deleted', 'sales.order.placed'];\n const createHeaders = { 'X-Source': 'om-qa', 'X-Trace': `crudform-${stamp}` };\n const updateHeaders = { 'X-Env': 'integration', 'X-Rotated': 'true' };\n\n await runCrudFormRoundTrip({\n request,\n token,\n collectionPath: WEBHOOKS_PATH,\n recordPath: (id) => `${WEBHOOKS_PATH}/${encodeURIComponent(id)}`,\n readById: (id) => readWebhookById(request, token, id),\n create: {\n payload: {\n name: `QA CRUDFORM Webhook ${stamp}`,\n description: 'Original webhook description',\n url: `https://example.com/hooks/crudform-${stamp}`,\n subscribedEvents: createEvents,\n httpMethod: 'POST',\n maxRetries: 3,\n timeoutMs: 5000,\n rateLimitPerMinute: 0,\n autoDisableThreshold: 5,\n customHeaders: createHeaders,\n },\n },\n expectAfterCreate: {\n scalars: {\n name: `QA CRUDFORM Webhook ${stamp}`,\n description: 'Original webhook description',\n url: `https://example.com/hooks/crudform-${stamp}`,\n subscribedEvents: createEvents,\n httpMethod: 'POST',\n isActive: true,\n maxRetries: 3,\n timeoutMs: 5000,\n rateLimitPerMinute: 0,\n autoDisableThreshold: 5,\n customHeaders: createHeaders,\n },\n },\n update: {\n payload: () => ({\n name: `QA CRUDFORM Webhook ${stamp} EDITED`,\n description: 'Updated webhook description',\n url: `https://example.com/hooks/crudform-${stamp}-edited`,\n subscribedEvents: updateEvents,\n httpMethod: 'PUT',\n isActive: false,\n maxRetries: 7,\n timeoutMs: 20000,\n rateLimitPerMinute: 120,\n autoDisableThreshold: 25,\n customHeaders: updateHeaders,\n }),\n },\n expectAfterUpdate: {\n scalars: {\n name: `QA CRUDFORM Webhook ${stamp} EDITED`,\n description: 'Updated webhook description',\n url: `https://example.com/hooks/crudform-${stamp}-edited`,\n subscribedEvents: updateEvents,\n httpMethod: 'PUT',\n isActive: false,\n maxRetries: 7,\n timeoutMs: 20000,\n rateLimitPerMinute: 120,\n autoDisableThreshold: 25,\n customHeaders: updateHeaders,\n },\n },\n });\n });\n});\n"],
5
+ "mappings": "AAAA,SAAS,QAAQ,YAAoC;AACrD,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAC7B;AAAA,EACE;AAAA,EACA;AAAA,OAEK;AAwBP,MAAM,gBAAgB;AAEtB,eAAe,gBACb,SACA,OACA,IAC4B;AAC5B,QAAM,WAAW,MAAM,WAAW,SAAS,OAAO,GAAG,aAAa,IAAI,mBAAmB,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC;AACzG,MAAI,SAAS,OAAO,MAAM,IAAK,QAAO;AACtC,SAAO,SAAS,OAAO,GAAG,6BAA6B,SAAS,OAAO,CAAC,EAAE,EAAE,KAAK,GAAG;AACpF,QAAM,OAAO,MAAM,aAAyB,QAAQ;AACpD,SAAO,QAAQ,KAAK,OAAO,KAAK,OAAO;AACzC;AAEA,KAAK,SAAS,4FAA4F,MAAM;AAC9G,OAAK,UAAU,MAAM;AACnB,yCAAqC;AAAA,EACvC,CAAC;AAED,OAAK,4FAA4F,OAAO,EAAE,QAAQ,MAAM;AACtH,UAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AACjD,UAAM,QAAQ,KAAK,IAAI;AACvB,UAAM,eAAe,CAAC,2BAA2B,yBAAyB;AAC1E,UAAM,eAAe,CAAC,uBAAuB,2BAA2B,oBAAoB;AAC5F,UAAM,gBAAgB,EAAE,YAAY,SAAS,WAAW,YAAY,KAAK,GAAG;AAC5E,UAAM,gBAAgB,EAAE,SAAS,eAAe,aAAa,OAAO;AAEpE,UAAM,qBAAqB;AAAA,MACzB;AAAA,MACA;AAAA,MACA,gBAAgB;AAAA,MAChB,YAAY,CAAC,OAAO,GAAG,aAAa,IAAI,mBAAmB,EAAE,CAAC;AAAA,MAC9D,UAAU,CAAC,OAAO,gBAAgB,SAAS,OAAO,EAAE;AAAA,MACpD,QAAQ;AAAA,QACN,SAAS;AAAA,UACP,MAAM,uBAAuB,KAAK;AAAA,UAClC,aAAa;AAAA,UACb,KAAK,sCAAsC,KAAK;AAAA,UAChD,kBAAkB;AAAA,UAClB,YAAY;AAAA,UACZ,YAAY;AAAA,UACZ,WAAW;AAAA,UACX,oBAAoB;AAAA,UACpB,sBAAsB;AAAA,UACtB,eAAe;AAAA,QACjB;AAAA,MACF;AAAA,MACA,mBAAmB;AAAA,QACjB,SAAS;AAAA,UACP,MAAM,uBAAuB,KAAK;AAAA,UAClC,aAAa;AAAA,UACb,KAAK,sCAAsC,KAAK;AAAA,UAChD,kBAAkB;AAAA,UAClB,YAAY;AAAA,UACZ,UAAU;AAAA,UACV,YAAY;AAAA,UACZ,WAAW;AAAA,UACX,oBAAoB;AAAA,UACpB,sBAAsB;AAAA,UACtB,eAAe;AAAA,QACjB;AAAA,MACF;AAAA,MACA,QAAQ;AAAA,QACN,SAAS,OAAO;AAAA,UACd,MAAM,uBAAuB,KAAK;AAAA,UAClC,aAAa;AAAA,UACb,KAAK,sCAAsC,KAAK;AAAA,UAChD,kBAAkB;AAAA,UAClB,YAAY;AAAA,UACZ,UAAU;AAAA,UACV,YAAY;AAAA,UACZ,WAAW;AAAA,UACX,oBAAoB;AAAA,UACpB,sBAAsB;AAAA,UACtB,eAAe;AAAA,QACjB;AAAA,MACF;AAAA,MACA,mBAAmB;AAAA,QACjB,SAAS;AAAA,UACP,MAAM,uBAAuB,KAAK;AAAA,UAClC,aAAa;AAAA,UACb,KAAK,sCAAsC,KAAK;AAAA,UAChD,kBAAkB;AAAA,UAClB,YAAY;AAAA,UACZ,UAAU;AAAA,UACV,YAAY;AAAA,UACZ,WAAW;AAAA,UACX,oBAAoB;AAAA,UACpB,sBAAsB;AAAA,UACtB,eAAe;AAAA,QACjB;AAAA,MACF;AAAA,IACF,CAAC;AAAA,EACH,CAAC;AACH,CAAC;",
6
+ "names": []
7
+ }
@@ -15,6 +15,7 @@ import { useOrganizationScopeVersion } from "@open-mercato/shared/lib/frontend/u
15
15
  import { useT } from "@open-mercato/shared/lib/i18n/context";
16
16
  import { useConfirmDialog } from "@open-mercato/ui/backend/confirm-dialog";
17
17
  import { Alert, AlertDescription, AlertTitle } from "@open-mercato/ui/primitives/alert";
18
+ import { ListEmptyState } from "@open-mercato/ui/backend/filters/ListEmptyState";
18
19
  import { useWebhookFeatureAccess } from "./useWebhookFeatureAccess.js";
19
20
  function WebhooksListPage() {
20
21
  const [rows, setRows] = React.useState([]);
@@ -245,6 +246,14 @@ function WebhooksListPage() {
245
246
  }
246
247
  return /* @__PURE__ */ jsx(RowActions, { items });
247
248
  },
249
+ emptyState: /* @__PURE__ */ jsx(
250
+ ListEmptyState,
251
+ {
252
+ entityName: t("webhooks.list.title"),
253
+ createHref: access.canManage ? "/backend/webhooks/create" : void 0,
254
+ createLabel: access.canManage ? t("webhooks.nav.create") : void 0
255
+ }
256
+ ),
248
257
  pagination: { page, pageSize: 20, total, totalPages, onPageChange: setPage },
249
258
  isLoading
250
259
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/webhooks/backend/webhooks/page.tsx"],
4
- "sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport Link from 'next/link'\nimport { useRouter } from 'next/navigation'\nimport { Page, PageBody } from '@open-mercato/ui/backend/Page'\nimport { DataTable } from '@open-mercato/ui/backend/DataTable'\nimport type { ColumnDef } from '@tanstack/react-table'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { RowActions } from '@open-mercato/ui/backend/RowActions'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'\nimport { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'\nimport type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'\nimport { Alert, AlertDescription, AlertTitle } from '@open-mercato/ui/primitives/alert'\nimport { useWebhookFeatureAccess } from './useWebhookFeatureAccess'\n\ntype Row = {\n id: string\n name: string\n description: string | null\n url: string\n subscribedEvents: string[]\n httpMethod: string\n isActive: boolean\n deliveryStrategy: string\n maxRetries: number\n consecutiveFailures: number\n lastSuccessAt: string | null\n lastFailureAt: string | null\n createdAt: string\n updatedAt: string\n}\n\ntype ResponsePayload = {\n items: Row[]\n total: number\n page: number\n totalPages: number\n}\n\nexport default function WebhooksListPage() {\n const [rows, setRows] = React.useState<Row[]>([])\n const [page, setPage] = React.useState(1)\n const [total, setTotal] = React.useState(0)\n const [totalPages, setTotalPages] = React.useState(1)\n const [search, setSearch] = React.useState('')\n const [filterValues, setFilterValues] = React.useState<FilterValues>({})\n const [isLoading, setIsLoading] = React.useState(true)\n const [reloadToken, setReloadToken] = React.useState(0)\n const scopeVersion = useOrganizationScopeVersion()\n const t = useT()\n const router = useRouter()\n const { confirm, ConfirmDialogElement } = useConfirmDialog()\n const access = useWebhookFeatureAccess()\n\n React.useEffect(() => {\n let cancelled = false\n async function load() {\n setIsLoading(true)\n try {\n const params = new URLSearchParams()\n params.set('page', String(page))\n params.set('pageSize', '20')\n if (search) params.set('search', search)\n if (typeof filterValues.status === 'string' && filterValues.status.length > 0) {\n params.set('isActive', filterValues.status)\n }\n const fallback: ResponsePayload = { items: [], total: 0, page, totalPages: 1 }\n const call = await apiCall<ResponsePayload>(\n `/api/webhooks?${params.toString()}`,\n undefined,\n { fallback },\n )\n if (!call.ok) {\n const errorPayload = call.result as { error?: string } | undefined\n const message = typeof errorPayload?.error === 'string' ? errorPayload.error : t('webhooks.list.loadError')\n flash(message, 'error')\n return\n }\n const payload = call.result ?? fallback\n if (!cancelled) {\n setRows(Array.isArray(payload.items) ? payload.items : [])\n setTotal(payload.total || 0)\n setTotalPages(payload.totalPages || 1)\n }\n } catch (error) {\n if (!cancelled) {\n const message = error instanceof Error ? error.message : t('webhooks.list.loadError')\n flash(message, 'error')\n }\n } finally {\n if (!cancelled) setIsLoading(false)\n }\n }\n load()\n return () => { cancelled = true }\n }, [filterValues.status, page, search, reloadToken, scopeVersion, t])\n\n const handleDelete = React.useCallback(async (row: Row) => {\n const confirmed = await confirm({\n title: t('webhooks.list.confirmDelete'),\n variant: 'destructive',\n })\n if (!confirmed) return\n try {\n const call = await apiCall<{ error?: string }>(\n `/api/webhooks/${encodeURIComponent(row.id)}`,\n { method: 'DELETE', headers: buildOptimisticLockHeader(row.updatedAt) },\n { fallback: null },\n )\n if (!call.ok) {\n if (surfaceRecordConflict({ status: call.status, body: call.result }, t)) return\n const errorPayload = call.result as { error?: string } | undefined\n const message = typeof errorPayload?.error === 'string' ? errorPayload.error : t('webhooks.list.deleteError')\n flash(message, 'error')\n return\n }\n flash(t('webhooks.list.deleteSuccess'), 'success')\n setReloadToken((token) => token + 1)\n } catch (error) {\n const message = error instanceof Error ? error.message : t('webhooks.list.deleteError')\n flash(message, 'error')\n }\n }, [confirm, t])\n\n const handleToggleActive = React.useCallback(async (row: Row) => {\n try {\n const call = await apiCall<Row>(\n `/api/webhooks/${encodeURIComponent(row.id)}`,\n {\n method: 'PUT',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ isActive: !row.isActive }),\n },\n { fallback: null },\n )\n if (!call.ok) {\n flash(t('webhooks.form.updateError'), 'error')\n return\n }\n flash(t('webhooks.form.updateSuccess'), 'success')\n setReloadToken((token) => token + 1)\n } catch (error) {\n flash(error instanceof Error ? error.message : t('webhooks.form.updateError'), 'error')\n }\n }, [t])\n\n const filters = React.useMemo<FilterDef[]>(() => [\n {\n id: 'status',\n label: t('webhooks.list.filters.status'),\n type: 'select',\n options: [\n { value: 'true', label: t('webhooks.list.status.active') },\n { value: 'false', label: t('webhooks.list.status.inactive') },\n ],\n },\n ], [t])\n\n const columns = React.useMemo<ColumnDef<Row>[]>(() => [\n {\n accessorKey: 'name',\n header: t('webhooks.list.columns.name'),\n cell: ({ row }) => (\n <Link href={`/backend/webhooks/${row.original.id}`} className=\"font-medium text-primary hover:underline\">\n {row.original.name}\n </Link>\n ),\n },\n {\n accessorKey: 'url',\n header: t('webhooks.list.columns.url'),\n cell: ({ row }) => (\n <code className=\"text-xs truncate max-w-[200px] block\" title={row.original.url}>{row.original.url}</code>\n ),\n meta: { truncate: true, maxWidth: 250 },\n },\n {\n accessorKey: 'subscribedEvents',\n header: t('webhooks.list.columns.events'),\n cell: ({ row }) => {\n const events = row.original.subscribedEvents\n if (events.length === 0) return <span className=\"text-muted-foreground text-xs\">\u2014</span>\n if (events.length <= 2) return <span className=\"text-xs\">{events.join(', ')}</span>\n return <span className=\"text-xs\">{events.slice(0, 2).join(', ')} +{events.length - 2}</span>\n },\n },\n {\n accessorKey: 'isActive',\n header: t('webhooks.list.columns.status'),\n cell: ({ row }) => (\n <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${row.original.isActive ? 'bg-status-success-bg text-status-success-text' : 'bg-status-neutral-bg text-status-neutral-text'}`}>\n {row.original.isActive ? t('webhooks.list.status.active') : t('webhooks.list.status.inactive')}\n </span>\n ),\n },\n {\n accessorKey: 'lastSuccessAt',\n header: t('webhooks.list.columns.lastDelivery'),\n cell: ({ row }) => {\n const lastSuccessAt = row.original.lastSuccessAt ? new Date(row.original.lastSuccessAt).getTime() : null\n const lastFailureAt = row.original.lastFailureAt ? new Date(row.original.lastFailureAt).getTime() : null\n const lastTimestamp = Math.max(lastSuccessAt ?? 0, lastFailureAt ?? 0)\n if (!lastTimestamp) return <span className=\"text-muted-foreground\">\u2014</span>\n const isFailure = lastFailureAt !== null && lastFailureAt >= (lastSuccessAt ?? 0)\n return (\n <span className={isFailure ? 'text-destructive' : 'text-muted-foreground'}>\n {new Date(lastTimestamp).toLocaleString()}\n </span>\n )\n },\n },\n {\n accessorKey: 'createdAt',\n header: t('webhooks.list.columns.createdAt'),\n cell: ({ row }) => {\n try {\n return new Date(row.original.createdAt).toLocaleDateString()\n } catch {\n return '\u2014'\n }\n },\n },\n ], [t])\n\n return (\n <Page>\n <PageBody className=\"space-y-4\">\n <Alert variant=\"info\">\n <AlertTitle>{t('webhooks.list.description')}</AlertTitle>\n <AlertDescription>{t('webhooks.list.operatorTip')}</AlertDescription>\n </Alert>\n <DataTable\n title={t('webhooks.list.title')}\n actions={access.canManage ? (\n <Button asChild>\n <Link href=\"/backend/webhooks/create\">{t('webhooks.nav.create')}</Link>\n </Button>\n ) : undefined}\n columns={columns}\n data={rows}\n searchValue={search}\n onSearchChange={(value) => { setSearch(value); setPage(1) }}\n filters={filters}\n filterValues={filterValues}\n onFiltersApply={(next) => {\n setFilterValues(next)\n setPage(1)\n }}\n onFiltersClear={() => {\n setFilterValues({})\n setPage(1)\n }}\n perspective={{ tableId: 'webhooks.list' }}\n rowActions={(row) => {\n const items = [\n {\n id: 'view-deliveries',\n label: t('webhooks.list.actions.viewDeliveries'),\n onSelect: () => { router.push(`/backend/webhooks/${row.id}`) },\n },\n ]\n\n if (access.canManage) {\n items.unshift(\n { id: 'edit', label: t('webhooks.list.actions.edit'), onSelect: () => { router.push(`/backend/webhooks/${row.id}`) } },\n {\n id: 'toggle-active',\n label: row.isActive ? t('webhooks.detail.actions.deactivate') : t('webhooks.detail.actions.activate'),\n onSelect: () => { void handleToggleActive(row) },\n },\n )\n items.push({ id: 'delete', label: t('webhooks.list.actions.delete'), onSelect: () => { void handleDelete(row) } })\n }\n\n return <RowActions items={items} />\n }}\n pagination={{ page, pageSize: 20, total, totalPages, onPageChange: setPage }}\n isLoading={isLoading}\n />\n </PageBody>\n {ConfirmDialogElement}\n </Page>\n )\n}\n"],
5
- "mappings": ";AAwKQ,cAoBO,YApBP;AAvKR,YAAY,WAAW;AACvB,OAAO,UAAU;AACjB,SAAS,iBAAiB;AAC1B,SAAS,MAAM,gBAAgB;AAC/B,SAAS,iBAAiB;AAE1B,SAAS,cAAc;AACvB,SAAS,kBAAkB;AAC3B,SAAS,eAAe;AACxB,SAAS,iCAAiC;AAC1C,SAAS,6BAA6B;AACtC,SAAS,aAAa;AACtB,SAAS,mCAAmC;AAC5C,SAAS,YAAY;AACrB,SAAS,wBAAwB;AAEjC,SAAS,OAAO,kBAAkB,kBAAkB;AACpD,SAAS,+BAA+B;AA0BzB,SAAR,mBAAoC;AACzC,QAAM,CAAC,MAAM,OAAO,IAAI,MAAM,SAAgB,CAAC,CAAC;AAChD,QAAM,CAAC,MAAM,OAAO,IAAI,MAAM,SAAS,CAAC;AACxC,QAAM,CAAC,OAAO,QAAQ,IAAI,MAAM,SAAS,CAAC;AAC1C,QAAM,CAAC,YAAY,aAAa,IAAI,MAAM,SAAS,CAAC;AACpD,QAAM,CAAC,QAAQ,SAAS,IAAI,MAAM,SAAS,EAAE;AAC7C,QAAM,CAAC,cAAc,eAAe,IAAI,MAAM,SAAuB,CAAC,CAAC;AACvE,QAAM,CAAC,WAAW,YAAY,IAAI,MAAM,SAAS,IAAI;AACrD,QAAM,CAAC,aAAa,cAAc,IAAI,MAAM,SAAS,CAAC;AACtD,QAAM,eAAe,4BAA4B;AACjD,QAAM,IAAI,KAAK;AACf,QAAM,SAAS,UAAU;AACzB,QAAM,EAAE,SAAS,qBAAqB,IAAI,iBAAiB;AAC3D,QAAM,SAAS,wBAAwB;AAEvC,QAAM,UAAU,MAAM;AACpB,QAAI,YAAY;AAChB,mBAAe,OAAO;AACpB,mBAAa,IAAI;AACjB,UAAI;AACF,cAAM,SAAS,IAAI,gBAAgB;AACnC,eAAO,IAAI,QAAQ,OAAO,IAAI,CAAC;AAC/B,eAAO,IAAI,YAAY,IAAI;AAC3B,YAAI,OAAQ,QAAO,IAAI,UAAU,MAAM;AACvC,YAAI,OAAO,aAAa,WAAW,YAAY,aAAa,OAAO,SAAS,GAAG;AAC7E,iBAAO,IAAI,YAAY,aAAa,MAAM;AAAA,QAC5C;AACA,cAAM,WAA4B,EAAE,OAAO,CAAC,GAAG,OAAO,GAAG,MAAM,YAAY,EAAE;AAC7E,cAAM,OAAO,MAAM;AAAA,UACjB,iBAAiB,OAAO,SAAS,CAAC;AAAA,UAClC;AAAA,UACA,EAAE,SAAS;AAAA,QACb;AACA,YAAI,CAAC,KAAK,IAAI;AACZ,gBAAM,eAAe,KAAK;AAC1B,gBAAM,UAAU,OAAO,cAAc,UAAU,WAAW,aAAa,QAAQ,EAAE,yBAAyB;AAC1G,gBAAM,SAAS,OAAO;AACtB;AAAA,QACF;AACA,cAAM,UAAU,KAAK,UAAU;AAC/B,YAAI,CAAC,WAAW;AACd,kBAAQ,MAAM,QAAQ,QAAQ,KAAK,IAAI,QAAQ,QAAQ,CAAC,CAAC;AACzD,mBAAS,QAAQ,SAAS,CAAC;AAC3B,wBAAc,QAAQ,cAAc,CAAC;AAAA,QACvC;AAAA,MACF,SAAS,OAAO;AACd,YAAI,CAAC,WAAW;AACd,gBAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,EAAE,yBAAyB;AACpF,gBAAM,SAAS,OAAO;AAAA,QACxB;AAAA,MACF,UAAE;AACA,YAAI,CAAC,UAAW,cAAa,KAAK;AAAA,MACpC;AAAA,IACF;AACA,SAAK;AACL,WAAO,MAAM;AAAE,kBAAY;AAAA,IAAK;AAAA,EAClC,GAAG,CAAC,aAAa,QAAQ,MAAM,QAAQ,aAAa,cAAc,CAAC,CAAC;AAEpE,QAAM,eAAe,MAAM,YAAY,OAAO,QAAa;AACzD,UAAM,YAAY,MAAM,QAAQ;AAAA,MAC9B,OAAO,EAAE,6BAA6B;AAAA,MACtC,SAAS;AAAA,IACX,CAAC;AACD,QAAI,CAAC,UAAW;AAChB,QAAI;AACF,YAAM,OAAO,MAAM;AAAA,QACjB,iBAAiB,mBAAmB,IAAI,EAAE,CAAC;AAAA,QAC3C,EAAE,QAAQ,UAAU,SAAS,0BAA0B,IAAI,SAAS,EAAE;AAAA,QACtE,EAAE,UAAU,KAAK;AAAA,MACnB;AACA,UAAI,CAAC,KAAK,IAAI;AACZ,YAAI,sBAAsB,EAAE,QAAQ,KAAK,QAAQ,MAAM,KAAK,OAAO,GAAG,CAAC,EAAG;AAC1E,cAAM,eAAe,KAAK;AAC1B,cAAM,UAAU,OAAO,cAAc,UAAU,WAAW,aAAa,QAAQ,EAAE,2BAA2B;AAC5G,cAAM,SAAS,OAAO;AACtB;AAAA,MACF;AACA,YAAM,EAAE,6BAA6B,GAAG,SAAS;AACjD,qBAAe,CAAC,UAAU,QAAQ,CAAC;AAAA,IACrC,SAAS,OAAO;AACd,YAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,EAAE,2BAA2B;AACtF,YAAM,SAAS,OAAO;AAAA,IACxB;AAAA,EACF,GAAG,CAAC,SAAS,CAAC,CAAC;AAEf,QAAM,qBAAqB,MAAM,YAAY,OAAO,QAAa;AAC/D,QAAI;AACF,YAAM,OAAO,MAAM;AAAA,QACjB,iBAAiB,mBAAmB,IAAI,EAAE,CAAC;AAAA,QAC3C;AAAA,UACE,QAAQ;AAAA,UACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,UAC9C,MAAM,KAAK,UAAU,EAAE,UAAU,CAAC,IAAI,SAAS,CAAC;AAAA,QAClD;AAAA,QACA,EAAE,UAAU,KAAK;AAAA,MACnB;AACA,UAAI,CAAC,KAAK,IAAI;AACZ,cAAM,EAAE,2BAA2B,GAAG,OAAO;AAC7C;AAAA,MACF;AACA,YAAM,EAAE,6BAA6B,GAAG,SAAS;AACjD,qBAAe,CAAC,UAAU,QAAQ,CAAC;AAAA,IACrC,SAAS,OAAO;AACd,YAAM,iBAAiB,QAAQ,MAAM,UAAU,EAAE,2BAA2B,GAAG,OAAO;AAAA,IACxF;AAAA,EACF,GAAG,CAAC,CAAC,CAAC;AAEN,QAAM,UAAU,MAAM,QAAqB,MAAM;AAAA,IAC/C;AAAA,MACE,IAAI;AAAA,MACJ,OAAO,EAAE,8BAA8B;AAAA,MACvC,MAAM;AAAA,MACN,SAAS;AAAA,QACP,EAAE,OAAO,QAAQ,OAAO,EAAE,6BAA6B,EAAE;AAAA,QACzD,EAAE,OAAO,SAAS,OAAO,EAAE,+BAA+B,EAAE;AAAA,MAC9D;AAAA,IACF;AAAA,EACF,GAAG,CAAC,CAAC,CAAC;AAEN,QAAM,UAAU,MAAM,QAA0B,MAAM;AAAA,IACpD;AAAA,MACE,aAAa;AAAA,MACb,QAAQ,EAAE,4BAA4B;AAAA,MACtC,MAAM,CAAC,EAAE,IAAI,MACX,oBAAC,QAAK,MAAM,qBAAqB,IAAI,SAAS,EAAE,IAAI,WAAU,4CAC3D,cAAI,SAAS,MAChB;AAAA,IAEJ;AAAA,IACA;AAAA,MACE,aAAa;AAAA,MACb,QAAQ,EAAE,2BAA2B;AAAA,MACrC,MAAM,CAAC,EAAE,IAAI,MACX,oBAAC,UAAK,WAAU,wCAAuC,OAAO,IAAI,SAAS,KAAM,cAAI,SAAS,KAAI;AAAA,MAEpG,MAAM,EAAE,UAAU,MAAM,UAAU,IAAI;AAAA,IACxC;AAAA,IACA;AAAA,MACE,aAAa;AAAA,MACb,QAAQ,EAAE,8BAA8B;AAAA,MACxC,MAAM,CAAC,EAAE,IAAI,MAAM;AACjB,cAAM,SAAS,IAAI,SAAS;AAC5B,YAAI,OAAO,WAAW,EAAG,QAAO,oBAAC,UAAK,WAAU,iCAAgC,oBAAC;AACjF,YAAI,OAAO,UAAU,EAAG,QAAO,oBAAC,UAAK,WAAU,WAAW,iBAAO,KAAK,IAAI,GAAE;AAC5E,eAAO,qBAAC,UAAK,WAAU,WAAW;AAAA,iBAAO,MAAM,GAAG,CAAC,EAAE,KAAK,IAAI;AAAA,UAAE;AAAA,UAAG,OAAO,SAAS;AAAA,WAAE;AAAA,MACvF;AAAA,IACF;AAAA,IACA;AAAA,MACE,aAAa;AAAA,MACb,QAAQ,EAAE,8BAA8B;AAAA,MACxC,MAAM,CAAC,EAAE,IAAI,MACX,oBAAC,UAAK,WAAW,yEAAyE,IAAI,SAAS,WAAW,kDAAkD,+CAA+C,IAChN,cAAI,SAAS,WAAW,EAAE,6BAA6B,IAAI,EAAE,+BAA+B,GAC/F;AAAA,IAEJ;AAAA,IACA;AAAA,MACE,aAAa;AAAA,MACb,QAAQ,EAAE,oCAAoC;AAAA,MAC9C,MAAM,CAAC,EAAE,IAAI,MAAM;AACjB,cAAM,gBAAgB,IAAI,SAAS,gBAAgB,IAAI,KAAK,IAAI,SAAS,aAAa,EAAE,QAAQ,IAAI;AACpG,cAAM,gBAAgB,IAAI,SAAS,gBAAgB,IAAI,KAAK,IAAI,SAAS,aAAa,EAAE,QAAQ,IAAI;AACpG,cAAM,gBAAgB,KAAK,IAAI,iBAAiB,GAAG,iBAAiB,CAAC;AACrE,YAAI,CAAC,cAAe,QAAO,oBAAC,UAAK,WAAU,yBAAwB,oBAAC;AACpE,cAAM,YAAY,kBAAkB,QAAQ,kBAAkB,iBAAiB;AAC/E,eACE,oBAAC,UAAK,WAAW,YAAY,qBAAqB,yBAC/C,cAAI,KAAK,aAAa,EAAE,eAAe,GAC1C;AAAA,MAEJ;AAAA,IACF;AAAA,IACA;AAAA,MACE,aAAa;AAAA,MACb,QAAQ,EAAE,iCAAiC;AAAA,MAC3C,MAAM,CAAC,EAAE,IAAI,MAAM;AACjB,YAAI;AACF,iBAAO,IAAI,KAAK,IAAI,SAAS,SAAS,EAAE,mBAAmB;AAAA,QAC7D,QAAQ;AACN,iBAAO;AAAA,QACT;AAAA,MACF;AAAA,IACF;AAAA,EACF,GAAG,CAAC,CAAC,CAAC;AAEN,SACE,qBAAC,QACC;AAAA,yBAAC,YAAS,WAAU,aAClB;AAAA,2BAAC,SAAM,SAAQ,QACb;AAAA,4BAAC,cAAY,YAAE,2BAA2B,GAAE;AAAA,QAC5C,oBAAC,oBAAkB,YAAE,2BAA2B,GAAE;AAAA,SACpD;AAAA,MACA;AAAA,QAAC;AAAA;AAAA,UACC,OAAO,EAAE,qBAAqB;AAAA,UAC9B,SAAS,OAAO,YACd,oBAAC,UAAO,SAAO,MACb,8BAAC,QAAK,MAAK,4BAA4B,YAAE,qBAAqB,GAAE,GAClE,IACE;AAAA,UACJ;AAAA,UACA,MAAM;AAAA,UACN,aAAa;AAAA,UACb,gBAAgB,CAAC,UAAU;AAAE,sBAAU,KAAK;AAAG,oBAAQ,CAAC;AAAA,UAAE;AAAA,UAC1D;AAAA,UACA;AAAA,UACA,gBAAgB,CAAC,SAAS;AACxB,4BAAgB,IAAI;AACpB,oBAAQ,CAAC;AAAA,UACX;AAAA,UACA,gBAAgB,MAAM;AACpB,4BAAgB,CAAC,CAAC;AAClB,oBAAQ,CAAC;AAAA,UACX;AAAA,UACA,aAAa,EAAE,SAAS,gBAAgB;AAAA,UACxC,YAAY,CAAC,QAAQ;AACnB,kBAAM,QAAQ;AAAA,cACZ;AAAA,gBACE,IAAI;AAAA,gBACJ,OAAO,EAAE,sCAAsC;AAAA,gBAC/C,UAAU,MAAM;AAAE,yBAAO,KAAK,qBAAqB,IAAI,EAAE,EAAE;AAAA,gBAAE;AAAA,cAC/D;AAAA,YACF;AAEA,gBAAI,OAAO,WAAW;AACpB,oBAAM;AAAA,gBACJ,EAAE,IAAI,QAAQ,OAAO,EAAE,4BAA4B,GAAG,UAAU,MAAM;AAAE,yBAAO,KAAK,qBAAqB,IAAI,EAAE,EAAE;AAAA,gBAAE,EAAE;AAAA,gBACrH;AAAA,kBACE,IAAI;AAAA,kBACJ,OAAO,IAAI,WAAW,EAAE,oCAAoC,IAAI,EAAE,kCAAkC;AAAA,kBACpG,UAAU,MAAM;AAAE,yBAAK,mBAAmB,GAAG;AAAA,kBAAE;AAAA,gBACjD;AAAA,cACF;AACA,oBAAM,KAAK,EAAE,IAAI,UAAU,OAAO,EAAE,8BAA8B,GAAG,UAAU,MAAM;AAAE,qBAAK,aAAa,GAAG;AAAA,cAAE,EAAE,CAAC;AAAA,YACnH;AAEA,mBAAO,oBAAC,cAAW,OAAc;AAAA,UACnC;AAAA,UACA,YAAY,EAAE,MAAM,UAAU,IAAI,OAAO,YAAY,cAAc,QAAQ;AAAA,UAC3E;AAAA;AAAA,MACF;AAAA,OACF;AAAA,IACC;AAAA,KACH;AAEJ;",
4
+ "sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport Link from 'next/link'\nimport { useRouter } from 'next/navigation'\nimport { Page, PageBody } from '@open-mercato/ui/backend/Page'\nimport { DataTable } from '@open-mercato/ui/backend/DataTable'\nimport type { ColumnDef } from '@tanstack/react-table'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { RowActions } from '@open-mercato/ui/backend/RowActions'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'\nimport { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'\nimport type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'\nimport { Alert, AlertDescription, AlertTitle } from '@open-mercato/ui/primitives/alert'\nimport { ListEmptyState } from '@open-mercato/ui/backend/filters/ListEmptyState'\nimport { useWebhookFeatureAccess } from './useWebhookFeatureAccess'\n\ntype Row = {\n id: string\n name: string\n description: string | null\n url: string\n subscribedEvents: string[]\n httpMethod: string\n isActive: boolean\n deliveryStrategy: string\n maxRetries: number\n consecutiveFailures: number\n lastSuccessAt: string | null\n lastFailureAt: string | null\n createdAt: string\n updatedAt: string\n}\n\ntype ResponsePayload = {\n items: Row[]\n total: number\n page: number\n totalPages: number\n}\n\nexport default function WebhooksListPage() {\n const [rows, setRows] = React.useState<Row[]>([])\n const [page, setPage] = React.useState(1)\n const [total, setTotal] = React.useState(0)\n const [totalPages, setTotalPages] = React.useState(1)\n const [search, setSearch] = React.useState('')\n const [filterValues, setFilterValues] = React.useState<FilterValues>({})\n const [isLoading, setIsLoading] = React.useState(true)\n const [reloadToken, setReloadToken] = React.useState(0)\n const scopeVersion = useOrganizationScopeVersion()\n const t = useT()\n const router = useRouter()\n const { confirm, ConfirmDialogElement } = useConfirmDialog()\n const access = useWebhookFeatureAccess()\n\n React.useEffect(() => {\n let cancelled = false\n async function load() {\n setIsLoading(true)\n try {\n const params = new URLSearchParams()\n params.set('page', String(page))\n params.set('pageSize', '20')\n if (search) params.set('search', search)\n if (typeof filterValues.status === 'string' && filterValues.status.length > 0) {\n params.set('isActive', filterValues.status)\n }\n const fallback: ResponsePayload = { items: [], total: 0, page, totalPages: 1 }\n const call = await apiCall<ResponsePayload>(\n `/api/webhooks?${params.toString()}`,\n undefined,\n { fallback },\n )\n if (!call.ok) {\n const errorPayload = call.result as { error?: string } | undefined\n const message = typeof errorPayload?.error === 'string' ? errorPayload.error : t('webhooks.list.loadError')\n flash(message, 'error')\n return\n }\n const payload = call.result ?? fallback\n if (!cancelled) {\n setRows(Array.isArray(payload.items) ? payload.items : [])\n setTotal(payload.total || 0)\n setTotalPages(payload.totalPages || 1)\n }\n } catch (error) {\n if (!cancelled) {\n const message = error instanceof Error ? error.message : t('webhooks.list.loadError')\n flash(message, 'error')\n }\n } finally {\n if (!cancelled) setIsLoading(false)\n }\n }\n load()\n return () => { cancelled = true }\n }, [filterValues.status, page, search, reloadToken, scopeVersion, t])\n\n const handleDelete = React.useCallback(async (row: Row) => {\n const confirmed = await confirm({\n title: t('webhooks.list.confirmDelete'),\n variant: 'destructive',\n })\n if (!confirmed) return\n try {\n const call = await apiCall<{ error?: string }>(\n `/api/webhooks/${encodeURIComponent(row.id)}`,\n { method: 'DELETE', headers: buildOptimisticLockHeader(row.updatedAt) },\n { fallback: null },\n )\n if (!call.ok) {\n if (surfaceRecordConflict({ status: call.status, body: call.result }, t)) return\n const errorPayload = call.result as { error?: string } | undefined\n const message = typeof errorPayload?.error === 'string' ? errorPayload.error : t('webhooks.list.deleteError')\n flash(message, 'error')\n return\n }\n flash(t('webhooks.list.deleteSuccess'), 'success')\n setReloadToken((token) => token + 1)\n } catch (error) {\n const message = error instanceof Error ? error.message : t('webhooks.list.deleteError')\n flash(message, 'error')\n }\n }, [confirm, t])\n\n const handleToggleActive = React.useCallback(async (row: Row) => {\n try {\n const call = await apiCall<Row>(\n `/api/webhooks/${encodeURIComponent(row.id)}`,\n {\n method: 'PUT',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ isActive: !row.isActive }),\n },\n { fallback: null },\n )\n if (!call.ok) {\n flash(t('webhooks.form.updateError'), 'error')\n return\n }\n flash(t('webhooks.form.updateSuccess'), 'success')\n setReloadToken((token) => token + 1)\n } catch (error) {\n flash(error instanceof Error ? error.message : t('webhooks.form.updateError'), 'error')\n }\n }, [t])\n\n const filters = React.useMemo<FilterDef[]>(() => [\n {\n id: 'status',\n label: t('webhooks.list.filters.status'),\n type: 'select',\n options: [\n { value: 'true', label: t('webhooks.list.status.active') },\n { value: 'false', label: t('webhooks.list.status.inactive') },\n ],\n },\n ], [t])\n\n const columns = React.useMemo<ColumnDef<Row>[]>(() => [\n {\n accessorKey: 'name',\n header: t('webhooks.list.columns.name'),\n cell: ({ row }) => (\n <Link href={`/backend/webhooks/${row.original.id}`} className=\"font-medium text-primary hover:underline\">\n {row.original.name}\n </Link>\n ),\n },\n {\n accessorKey: 'url',\n header: t('webhooks.list.columns.url'),\n cell: ({ row }) => (\n <code className=\"text-xs truncate max-w-[200px] block\" title={row.original.url}>{row.original.url}</code>\n ),\n meta: { truncate: true, maxWidth: 250 },\n },\n {\n accessorKey: 'subscribedEvents',\n header: t('webhooks.list.columns.events'),\n cell: ({ row }) => {\n const events = row.original.subscribedEvents\n if (events.length === 0) return <span className=\"text-muted-foreground text-xs\">\u2014</span>\n if (events.length <= 2) return <span className=\"text-xs\">{events.join(', ')}</span>\n return <span className=\"text-xs\">{events.slice(0, 2).join(', ')} +{events.length - 2}</span>\n },\n },\n {\n accessorKey: 'isActive',\n header: t('webhooks.list.columns.status'),\n cell: ({ row }) => (\n <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${row.original.isActive ? 'bg-status-success-bg text-status-success-text' : 'bg-status-neutral-bg text-status-neutral-text'}`}>\n {row.original.isActive ? t('webhooks.list.status.active') : t('webhooks.list.status.inactive')}\n </span>\n ),\n },\n {\n accessorKey: 'lastSuccessAt',\n header: t('webhooks.list.columns.lastDelivery'),\n cell: ({ row }) => {\n const lastSuccessAt = row.original.lastSuccessAt ? new Date(row.original.lastSuccessAt).getTime() : null\n const lastFailureAt = row.original.lastFailureAt ? new Date(row.original.lastFailureAt).getTime() : null\n const lastTimestamp = Math.max(lastSuccessAt ?? 0, lastFailureAt ?? 0)\n if (!lastTimestamp) return <span className=\"text-muted-foreground\">\u2014</span>\n const isFailure = lastFailureAt !== null && lastFailureAt >= (lastSuccessAt ?? 0)\n return (\n <span className={isFailure ? 'text-destructive' : 'text-muted-foreground'}>\n {new Date(lastTimestamp).toLocaleString()}\n </span>\n )\n },\n },\n {\n accessorKey: 'createdAt',\n header: t('webhooks.list.columns.createdAt'),\n cell: ({ row }) => {\n try {\n return new Date(row.original.createdAt).toLocaleDateString()\n } catch {\n return '\u2014'\n }\n },\n },\n ], [t])\n\n return (\n <Page>\n <PageBody className=\"space-y-4\">\n <Alert variant=\"info\">\n <AlertTitle>{t('webhooks.list.description')}</AlertTitle>\n <AlertDescription>{t('webhooks.list.operatorTip')}</AlertDescription>\n </Alert>\n <DataTable\n title={t('webhooks.list.title')}\n actions={access.canManage ? (\n <Button asChild>\n <Link href=\"/backend/webhooks/create\">{t('webhooks.nav.create')}</Link>\n </Button>\n ) : undefined}\n columns={columns}\n data={rows}\n searchValue={search}\n onSearchChange={(value) => { setSearch(value); setPage(1) }}\n filters={filters}\n filterValues={filterValues}\n onFiltersApply={(next) => {\n setFilterValues(next)\n setPage(1)\n }}\n onFiltersClear={() => {\n setFilterValues({})\n setPage(1)\n }}\n perspective={{ tableId: 'webhooks.list' }}\n rowActions={(row) => {\n const items = [\n {\n id: 'view-deliveries',\n label: t('webhooks.list.actions.viewDeliveries'),\n onSelect: () => { router.push(`/backend/webhooks/${row.id}`) },\n },\n ]\n\n if (access.canManage) {\n items.unshift(\n { id: 'edit', label: t('webhooks.list.actions.edit'), onSelect: () => { router.push(`/backend/webhooks/${row.id}`) } },\n {\n id: 'toggle-active',\n label: row.isActive ? t('webhooks.detail.actions.deactivate') : t('webhooks.detail.actions.activate'),\n onSelect: () => { void handleToggleActive(row) },\n },\n )\n items.push({ id: 'delete', label: t('webhooks.list.actions.delete'), onSelect: () => { void handleDelete(row) } })\n }\n\n return <RowActions items={items} />\n }}\n emptyState={(\n <ListEmptyState\n entityName={t('webhooks.list.title')}\n createHref={access.canManage ? '/backend/webhooks/create' : undefined}\n createLabel={access.canManage ? t('webhooks.nav.create') : undefined}\n />\n )}\n pagination={{ page, pageSize: 20, total, totalPages, onPageChange: setPage }}\n isLoading={isLoading}\n />\n </PageBody>\n {ConfirmDialogElement}\n </Page>\n )\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/webhooks",
3
- "version": "0.6.5-develop.4788.1.a3e2520ec0",
3
+ "version": "0.6.5-develop.4838.1.115785d8d7",
4
4
  "description": "Webhooks module for Open Mercato — Standard Webhooks compliant outbound/inbound delivery",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -69,19 +69,19 @@
69
69
  }
70
70
  },
71
71
  "dependencies": {
72
- "@open-mercato/core": "0.6.5-develop.4788.1.a3e2520ec0",
73
- "@open-mercato/queue": "0.6.5-develop.4788.1.a3e2520ec0",
74
- "@open-mercato/ui": "0.6.5-develop.4788.1.a3e2520ec0",
72
+ "@open-mercato/core": "0.6.5-develop.4838.1.115785d8d7",
73
+ "@open-mercato/queue": "0.6.5-develop.4838.1.115785d8d7",
74
+ "@open-mercato/ui": "0.6.5-develop.4838.1.115785d8d7",
75
75
  "svix": "^1.95.1"
76
76
  },
77
77
  "peerDependencies": {
78
78
  "@mikro-orm/postgresql": "^7.0.14",
79
- "@open-mercato/shared": "0.6.5-develop.4788.1.a3e2520ec0",
79
+ "@open-mercato/shared": "0.6.5-develop.4838.1.115785d8d7",
80
80
  "react": "^19.0.0",
81
81
  "react-dom": "^19.0.0"
82
82
  },
83
83
  "devDependencies": {
84
- "@open-mercato/shared": "0.6.5-develop.4788.1.a3e2520ec0",
84
+ "@open-mercato/shared": "0.6.5-develop.4838.1.115785d8d7",
85
85
  "@types/jest": "^30.0.0",
86
86
  "@types/react": "^19.2.16",
87
87
  "@types/react-dom": "^19.2.3",
@@ -0,0 +1,126 @@
1
+ import { expect, test, type APIRequestContext } from '@playwright/test';
2
+ import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api';
3
+ import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures';
4
+ import {
5
+ runCrudFormRoundTrip,
6
+ skipIfCrudFormExtensionTestsDisabled,
7
+ type CrudRecord,
8
+ } from '@open-mercato/core/helpers/integration/crudFormPersistence';
9
+
10
+ /**
11
+ * TC-WH-CRUDFORM-001: Webhook CrudForm persists scalars, the events multiselect, and the
12
+ * custom-headers JSON object (#2466, Tier B — hand-written / non-makeCrud saves).
13
+ *
14
+ * Webhooks is the first hand-written surface in the sweep. The backend CrudForm
15
+ * (`backend/webhooks`) submits through the canonical, hand-written routes — `POST /api/webhooks`,
16
+ * `PUT /api/webhooks/:id`, `DELETE /api/webhooks/:id` — NOT the makeCrud `/api/webhooks/webhooks`
17
+ * alias, so this spec drives those exact endpoints via the harness `recordPath` seam to prove the
18
+ * real form-submit path persists every field.
19
+ *
20
+ * Verified contract:
21
+ * - Responses are camelCase (hand-written serializer), so scalar assertions use camelCase keys.
22
+ * - `subscribedEvents` is a multiselect string[] (the form `tags` field); `customHeaders` is a JSON
23
+ * object (the form `JsonBuilder`). Both round-trip through deep equality.
24
+ * - `PUT /api/webhooks/:id` is a partial patch; the spec sends the full field set on update.
25
+ * - Read-back uses the detail GET `/api/webhooks/:id` — the list GET omits `customHeaders` and has no
26
+ * `id` filter. Cleanup soft-deletes via `DELETE /api/webhooks/:id` in the harness `finally`.
27
+ * - The webhook entity has no custom fields, so the rich coverage here is the multiselect + JSON.
28
+ * - Self-contained: a single webhook fixture, created and deleted within the round-trip.
29
+ *
30
+ * Gated by `OM_INTEGRATION_CRUDFORM_EXTENSION_TESTS_DISABLED` (default off → runs).
31
+ */
32
+ const WEBHOOKS_PATH = '/api/webhooks';
33
+
34
+ async function readWebhookById(
35
+ request: APIRequestContext,
36
+ token: string,
37
+ id: string,
38
+ ): Promise<CrudRecord | null> {
39
+ const response = await apiRequest(request, 'GET', `${WEBHOOKS_PATH}/${encodeURIComponent(id)}`, { token });
40
+ if (response.status() === 404) return null;
41
+ expect(response.status(), `read-back webhook failed: ${response.status()}`).toBe(200);
42
+ const body = await readJsonSafe<CrudRecord>(response);
43
+ return body && body.id === id ? body : null;
44
+ }
45
+
46
+ test.describe('TC-WH-CRUDFORM-001: Webhook CrudForm persists scalars, events multiselect + headers JSON', () => {
47
+ test.beforeAll(() => {
48
+ skipIfCrudFormExtensionTestsDisabled();
49
+ });
50
+
51
+ test('round-trips scalars, subscribedEvents array, and customHeaders JSON on create and update', async ({ request }) => {
52
+ const token = await getAuthToken(request, 'admin');
53
+ const stamp = Date.now();
54
+ const createEvents = ['catalog.product.created', 'catalog.product.updated'];
55
+ const updateEvents = ['sales.quote.created', 'catalog.product.deleted', 'sales.order.placed'];
56
+ const createHeaders = { 'X-Source': 'om-qa', 'X-Trace': `crudform-${stamp}` };
57
+ const updateHeaders = { 'X-Env': 'integration', 'X-Rotated': 'true' };
58
+
59
+ await runCrudFormRoundTrip({
60
+ request,
61
+ token,
62
+ collectionPath: WEBHOOKS_PATH,
63
+ recordPath: (id) => `${WEBHOOKS_PATH}/${encodeURIComponent(id)}`,
64
+ readById: (id) => readWebhookById(request, token, id),
65
+ create: {
66
+ payload: {
67
+ name: `QA CRUDFORM Webhook ${stamp}`,
68
+ description: 'Original webhook description',
69
+ url: `https://example.com/hooks/crudform-${stamp}`,
70
+ subscribedEvents: createEvents,
71
+ httpMethod: 'POST',
72
+ maxRetries: 3,
73
+ timeoutMs: 5000,
74
+ rateLimitPerMinute: 0,
75
+ autoDisableThreshold: 5,
76
+ customHeaders: createHeaders,
77
+ },
78
+ },
79
+ expectAfterCreate: {
80
+ scalars: {
81
+ name: `QA CRUDFORM Webhook ${stamp}`,
82
+ description: 'Original webhook description',
83
+ url: `https://example.com/hooks/crudform-${stamp}`,
84
+ subscribedEvents: createEvents,
85
+ httpMethod: 'POST',
86
+ isActive: true,
87
+ maxRetries: 3,
88
+ timeoutMs: 5000,
89
+ rateLimitPerMinute: 0,
90
+ autoDisableThreshold: 5,
91
+ customHeaders: createHeaders,
92
+ },
93
+ },
94
+ update: {
95
+ payload: () => ({
96
+ name: `QA CRUDFORM Webhook ${stamp} EDITED`,
97
+ description: 'Updated webhook description',
98
+ url: `https://example.com/hooks/crudform-${stamp}-edited`,
99
+ subscribedEvents: updateEvents,
100
+ httpMethod: 'PUT',
101
+ isActive: false,
102
+ maxRetries: 7,
103
+ timeoutMs: 20000,
104
+ rateLimitPerMinute: 120,
105
+ autoDisableThreshold: 25,
106
+ customHeaders: updateHeaders,
107
+ }),
108
+ },
109
+ expectAfterUpdate: {
110
+ scalars: {
111
+ name: `QA CRUDFORM Webhook ${stamp} EDITED`,
112
+ description: 'Updated webhook description',
113
+ url: `https://example.com/hooks/crudform-${stamp}-edited`,
114
+ subscribedEvents: updateEvents,
115
+ httpMethod: 'PUT',
116
+ isActive: false,
117
+ maxRetries: 7,
118
+ timeoutMs: 20000,
119
+ rateLimitPerMinute: 120,
120
+ autoDisableThreshold: 25,
121
+ customHeaders: updateHeaders,
122
+ },
123
+ },
124
+ });
125
+ });
126
+ });
@@ -16,6 +16,7 @@ import { useT } from '@open-mercato/shared/lib/i18n/context'
16
16
  import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
17
17
  import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
18
18
  import { Alert, AlertDescription, AlertTitle } from '@open-mercato/ui/primitives/alert'
19
+ import { ListEmptyState } from '@open-mercato/ui/backend/filters/ListEmptyState'
19
20
  import { useWebhookFeatureAccess } from './useWebhookFeatureAccess'
20
21
 
21
22
  type Row = {
@@ -279,6 +280,13 @@ export default function WebhooksListPage() {
279
280
 
280
281
  return <RowActions items={items} />
281
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
+ )}
282
290
  pagination={{ page, pageSize: 20, total, totalPages, onPageChange: setPage }}
283
291
  isLoading={isLoading}
284
292
  />