@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.
@@ -1,2 +1,2 @@
1
- [build:webhooks] found 58 entry points
1
+ [build:webhooks] found 59 entry points
2
2
  [build:webhooks] built successfully
@@ -0,0 +1,238 @@
1
+ import { test, expect } 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
+ const WEBHOOKS_API = "/api/webhooks";
12
+ const INBOX_SETTINGS_API = "/api/inbox_ops/settings";
13
+ const SCHEDULES_API = "/api/data_sync/schedules";
14
+ async function createWebhook(request, token, stamp) {
15
+ const created = await apiRequest(request, "POST", WEBHOOKS_API, {
16
+ token,
17
+ data: {
18
+ name: `QA Lock 043 ${stamp}`,
19
+ url: `https://example.com/qa-lock-043-${stamp}`,
20
+ subscribedEvents: ["qa.lock.test"]
21
+ }
22
+ });
23
+ expect(created.status(), "POST webhook should be 201").toBe(201);
24
+ const body = await created.json();
25
+ expect(typeof body.id, "webhook creation should return an id").toBe("string");
26
+ const detail = await apiRequest(request, "GET", `${WEBHOOKS_API}/${body.id}`, { token });
27
+ expect(detail.status(), "GET webhook detail should be 200").toBe(200);
28
+ const detailBody = await detail.json();
29
+ expect(typeof detailBody.updatedAt, "webhook should expose updatedAt").toBe("string");
30
+ return { id: body.id, updatedAt: detailBody.updatedAt };
31
+ }
32
+ async function readWebhookUpdatedAt(request, token, id) {
33
+ const detail = await apiRequest(request, "GET", `${WEBHOOKS_API}/${id}`, { token });
34
+ expect(detail.status(), "GET webhook detail should be 200").toBe(200);
35
+ const body = await detail.json();
36
+ return body.updatedAt;
37
+ }
38
+ async function deleteWebhook(request, token, id) {
39
+ const current = await readWebhookUpdatedAt(request, token, id).catch(() => void 0);
40
+ await request.fetch(resolveApiUrl(`${WEBHOOKS_API}/${id}`), {
41
+ method: "DELETE",
42
+ headers: {
43
+ Authorization: `Bearer ${token}`,
44
+ "Content-Type": "application/json",
45
+ ...current ? { [OPTIMISTIC_LOCK_HEADER_NAME]: current } : {}
46
+ }
47
+ }).catch(() => void 0);
48
+ }
49
+ async function patchInboxSettings(request, token, body, lockValue) {
50
+ return request.fetch(resolveApiUrl(INBOX_SETTINGS_API), {
51
+ method: "PATCH",
52
+ headers: {
53
+ Authorization: `Bearer ${token}`,
54
+ "Content-Type": "application/json",
55
+ ...lockValue !== void 0 ? { [OPTIMISTIC_LOCK_HEADER_NAME]: lockValue } : {}
56
+ },
57
+ data: body
58
+ });
59
+ }
60
+ async function readInboxSettings(request, token) {
61
+ const response = await apiRequest(request, "GET", INBOX_SETTINGS_API, { token });
62
+ expect(response.status(), "GET inbox settings should be 200").toBe(200);
63
+ const body = await response.json();
64
+ expect(body.settings, "tenant inbox settings singleton should exist").toBeTruthy();
65
+ return body.settings;
66
+ }
67
+ async function postSchedule(request, token, scheduleValue, integrationId, lockValue) {
68
+ return request.fetch(resolveApiUrl(SCHEDULES_API), {
69
+ method: "POST",
70
+ headers: {
71
+ Authorization: `Bearer ${token}`,
72
+ "Content-Type": "application/json",
73
+ ...lockValue !== void 0 ? { [OPTIMISTIC_LOCK_HEADER_NAME]: lockValue } : {}
74
+ },
75
+ data: {
76
+ integrationId,
77
+ entityType: "products",
78
+ direction: "import",
79
+ scheduleType: "cron",
80
+ scheduleValue,
81
+ timezone: "UTC",
82
+ fullSync: false,
83
+ isEnabled: true
84
+ }
85
+ });
86
+ }
87
+ test.describe("TC-LOCK-OSS-043: webhooks + inbox settings + data-sync schedule optimistic lock", () => {
88
+ test("WHK-01 stale webhook PUT is refused with a 409 conflict", async ({ page }) => {
89
+ const token = await getAuthToken(page.request, "admin");
90
+ const stamp = Date.now();
91
+ let webhookId = null;
92
+ try {
93
+ const webhook = await createWebhook(page.request, token, stamp);
94
+ webhookId = webhook.id;
95
+ const staleUpdatedAt = webhook.updatedAt;
96
+ const bump = await apiRequest(page.request, "PUT", `${WEBHOOKS_API}/${webhookId}`, {
97
+ token,
98
+ data: { name: `QA Lock 043 bumped ${stamp}` }
99
+ });
100
+ expect(bump.status(), "out-of-band webhook PUT should succeed").toBeLessThan(300);
101
+ const conflict = await putWithLock(
102
+ page.request,
103
+ token,
104
+ `${WEBHOOKS_API}/${webhookId}`,
105
+ { name: `QA Lock 043 stale ${stamp}` },
106
+ staleUpdatedAt
107
+ );
108
+ await expectConflictBody(conflict);
109
+ } finally {
110
+ if (webhookId) await deleteWebhook(page.request, token, webhookId);
111
+ }
112
+ });
113
+ test("WHK-01 stale webhook DELETE is refused with a 409 conflict", async ({ page }) => {
114
+ const token = await getAuthToken(page.request, "admin");
115
+ const stamp = Date.now();
116
+ let webhookId = null;
117
+ try {
118
+ const webhook = await createWebhook(page.request, token, stamp);
119
+ webhookId = webhook.id;
120
+ const staleUpdatedAt = webhook.updatedAt;
121
+ const bump = await apiRequest(page.request, "PUT", `${WEBHOOKS_API}/${webhookId}`, {
122
+ token,
123
+ data: { name: `QA Lock 043 bumped ${stamp}` }
124
+ });
125
+ expect(bump.status(), "out-of-band webhook PUT should succeed").toBeLessThan(300);
126
+ const conflict = await page.request.fetch(resolveApiUrl(`${WEBHOOKS_API}/${webhookId}`), {
127
+ method: "DELETE",
128
+ headers: {
129
+ Authorization: `Bearer ${token}`,
130
+ "Content-Type": "application/json",
131
+ [OPTIMISTIC_LOCK_HEADER_NAME]: staleUpdatedAt
132
+ }
133
+ });
134
+ await expectConflictBody(conflict);
135
+ } finally {
136
+ if (webhookId) await deleteWebhook(page.request, token, webhookId);
137
+ }
138
+ });
139
+ test("WHK-01 stale webhook DELETE in the list surfaces the conflict bar", async ({ page }) => {
140
+ const token = await getAuthToken(page.request, "admin");
141
+ const stamp = Date.now();
142
+ const webhookName = `QA Lock 043 ${stamp}`;
143
+ let webhookId = null;
144
+ try {
145
+ const webhook = await createWebhook(page.request, token, stamp);
146
+ webhookId = webhook.id;
147
+ await login(page, "admin");
148
+ await page.goto("/backend/webhooks");
149
+ await page.waitForResponse(
150
+ (response) => response.url().includes("/api/webhooks") && response.request().method() === "GET" && response.ok(),
151
+ { timeout: 2e4 }
152
+ ).catch(() => void 0);
153
+ const row = page.getByRole("row", { name: new RegExp(`${webhookName}\\b`) }).first();
154
+ await expect(row, "created webhook should appear in the list").toBeVisible({ timeout: 2e4 });
155
+ const bump = await apiRequest(page.request, "PUT", `${WEBHOOKS_API}/${webhookId}`, {
156
+ token,
157
+ data: { name: `QA Lock 043 bumped ${stamp}` }
158
+ });
159
+ expect(bump.status(), "out-of-band webhook PUT should succeed").toBeLessThan(300);
160
+ const kebab = row.getByRole("button", { name: /open actions/i });
161
+ await expect(kebab, "row should expose an Open actions trigger").toBeVisible();
162
+ await kebab.click();
163
+ const deleteItem = page.getByRole("menuitem", { name: /^delete$/i });
164
+ await expect(deleteItem, "delete menu item should be visible").toBeVisible({ timeout: 1e4 });
165
+ await deleteItem.click();
166
+ const dialog = page.getByRole("alertdialog");
167
+ await expect(dialog, "confirm dialog should open").toBeVisible({ timeout: 1e4 });
168
+ await dialog.getByRole("button", { name: /^(confirm|delete)$/i }).click();
169
+ await expectConflictBanner(page);
170
+ } finally {
171
+ if (webhookId) await deleteWebhook(page.request, token, webhookId);
172
+ }
173
+ });
174
+ test("INB-01 stale inbox-settings PATCH is refused with a 409 conflict", async ({ page }) => {
175
+ const token = await getAuthToken(page.request, "admin");
176
+ const before = await readInboxSettings(page.request, token);
177
+ const originalLanguage = before.workingLanguage;
178
+ const staleUpdatedAt = before.updatedAt;
179
+ const bumpLanguage = originalLanguage === "de" ? "en" : "de";
180
+ const staleLanguage = originalLanguage === "es" ? "pl" : "es";
181
+ try {
182
+ const bump = await patchInboxSettings(page.request, token, { workingLanguage: bumpLanguage });
183
+ expect(bump.status(), "out-of-band inbox PATCH should succeed").toBeLessThan(300);
184
+ const conflict = await patchInboxSettings(
185
+ page.request,
186
+ token,
187
+ { workingLanguage: staleLanguage },
188
+ staleUpdatedAt
189
+ );
190
+ await expectConflictBody(conflict);
191
+ } finally {
192
+ const current = await readInboxSettings(page.request, token).catch(() => null);
193
+ if (current) {
194
+ await patchInboxSettings(
195
+ page.request,
196
+ token,
197
+ { workingLanguage: originalLanguage },
198
+ current.updatedAt
199
+ ).catch(() => void 0);
200
+ }
201
+ }
202
+ });
203
+ test("SYNC-01 stale data-sync schedule save is refused with a 409 conflict", async ({ page }) => {
204
+ const token = await getAuthToken(page.request, "admin");
205
+ const stamp = Date.now();
206
+ const integrationId = `qa-lock-043-${stamp}`;
207
+ let scheduleId = null;
208
+ try {
209
+ const created = await postSchedule(page.request, token, "0 * * * *", integrationId);
210
+ expect(created.status(), "POST schedule should be 201").toBe(201);
211
+ const createdBody = await created.json();
212
+ scheduleId = createdBody.id ?? null;
213
+ const staleUpdatedAt = createdBody.updatedAt;
214
+ expect(typeof scheduleId, "schedule creation should return an id").toBe("string");
215
+ expect(typeof staleUpdatedAt, "schedule should expose updatedAt").toBe("string");
216
+ const bump = await apiRequest(page.request, "PUT", `${SCHEDULES_API}/${scheduleId}`, {
217
+ token,
218
+ data: { scheduleValue: "5 * * * *" }
219
+ });
220
+ expect(bump.status(), "out-of-band schedule PUT should succeed").toBeLessThan(300);
221
+ const conflict = await postSchedule(
222
+ page.request,
223
+ token,
224
+ "9 * * * *",
225
+ integrationId,
226
+ staleUpdatedAt
227
+ );
228
+ await expectConflictBody(conflict);
229
+ } finally {
230
+ if (scheduleId) {
231
+ await apiRequest(page.request, "DELETE", `${SCHEDULES_API}/${scheduleId}`, { token }).catch(
232
+ () => void 0
233
+ );
234
+ }
235
+ }
236
+ });
237
+ });
238
+ //# sourceMappingURL=TC-LOCK-OSS-043.spec.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/webhooks/__integration__/TC-LOCK-OSS-043.spec.ts"],
4
+ "sourcesContent": ["import { test, expect, type APIRequestContext } from '@playwright/test'\nimport { getAuthToken, apiRequest } from '@open-mercato/core/modules/core/__integration__/helpers/api'\nimport { login } from '@open-mercato/core/modules/core/__integration__/helpers/auth'\nimport {\n putWithLock,\n expectConflictBody,\n expectConflictBanner,\n resolveApiUrl,\n} from '@open-mercato/core/modules/core/__integration__/helpers/optimisticLockUi'\nimport { OPTIMISTIC_LOCK_HEADER_NAME } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'\n\n/**\n * TC-LOCK-OSS-043 \u2014 webhooks + inbox settings + data-sync schedule\n * optimistic-lock conflict contract (WHK-01, INB-01, SYNC-01).\n *\n * All three surfaces guard writes with `enforceCommandOptimisticLock` inside\n * pure command/API routes \u2014 none expose a dedicated `data-crud-field-id`\n * CrudForm edit page for the locked record, so the conflict contract is proven\n * at the API level: capture the record's `updated_at`, advance it out-of-band\n * via a header-less write (the strictly-additive path always succeeds and bumps\n * `updated_at`), then replay the now-stale write with the original\n * expected-version header \u2192 409 `optimistic_lock_conflict`.\n *\n * - WHK-01: `PUT /api/webhooks/<id>` and `DELETE /api/webhooks/<id>`\n * (`packages/webhooks/src/modules/webhooks/api/webhooks/[id]/route.ts`,\n * `resourceKind: 'webhooks.endpoint'`).\n * - INB-01: `PATCH /api/inbox_ops/settings`\n * (`packages/core/src/modules/inbox_ops/api/settings/route.ts`,\n * `resourceKind: 'inbox_ops.settings'`). The settings row is a per-tenant\n * singleton, so the test bumps + restores `workingLanguage` instead of\n * creating/deleting a fixture.\n * - SYNC-01: `POST /api/data_sync/schedules`\n * (`packages/core/src/modules/data_sync/api/schedules/route.ts` \u2192\n * `SyncScheduleService.saveSchedule`, `resourceKind: 'data_sync.schedule'`).\n * The collection POST reads the expected-version header; the service enforces\n * the lock when a row with the same (integrationId, entityType, direction)\n * key already exists.\n */\n\nconst WEBHOOKS_API = '/api/webhooks'\nconst INBOX_SETTINGS_API = '/api/inbox_ops/settings'\nconst SCHEDULES_API = '/api/data_sync/schedules'\n\ntype InboxSettings = { id: string; workingLanguage: string; updatedAt: string }\n\nasync function createWebhook(\n request: APIRequestContext,\n token: string,\n stamp: number,\n): Promise<{ id: string; updatedAt: string }> {\n const created = await apiRequest(request, 'POST', WEBHOOKS_API, {\n token,\n data: {\n name: `QA Lock 043 ${stamp}`,\n url: `https://example.com/qa-lock-043-${stamp}`,\n subscribedEvents: ['qa.lock.test'],\n },\n })\n expect(created.status(), 'POST webhook should be 201').toBe(201)\n const body = (await created.json()) as { id?: string }\n expect(typeof body.id, 'webhook creation should return an id').toBe('string')\n const detail = await apiRequest(request, 'GET', `${WEBHOOKS_API}/${body.id}`, { token })\n expect(detail.status(), 'GET webhook detail should be 200').toBe(200)\n const detailBody = (await detail.json()) as { updatedAt?: string }\n expect(typeof detailBody.updatedAt, 'webhook should expose updatedAt').toBe('string')\n return { id: body.id as string, updatedAt: detailBody.updatedAt as string }\n}\n\nasync function readWebhookUpdatedAt(\n request: APIRequestContext,\n token: string,\n id: string,\n): Promise<string> {\n const detail = await apiRequest(request, 'GET', `${WEBHOOKS_API}/${id}`, { token })\n expect(detail.status(), 'GET webhook detail should be 200').toBe(200)\n const body = (await detail.json()) as { updatedAt?: string }\n return body.updatedAt as string\n}\n\nasync function deleteWebhook(\n request: APIRequestContext,\n token: string,\n id: string,\n): Promise<void> {\n const current = await readWebhookUpdatedAt(request, token, id).catch(() => undefined)\n await request\n .fetch(resolveApiUrl(`${WEBHOOKS_API}/${id}`), {\n method: 'DELETE',\n headers: {\n Authorization: `Bearer ${token}`,\n 'Content-Type': 'application/json',\n ...(current ? { [OPTIMISTIC_LOCK_HEADER_NAME]: current } : {}),\n },\n })\n .catch(() => undefined)\n}\n\nasync function patchInboxSettings(\n request: APIRequestContext,\n token: string,\n body: Record<string, unknown>,\n lockValue?: string,\n) {\n return request.fetch(resolveApiUrl(INBOX_SETTINGS_API), {\n method: 'PATCH',\n headers: {\n Authorization: `Bearer ${token}`,\n 'Content-Type': 'application/json',\n ...(lockValue !== undefined ? { [OPTIMISTIC_LOCK_HEADER_NAME]: lockValue } : {}),\n },\n data: body,\n })\n}\n\nasync function readInboxSettings(\n request: APIRequestContext,\n token: string,\n): Promise<InboxSettings> {\n const response = await apiRequest(request, 'GET', INBOX_SETTINGS_API, { token })\n expect(response.status(), 'GET inbox settings should be 200').toBe(200)\n const body = (await response.json()) as { settings?: InboxSettings | null }\n expect(body.settings, 'tenant inbox settings singleton should exist').toBeTruthy()\n return body.settings as InboxSettings\n}\n\nasync function postSchedule(\n request: APIRequestContext,\n token: string,\n scheduleValue: string,\n integrationId: string,\n lockValue?: string,\n) {\n return request.fetch(resolveApiUrl(SCHEDULES_API), {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${token}`,\n 'Content-Type': 'application/json',\n ...(lockValue !== undefined ? { [OPTIMISTIC_LOCK_HEADER_NAME]: lockValue } : {}),\n },\n data: {\n integrationId,\n entityType: 'products',\n direction: 'import',\n scheduleType: 'cron',\n scheduleValue,\n timezone: 'UTC',\n fullSync: false,\n isEnabled: true,\n },\n })\n}\n\ntest.describe('TC-LOCK-OSS-043: webhooks + inbox settings + data-sync schedule optimistic lock', () => {\n test('WHK-01 stale webhook PUT is refused with a 409 conflict', async ({ page }) => {\n const token = await getAuthToken(page.request, 'admin')\n const stamp = Date.now()\n let webhookId: string | null = null\n try {\n const webhook = await createWebhook(page.request, token, stamp)\n webhookId = webhook.id\n const staleUpdatedAt = webhook.updatedAt\n\n // Advance updated_at out-of-band via a header-less PUT.\n const bump = await apiRequest(page.request, 'PUT', `${WEBHOOKS_API}/${webhookId}`, {\n token,\n data: { name: `QA Lock 043 bumped ${stamp}` },\n })\n expect(bump.status(), 'out-of-band webhook PUT should succeed').toBeLessThan(300)\n\n // Replay the now-stale write \u2192 409.\n const conflict = await putWithLock(\n page.request,\n token,\n `${WEBHOOKS_API}/${webhookId}`,\n { name: `QA Lock 043 stale ${stamp}` },\n staleUpdatedAt,\n )\n await expectConflictBody(conflict)\n } finally {\n if (webhookId) await deleteWebhook(page.request, token, webhookId)\n }\n })\n\n test('WHK-01 stale webhook DELETE is refused with a 409 conflict', async ({ page }) => {\n const token = await getAuthToken(page.request, 'admin')\n const stamp = Date.now()\n let webhookId: string | null = null\n try {\n const webhook = await createWebhook(page.request, token, stamp)\n webhookId = webhook.id\n const staleUpdatedAt = webhook.updatedAt\n\n const bump = await apiRequest(page.request, 'PUT', `${WEBHOOKS_API}/${webhookId}`, {\n token,\n data: { name: `QA Lock 043 bumped ${stamp}` },\n })\n expect(bump.status(), 'out-of-band webhook PUT should succeed').toBeLessThan(300)\n\n const conflict = await page.request.fetch(resolveApiUrl(`${WEBHOOKS_API}/${webhookId}`), {\n method: 'DELETE',\n headers: {\n Authorization: `Bearer ${token}`,\n 'Content-Type': 'application/json',\n [OPTIMISTIC_LOCK_HEADER_NAME]: staleUpdatedAt,\n },\n })\n await expectConflictBody(conflict)\n } finally {\n if (webhookId) await deleteWebhook(page.request, token, webhookId)\n }\n })\n\n // WHK-01 (browser UI): now ACTIVE. The product fix is committed/verified \u2014 the webhooks\n // server DELETE returns the structured 409 (proven by the active \"WHK-01 stale webhook\n // DELETE is refused with a 409\" API test) and the list page sends the lock header\n // (`buildOptimisticLockHeader(row.updatedAt)`) + routes the resulting 409 through\n // surfaceRecordConflict. The RowActions menu opens on CLICK of the \"Open actions\" kebab and\n // renders the items in a portal on document.body (role=\"menuitem\"); the confirm dialog is a\n // role=\"alertdialog\" with a \"Confirm\" button. We drive that choreography robustly here:\n // wait for the filtered list GET to settle, open the kebab, click Delete, confirm, then\n // assert the unified conflict bar (never the success toast).\n test('WHK-01 stale webhook DELETE in the list surfaces the conflict bar', async ({ page }) => {\n const token = await getAuthToken(page.request, 'admin')\n const stamp = Date.now()\n const webhookName = `QA Lock 043 ${stamp}`\n let webhookId: string | null = null\n try {\n const webhook = await createWebhook(page.request, token, stamp)\n webhookId = webhook.id\n\n await login(page, 'admin')\n await page.goto('/backend/webhooks')\n // Wait for the list GET to settle before interacting. The list reflects the\n // freshly created fixture immediately (newest-first), so a unique name makes\n // the row unambiguous.\n await page\n .waitForResponse(\n (response) =>\n response.url().includes('/api/webhooks') &&\n response.request().method() === 'GET' &&\n response.ok(),\n { timeout: 20_000 },\n )\n .catch(() => undefined)\n\n const row = page.getByRole('row', { name: new RegExp(`${webhookName}\\\\b`) }).first()\n await expect(row, 'created webhook should appear in the list').toBeVisible({ timeout: 20_000 })\n\n // Advance updated_at out-of-band \u2192 the in-page row token is now stale.\n const bump = await apiRequest(page.request, 'PUT', `${WEBHOOKS_API}/${webhookId}`, {\n token,\n data: { name: `QA Lock 043 bumped ${stamp}` },\n })\n expect(bump.status(), 'out-of-band webhook PUT should succeed').toBeLessThan(300)\n\n // Open the row's RowActions kebab (opens on click) and trigger Delete. The\n // menu renders in a portal on document.body, so query it at the page level.\n const kebab = row.getByRole('button', { name: /open actions/i })\n await expect(kebab, 'row should expose an Open actions trigger').toBeVisible()\n await kebab.click()\n const deleteItem = page.getByRole('menuitem', { name: /^delete$/i })\n await expect(deleteItem, 'delete menu item should be visible').toBeVisible({ timeout: 10_000 })\n await deleteItem.click()\n\n // Confirm the destructive alertdialog (the row still holds the stale\n // updated_at captured at render time, so the DELETE 409s).\n const dialog = page.getByRole('alertdialog')\n await expect(dialog, 'confirm dialog should open').toBeVisible({ timeout: 10_000 })\n await dialog.getByRole('button', { name: /^(confirm|delete)$/i }).click()\n\n // The client must route the 409 to the unified conflict bar, never the\n // success toast.\n await expectConflictBanner(page)\n } finally {\n if (webhookId) await deleteWebhook(page.request, token, webhookId)\n }\n })\n\n test('INB-01 stale inbox-settings PATCH is refused with a 409 conflict', async ({ page }) => {\n const token = await getAuthToken(page.request, 'admin')\n const before = await readInboxSettings(page.request, token)\n const originalLanguage = before.workingLanguage\n const staleUpdatedAt = before.updatedAt\n const bumpLanguage = originalLanguage === 'de' ? 'en' : 'de'\n const staleLanguage = originalLanguage === 'es' ? 'pl' : 'es'\n try {\n // Advance updated_at out-of-band via a header-less PATCH.\n const bump = await patchInboxSettings(page.request, token, { workingLanguage: bumpLanguage })\n expect(bump.status(), 'out-of-band inbox PATCH should succeed').toBeLessThan(300)\n\n // Replay the now-stale write with the original expected-version \u2192 409.\n const conflict = await patchInboxSettings(\n page.request,\n token,\n { workingLanguage: staleLanguage },\n staleUpdatedAt,\n )\n await expectConflictBody(conflict)\n } finally {\n // Restore the original working language with a fresh lock token.\n const current = await readInboxSettings(page.request, token).catch(() => null)\n if (current) {\n await patchInboxSettings(\n page.request,\n token,\n { workingLanguage: originalLanguage },\n current.updatedAt,\n ).catch(() => undefined)\n }\n }\n })\n\n test('SYNC-01 stale data-sync schedule save is refused with a 409 conflict', async ({ page }) => {\n const token = await getAuthToken(page.request, 'admin')\n const stamp = Date.now()\n const integrationId = `qa-lock-043-${stamp}`\n let scheduleId: string | null = null\n try {\n const created = await postSchedule(page.request, token, '0 * * * *', integrationId)\n expect(created.status(), 'POST schedule should be 201').toBe(201)\n const createdBody = (await created.json()) as { id?: string; updatedAt?: string }\n scheduleId = createdBody.id ?? null\n const staleUpdatedAt = createdBody.updatedAt\n expect(typeof scheduleId, 'schedule creation should return an id').toBe('string')\n expect(typeof staleUpdatedAt, 'schedule should expose updatedAt').toBe('string')\n\n // Advance updated_at out-of-band via the header-less [id] PUT route.\n const bump = await apiRequest(page.request, 'PUT', `${SCHEDULES_API}/${scheduleId}`, {\n token,\n data: { scheduleValue: '5 * * * *' },\n })\n expect(bump.status(), 'out-of-band schedule PUT should succeed').toBeLessThan(300)\n\n // Replay the save for the same (integrationId, entityType, direction) key\n // with the now-stale expected-version \u2192 409.\n const conflict = await postSchedule(\n page.request,\n token,\n '9 * * * *',\n integrationId,\n staleUpdatedAt as string,\n )\n await expectConflictBody(conflict)\n } finally {\n if (scheduleId) {\n await apiRequest(page.request, 'DELETE', `${SCHEDULES_API}/${scheduleId}`, { token }).catch(\n () => undefined,\n )\n }\n }\n })\n})\n"],
5
+ "mappings": "AAAA,SAAS,MAAM,cAAsC;AACrD,SAAS,cAAc,kBAAkB;AACzC,SAAS,aAAa;AACtB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,mCAAmC;AA8B5C,MAAM,eAAe;AACrB,MAAM,qBAAqB;AAC3B,MAAM,gBAAgB;AAItB,eAAe,cACb,SACA,OACA,OAC4C;AAC5C,QAAM,UAAU,MAAM,WAAW,SAAS,QAAQ,cAAc;AAAA,IAC9D;AAAA,IACA,MAAM;AAAA,MACJ,MAAM,eAAe,KAAK;AAAA,MAC1B,KAAK,mCAAmC,KAAK;AAAA,MAC7C,kBAAkB,CAAC,cAAc;AAAA,IACnC;AAAA,EACF,CAAC;AACD,SAAO,QAAQ,OAAO,GAAG,4BAA4B,EAAE,KAAK,GAAG;AAC/D,QAAM,OAAQ,MAAM,QAAQ,KAAK;AACjC,SAAO,OAAO,KAAK,IAAI,sCAAsC,EAAE,KAAK,QAAQ;AAC5E,QAAM,SAAS,MAAM,WAAW,SAAS,OAAO,GAAG,YAAY,IAAI,KAAK,EAAE,IAAI,EAAE,MAAM,CAAC;AACvF,SAAO,OAAO,OAAO,GAAG,kCAAkC,EAAE,KAAK,GAAG;AACpE,QAAM,aAAc,MAAM,OAAO,KAAK;AACtC,SAAO,OAAO,WAAW,WAAW,iCAAiC,EAAE,KAAK,QAAQ;AACpF,SAAO,EAAE,IAAI,KAAK,IAAc,WAAW,WAAW,UAAoB;AAC5E;AAEA,eAAe,qBACb,SACA,OACA,IACiB;AACjB,QAAM,SAAS,MAAM,WAAW,SAAS,OAAO,GAAG,YAAY,IAAI,EAAE,IAAI,EAAE,MAAM,CAAC;AAClF,SAAO,OAAO,OAAO,GAAG,kCAAkC,EAAE,KAAK,GAAG;AACpE,QAAM,OAAQ,MAAM,OAAO,KAAK;AAChC,SAAO,KAAK;AACd;AAEA,eAAe,cACb,SACA,OACA,IACe;AACf,QAAM,UAAU,MAAM,qBAAqB,SAAS,OAAO,EAAE,EAAE,MAAM,MAAM,MAAS;AACpF,QAAM,QACH,MAAM,cAAc,GAAG,YAAY,IAAI,EAAE,EAAE,GAAG;AAAA,IAC7C,QAAQ;AAAA,IACR,SAAS;AAAA,MACP,eAAe,UAAU,KAAK;AAAA,MAC9B,gBAAgB;AAAA,MAChB,GAAI,UAAU,EAAE,CAAC,2BAA2B,GAAG,QAAQ,IAAI,CAAC;AAAA,IAC9D;AAAA,EACF,CAAC,EACA,MAAM,MAAM,MAAS;AAC1B;AAEA,eAAe,mBACb,SACA,OACA,MACA,WACA;AACA,SAAO,QAAQ,MAAM,cAAc,kBAAkB,GAAG;AAAA,IACtD,QAAQ;AAAA,IACR,SAAS;AAAA,MACP,eAAe,UAAU,KAAK;AAAA,MAC9B,gBAAgB;AAAA,MAChB,GAAI,cAAc,SAAY,EAAE,CAAC,2BAA2B,GAAG,UAAU,IAAI,CAAC;AAAA,IAChF;AAAA,IACA,MAAM;AAAA,EACR,CAAC;AACH;AAEA,eAAe,kBACb,SACA,OACwB;AACxB,QAAM,WAAW,MAAM,WAAW,SAAS,OAAO,oBAAoB,EAAE,MAAM,CAAC;AAC/E,SAAO,SAAS,OAAO,GAAG,kCAAkC,EAAE,KAAK,GAAG;AACtE,QAAM,OAAQ,MAAM,SAAS,KAAK;AAClC,SAAO,KAAK,UAAU,8CAA8C,EAAE,WAAW;AACjF,SAAO,KAAK;AACd;AAEA,eAAe,aACb,SACA,OACA,eACA,eACA,WACA;AACA,SAAO,QAAQ,MAAM,cAAc,aAAa,GAAG;AAAA,IACjD,QAAQ;AAAA,IACR,SAAS;AAAA,MACP,eAAe,UAAU,KAAK;AAAA,MAC9B,gBAAgB;AAAA,MAChB,GAAI,cAAc,SAAY,EAAE,CAAC,2BAA2B,GAAG,UAAU,IAAI,CAAC;AAAA,IAChF;AAAA,IACA,MAAM;AAAA,MACJ;AAAA,MACA,YAAY;AAAA,MACZ,WAAW;AAAA,MACX,cAAc;AAAA,MACd;AAAA,MACA,UAAU;AAAA,MACV,UAAU;AAAA,MACV,WAAW;AAAA,IACb;AAAA,EACF,CAAC;AACH;AAEA,KAAK,SAAS,mFAAmF,MAAM;AACrG,OAAK,2DAA2D,OAAO,EAAE,KAAK,MAAM;AAClF,UAAM,QAAQ,MAAM,aAAa,KAAK,SAAS,OAAO;AACtD,UAAM,QAAQ,KAAK,IAAI;AACvB,QAAI,YAA2B;AAC/B,QAAI;AACF,YAAM,UAAU,MAAM,cAAc,KAAK,SAAS,OAAO,KAAK;AAC9D,kBAAY,QAAQ;AACpB,YAAM,iBAAiB,QAAQ;AAG/B,YAAM,OAAO,MAAM,WAAW,KAAK,SAAS,OAAO,GAAG,YAAY,IAAI,SAAS,IAAI;AAAA,QACjF;AAAA,QACA,MAAM,EAAE,MAAM,sBAAsB,KAAK,GAAG;AAAA,MAC9C,CAAC;AACD,aAAO,KAAK,OAAO,GAAG,wCAAwC,EAAE,aAAa,GAAG;AAGhF,YAAM,WAAW,MAAM;AAAA,QACrB,KAAK;AAAA,QACL;AAAA,QACA,GAAG,YAAY,IAAI,SAAS;AAAA,QAC5B,EAAE,MAAM,qBAAqB,KAAK,GAAG;AAAA,QACrC;AAAA,MACF;AACA,YAAM,mBAAmB,QAAQ;AAAA,IACnC,UAAE;AACA,UAAI,UAAW,OAAM,cAAc,KAAK,SAAS,OAAO,SAAS;AAAA,IACnE;AAAA,EACF,CAAC;AAED,OAAK,8DAA8D,OAAO,EAAE,KAAK,MAAM;AACrF,UAAM,QAAQ,MAAM,aAAa,KAAK,SAAS,OAAO;AACtD,UAAM,QAAQ,KAAK,IAAI;AACvB,QAAI,YAA2B;AAC/B,QAAI;AACF,YAAM,UAAU,MAAM,cAAc,KAAK,SAAS,OAAO,KAAK;AAC9D,kBAAY,QAAQ;AACpB,YAAM,iBAAiB,QAAQ;AAE/B,YAAM,OAAO,MAAM,WAAW,KAAK,SAAS,OAAO,GAAG,YAAY,IAAI,SAAS,IAAI;AAAA,QACjF;AAAA,QACA,MAAM,EAAE,MAAM,sBAAsB,KAAK,GAAG;AAAA,MAC9C,CAAC;AACD,aAAO,KAAK,OAAO,GAAG,wCAAwC,EAAE,aAAa,GAAG;AAEhF,YAAM,WAAW,MAAM,KAAK,QAAQ,MAAM,cAAc,GAAG,YAAY,IAAI,SAAS,EAAE,GAAG;AAAA,QACvF,QAAQ;AAAA,QACR,SAAS;AAAA,UACP,eAAe,UAAU,KAAK;AAAA,UAC9B,gBAAgB;AAAA,UAChB,CAAC,2BAA2B,GAAG;AAAA,QACjC;AAAA,MACF,CAAC;AACD,YAAM,mBAAmB,QAAQ;AAAA,IACnC,UAAE;AACA,UAAI,UAAW,OAAM,cAAc,KAAK,SAAS,OAAO,SAAS;AAAA,IACnE;AAAA,EACF,CAAC;AAWD,OAAK,qEAAqE,OAAO,EAAE,KAAK,MAAM;AAC5F,UAAM,QAAQ,MAAM,aAAa,KAAK,SAAS,OAAO;AACtD,UAAM,QAAQ,KAAK,IAAI;AACvB,UAAM,cAAc,eAAe,KAAK;AACxC,QAAI,YAA2B;AAC/B,QAAI;AACF,YAAM,UAAU,MAAM,cAAc,KAAK,SAAS,OAAO,KAAK;AAC9D,kBAAY,QAAQ;AAEpB,YAAM,MAAM,MAAM,OAAO;AACzB,YAAM,KAAK,KAAK,mBAAmB;AAInC,YAAM,KACH;AAAA,QACC,CAAC,aACC,SAAS,IAAI,EAAE,SAAS,eAAe,KACvC,SAAS,QAAQ,EAAE,OAAO,MAAM,SAChC,SAAS,GAAG;AAAA,QACd,EAAE,SAAS,IAAO;AAAA,MACpB,EACC,MAAM,MAAM,MAAS;AAExB,YAAM,MAAM,KAAK,UAAU,OAAO,EAAE,MAAM,IAAI,OAAO,GAAG,WAAW,KAAK,EAAE,CAAC,EAAE,MAAM;AACnF,YAAM,OAAO,KAAK,2CAA2C,EAAE,YAAY,EAAE,SAAS,IAAO,CAAC;AAG9F,YAAM,OAAO,MAAM,WAAW,KAAK,SAAS,OAAO,GAAG,YAAY,IAAI,SAAS,IAAI;AAAA,QACjF;AAAA,QACA,MAAM,EAAE,MAAM,sBAAsB,KAAK,GAAG;AAAA,MAC9C,CAAC;AACD,aAAO,KAAK,OAAO,GAAG,wCAAwC,EAAE,aAAa,GAAG;AAIhF,YAAM,QAAQ,IAAI,UAAU,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAC/D,YAAM,OAAO,OAAO,2CAA2C,EAAE,YAAY;AAC7E,YAAM,MAAM,MAAM;AAClB,YAAM,aAAa,KAAK,UAAU,YAAY,EAAE,MAAM,YAAY,CAAC;AACnE,YAAM,OAAO,YAAY,oCAAoC,EAAE,YAAY,EAAE,SAAS,IAAO,CAAC;AAC9F,YAAM,WAAW,MAAM;AAIvB,YAAM,SAAS,KAAK,UAAU,aAAa;AAC3C,YAAM,OAAO,QAAQ,4BAA4B,EAAE,YAAY,EAAE,SAAS,IAAO,CAAC;AAClF,YAAM,OAAO,UAAU,UAAU,EAAE,MAAM,sBAAsB,CAAC,EAAE,MAAM;AAIxE,YAAM,qBAAqB,IAAI;AAAA,IACjC,UAAE;AACA,UAAI,UAAW,OAAM,cAAc,KAAK,SAAS,OAAO,SAAS;AAAA,IACnE;AAAA,EACF,CAAC;AAED,OAAK,oEAAoE,OAAO,EAAE,KAAK,MAAM;AAC3F,UAAM,QAAQ,MAAM,aAAa,KAAK,SAAS,OAAO;AACtD,UAAM,SAAS,MAAM,kBAAkB,KAAK,SAAS,KAAK;AAC1D,UAAM,mBAAmB,OAAO;AAChC,UAAM,iBAAiB,OAAO;AAC9B,UAAM,eAAe,qBAAqB,OAAO,OAAO;AACxD,UAAM,gBAAgB,qBAAqB,OAAO,OAAO;AACzD,QAAI;AAEF,YAAM,OAAO,MAAM,mBAAmB,KAAK,SAAS,OAAO,EAAE,iBAAiB,aAAa,CAAC;AAC5F,aAAO,KAAK,OAAO,GAAG,wCAAwC,EAAE,aAAa,GAAG;AAGhF,YAAM,WAAW,MAAM;AAAA,QACrB,KAAK;AAAA,QACL;AAAA,QACA,EAAE,iBAAiB,cAAc;AAAA,QACjC;AAAA,MACF;AACA,YAAM,mBAAmB,QAAQ;AAAA,IACnC,UAAE;AAEA,YAAM,UAAU,MAAM,kBAAkB,KAAK,SAAS,KAAK,EAAE,MAAM,MAAM,IAAI;AAC7E,UAAI,SAAS;AACX,cAAM;AAAA,UACJ,KAAK;AAAA,UACL;AAAA,UACA,EAAE,iBAAiB,iBAAiB;AAAA,UACpC,QAAQ;AAAA,QACV,EAAE,MAAM,MAAM,MAAS;AAAA,MACzB;AAAA,IACF;AAAA,EACF,CAAC;AAED,OAAK,wEAAwE,OAAO,EAAE,KAAK,MAAM;AAC/F,UAAM,QAAQ,MAAM,aAAa,KAAK,SAAS,OAAO;AACtD,UAAM,QAAQ,KAAK,IAAI;AACvB,UAAM,gBAAgB,eAAe,KAAK;AAC1C,QAAI,aAA4B;AAChC,QAAI;AACF,YAAM,UAAU,MAAM,aAAa,KAAK,SAAS,OAAO,aAAa,aAAa;AAClF,aAAO,QAAQ,OAAO,GAAG,6BAA6B,EAAE,KAAK,GAAG;AAChE,YAAM,cAAe,MAAM,QAAQ,KAAK;AACxC,mBAAa,YAAY,MAAM;AAC/B,YAAM,iBAAiB,YAAY;AACnC,aAAO,OAAO,YAAY,uCAAuC,EAAE,KAAK,QAAQ;AAChF,aAAO,OAAO,gBAAgB,kCAAkC,EAAE,KAAK,QAAQ;AAG/E,YAAM,OAAO,MAAM,WAAW,KAAK,SAAS,OAAO,GAAG,aAAa,IAAI,UAAU,IAAI;AAAA,QACnF;AAAA,QACA,MAAM,EAAE,eAAe,YAAY;AAAA,MACrC,CAAC;AACD,aAAO,KAAK,OAAO,GAAG,yCAAyC,EAAE,aAAa,GAAG;AAIjF,YAAM,WAAW,MAAM;AAAA,QACrB,KAAK;AAAA,QACL;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,MACF;AACA,YAAM,mBAAmB,QAAQ;AAAA,IACnC,UAAE;AACA,UAAI,YAAY;AACd,cAAM,WAAW,KAAK,SAAS,UAAU,GAAG,aAAa,IAAI,UAAU,IAAI,EAAE,MAAM,CAAC,EAAE;AAAA,UACpF,MAAM;AAAA,QACR;AAAA,MACF;AAAA,IACF;AAAA,EACF,CAAC;AACH,CAAC;",
6
+ "names": []
7
+ }
@@ -3,6 +3,8 @@ import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
3
3
  import { emitWebhooksEvent } from "../../../events.js";
4
4
  import { findScopedWebhook, json, resolveWebhookRequestScope, serializeWebhookDetail } from "../../helpers.js";
5
5
  import { webhookUpdateSchema } from "../../../data/validators.js";
6
+ import { enforceCommandOptimisticLock } from "@open-mercato/shared/lib/crud/optimistic-lock-command";
7
+ import { isCrudHttpError } from "@open-mercato/shared/lib/crud/errors";
6
8
  const metadata = {
7
9
  GET: { requireAuth: true, requireFeatures: ["webhooks.view"] },
8
10
  PUT: { requireAuth: true, requireFeatures: ["webhooks.manage"] },
@@ -53,6 +55,17 @@ async function PUT(request, context) {
53
55
  if (!webhook) {
54
56
  return json({ error: "Webhook not found" }, { status: 404 });
55
57
  }
58
+ try {
59
+ enforceCommandOptimisticLock({
60
+ resourceKind: "webhooks.endpoint",
61
+ resourceId: webhook.id,
62
+ current: webhook.updatedAt ?? null,
63
+ request
64
+ });
65
+ } catch (err) {
66
+ if (isCrudHttpError(err)) return json(err.body, { status: err.status });
67
+ throw err;
68
+ }
56
69
  const parsed = webhookUpdateSchema.safeParse(await request.json().catch(() => null));
57
70
  if (!parsed.success) {
58
71
  return json({ error: "Invalid request payload" }, { status: 400 });
@@ -90,6 +103,17 @@ async function DELETE(request, context) {
90
103
  if (!webhook) {
91
104
  return json({ error: translate("webhooks.errors.notFound", "Webhook not found") }, { status: 404 });
92
105
  }
106
+ try {
107
+ enforceCommandOptimisticLock({
108
+ resourceKind: "webhooks.endpoint",
109
+ resourceId: webhook.id,
110
+ current: webhook.updatedAt ?? null,
111
+ request
112
+ });
113
+ } catch (err) {
114
+ if (isCrudHttpError(err)) return json(err.body, { status: err.status });
115
+ throw err;
116
+ }
93
117
  webhook.deletedAt = /* @__PURE__ */ new Date();
94
118
  await em.flush();
95
119
  await emitWebhooksEvent("webhooks.webhook.deleted", {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../src/modules/webhooks/api/webhooks/%5Bid%5D/route.ts"],
4
- "sourcesContent": ["import { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { emitWebhooksEvent } from '../../../events'\nimport { findScopedWebhook, json, resolveWebhookRequestScope, serializeWebhookDetail } from '../../helpers'\nimport { webhookUpdateSchema } from '../../../data/validators'\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['webhooks.view'] },\n PUT: { requireAuth: true, requireFeatures: ['webhooks.manage'] },\n DELETE: { requireAuth: true, requireFeatures: ['webhooks.manage'] },\n}\n\ninterface RouteContext {\n params: Promise<{ id: string }>\n}\n\nconst webhookDetailResponseSchema = z.object({\n id: z.string(),\n name: z.string(),\n description: z.string().nullable(),\n url: z.string(),\n subscribedEvents: z.array(z.string()),\n httpMethod: z.string(),\n isActive: z.boolean(),\n deliveryStrategy: z.string(),\n maxRetries: z.number(),\n consecutiveFailures: z.number(),\n lastSuccessAt: z.string().nullable(),\n lastFailureAt: z.string().nullable(),\n createdAt: z.string(),\n updatedAt: z.string(),\n customHeaders: z.record(z.string(), z.string()).nullable(),\n strategyConfig: z.record(z.string(), z.unknown()).nullable(),\n timeoutMs: z.number(),\n rateLimitPerMinute: z.number(),\n autoDisableThreshold: z.number(),\n integrationId: z.string().nullable(),\n maskedSecret: z.string(),\n previousSecretSetAt: z.string().nullable(),\n})\n\nconst errorSchema = z.object({ error: z.string() })\nconst deleteResponseSchema = z.object({ success: z.literal(true) })\n\nexport async function GET(request: Request, context: RouteContext): Promise<Response> {\n const scope = await resolveWebhookRequestScope(request)\n if (scope instanceof Response) return scope\n\n const params = await context.params\n const webhook = await findScopedWebhook(scope.em.fork(), scope, params.id)\n\n if (!webhook) {\n return json({ error: 'Webhook not found' }, { status: 404 })\n }\n\n return json(serializeWebhookDetail(webhook))\n}\n\nexport async function PUT(request: Request, context: RouteContext): Promise<Response> {\n const scope = await resolveWebhookRequestScope(request)\n if (scope instanceof Response) return scope\n\n const params = await context.params\n const em = scope.em.fork()\n const webhook = await findScopedWebhook(em, scope, params.id)\n\n if (!webhook) {\n return json({ error: 'Webhook not found' }, { status: 404 })\n }\n\n const parsed = webhookUpdateSchema.safeParse(await request.json().catch(() => null))\n if (!parsed.success) {\n return json({ error: 'Invalid request payload' }, { status: 400 })\n }\n\n const input = parsed.data\n if (input.name !== undefined) webhook.name = input.name\n if (input.description !== undefined) webhook.description = input.description\n if (input.url !== undefined) webhook.url = input.url\n if (input.subscribedEvents !== undefined) webhook.subscribedEvents = input.subscribedEvents\n if (input.httpMethod !== undefined) webhook.httpMethod = input.httpMethod\n if (input.customHeaders !== undefined) webhook.customHeaders = input.customHeaders\n if (input.deliveryStrategy !== undefined) webhook.deliveryStrategy = input.deliveryStrategy\n if (input.strategyConfig !== undefined) webhook.strategyConfig = input.strategyConfig\n if (input.maxRetries !== undefined) webhook.maxRetries = input.maxRetries\n if (input.timeoutMs !== undefined) webhook.timeoutMs = input.timeoutMs\n if (input.rateLimitPerMinute !== undefined) webhook.rateLimitPerMinute = input.rateLimitPerMinute\n if (input.autoDisableThreshold !== undefined) webhook.autoDisableThreshold = input.autoDisableThreshold\n if (input.integrationId !== undefined) webhook.integrationId = input.integrationId\n if (input.isActive !== undefined) webhook.isActive = input.isActive\n\n await em.flush()\n\n await emitWebhooksEvent('webhooks.webhook.updated', {\n webhookId: webhook.id,\n organizationId: webhook.organizationId,\n tenantId: webhook.tenantId,\n }, { persistent: true })\n\n return json(serializeWebhookDetail(webhook))\n}\n\nexport async function DELETE(request: Request, context: RouteContext): Promise<Response> {\n const scope = await resolveWebhookRequestScope(request)\n if (scope instanceof Response) return scope\n\n const params = await context.params\n const em = scope.em.fork()\n const webhook = await findScopedWebhook(em, scope, params.id)\n const { translate } = await resolveTranslations()\n\n if (!webhook) {\n return json({ error: translate('webhooks.errors.notFound', 'Webhook not found') }, { status: 404 })\n }\n\n webhook.deletedAt = new Date()\n await em.flush()\n\n await emitWebhooksEvent('webhooks.webhook.deleted', {\n webhookId: webhook.id,\n organizationId: webhook.organizationId,\n tenantId: webhook.tenantId,\n }, { persistent: true })\n\n return json({ success: true })\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Get webhook detail',\n description: 'Returns a single webhook configuration for the current tenant scope.',\n methods: {\n GET: {\n summary: 'Get webhook',\n description: 'Returns webhook configuration, masked secret metadata, and delivery settings.',\n pathParams: z.object({ id: z.string().uuid() }),\n responses: [{ status: 200, description: 'Webhook detail', schema: webhookDetailResponseSchema }],\n errors: [\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 404, description: 'Webhook not found', schema: errorSchema },\n ],\n },\n PUT: {\n summary: 'Update webhook',\n description: 'Updates a single webhook configuration.',\n pathParams: z.object({ id: z.string().uuid() }),\n requestBody: {\n contentType: 'application/json',\n schema: webhookUpdateSchema,\n description: 'Webhook fields to update.',\n },\n responses: [{ status: 200, description: 'Webhook updated', schema: webhookDetailResponseSchema }],\n errors: [\n { status: 400, description: 'Invalid payload', schema: errorSchema },\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 404, description: 'Webhook not found', schema: errorSchema },\n ],\n },\n DELETE: {\n summary: 'Delete webhook',\n description: 'Soft-deletes a webhook endpoint.',\n pathParams: z.object({ id: z.string().uuid() }),\n responses: [{ status: 200, description: 'Webhook deleted', schema: deleteResponseSchema }],\n errors: [\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 404, description: 'Webhook not found', schema: errorSchema },\n ],\n },\n },\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { emitWebhooksEvent } from '../../../events'\nimport { findScopedWebhook, json, resolveWebhookRequestScope, serializeWebhookDetail } from '../../helpers'\nimport { webhookUpdateSchema } from '../../../data/validators'\nimport { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['webhooks.view'] },\n PUT: { requireAuth: true, requireFeatures: ['webhooks.manage'] },\n DELETE: { requireAuth: true, requireFeatures: ['webhooks.manage'] },\n}\n\ninterface RouteContext {\n params: Promise<{ id: string }>\n}\n\nconst webhookDetailResponseSchema = z.object({\n id: z.string(),\n name: z.string(),\n description: z.string().nullable(),\n url: z.string(),\n subscribedEvents: z.array(z.string()),\n httpMethod: z.string(),\n isActive: z.boolean(),\n deliveryStrategy: z.string(),\n maxRetries: z.number(),\n consecutiveFailures: z.number(),\n lastSuccessAt: z.string().nullable(),\n lastFailureAt: z.string().nullable(),\n createdAt: z.string(),\n updatedAt: z.string(),\n customHeaders: z.record(z.string(), z.string()).nullable(),\n strategyConfig: z.record(z.string(), z.unknown()).nullable(),\n timeoutMs: z.number(),\n rateLimitPerMinute: z.number(),\n autoDisableThreshold: z.number(),\n integrationId: z.string().nullable(),\n maskedSecret: z.string(),\n previousSecretSetAt: z.string().nullable(),\n})\n\nconst errorSchema = z.object({ error: z.string() })\nconst deleteResponseSchema = z.object({ success: z.literal(true) })\n\nexport async function GET(request: Request, context: RouteContext): Promise<Response> {\n const scope = await resolveWebhookRequestScope(request)\n if (scope instanceof Response) return scope\n\n const params = await context.params\n const webhook = await findScopedWebhook(scope.em.fork(), scope, params.id)\n\n if (!webhook) {\n return json({ error: 'Webhook not found' }, { status: 404 })\n }\n\n return json(serializeWebhookDetail(webhook))\n}\n\nexport async function PUT(request: Request, context: RouteContext): Promise<Response> {\n const scope = await resolveWebhookRequestScope(request)\n if (scope instanceof Response) return scope\n\n const params = await context.params\n const em = scope.em.fork()\n const webhook = await findScopedWebhook(em, scope, params.id)\n\n if (!webhook) {\n return json({ error: 'Webhook not found' }, { status: 404 })\n }\n\n try {\n enforceCommandOptimisticLock({\n resourceKind: 'webhooks.endpoint',\n resourceId: webhook.id,\n current: webhook.updatedAt ?? null,\n request,\n })\n } catch (err) {\n if (isCrudHttpError(err)) return json(err.body, { status: err.status })\n throw err\n }\n\n const parsed = webhookUpdateSchema.safeParse(await request.json().catch(() => null))\n if (!parsed.success) {\n return json({ error: 'Invalid request payload' }, { status: 400 })\n }\n\n const input = parsed.data\n if (input.name !== undefined) webhook.name = input.name\n if (input.description !== undefined) webhook.description = input.description\n if (input.url !== undefined) webhook.url = input.url\n if (input.subscribedEvents !== undefined) webhook.subscribedEvents = input.subscribedEvents\n if (input.httpMethod !== undefined) webhook.httpMethod = input.httpMethod\n if (input.customHeaders !== undefined) webhook.customHeaders = input.customHeaders\n if (input.deliveryStrategy !== undefined) webhook.deliveryStrategy = input.deliveryStrategy\n if (input.strategyConfig !== undefined) webhook.strategyConfig = input.strategyConfig\n if (input.maxRetries !== undefined) webhook.maxRetries = input.maxRetries\n if (input.timeoutMs !== undefined) webhook.timeoutMs = input.timeoutMs\n if (input.rateLimitPerMinute !== undefined) webhook.rateLimitPerMinute = input.rateLimitPerMinute\n if (input.autoDisableThreshold !== undefined) webhook.autoDisableThreshold = input.autoDisableThreshold\n if (input.integrationId !== undefined) webhook.integrationId = input.integrationId\n if (input.isActive !== undefined) webhook.isActive = input.isActive\n\n await em.flush()\n\n await emitWebhooksEvent('webhooks.webhook.updated', {\n webhookId: webhook.id,\n organizationId: webhook.organizationId,\n tenantId: webhook.tenantId,\n }, { persistent: true })\n\n return json(serializeWebhookDetail(webhook))\n}\n\nexport async function DELETE(request: Request, context: RouteContext): Promise<Response> {\n const scope = await resolveWebhookRequestScope(request)\n if (scope instanceof Response) return scope\n\n const params = await context.params\n const em = scope.em.fork()\n const webhook = await findScopedWebhook(em, scope, params.id)\n const { translate } = await resolveTranslations()\n\n if (!webhook) {\n return json({ error: translate('webhooks.errors.notFound', 'Webhook not found') }, { status: 404 })\n }\n\n try {\n enforceCommandOptimisticLock({\n resourceKind: 'webhooks.endpoint',\n resourceId: webhook.id,\n current: webhook.updatedAt ?? null,\n request,\n })\n } catch (err) {\n if (isCrudHttpError(err)) return json(err.body, { status: err.status })\n throw err\n }\n\n webhook.deletedAt = new Date()\n await em.flush()\n\n await emitWebhooksEvent('webhooks.webhook.deleted', {\n webhookId: webhook.id,\n organizationId: webhook.organizationId,\n tenantId: webhook.tenantId,\n }, { persistent: true })\n\n return json({ success: true })\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Get webhook detail',\n description: 'Returns a single webhook configuration for the current tenant scope.',\n methods: {\n GET: {\n summary: 'Get webhook',\n description: 'Returns webhook configuration, masked secret metadata, and delivery settings.',\n pathParams: z.object({ id: z.string().uuid() }),\n responses: [{ status: 200, description: 'Webhook detail', schema: webhookDetailResponseSchema }],\n errors: [\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 404, description: 'Webhook not found', schema: errorSchema },\n ],\n },\n PUT: {\n summary: 'Update webhook',\n description: 'Updates a single webhook configuration.',\n pathParams: z.object({ id: z.string().uuid() }),\n requestBody: {\n contentType: 'application/json',\n schema: webhookUpdateSchema,\n description: 'Webhook fields to update.',\n },\n responses: [{ status: 200, description: 'Webhook updated', schema: webhookDetailResponseSchema }],\n errors: [\n { status: 400, description: 'Invalid payload', schema: errorSchema },\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 404, description: 'Webhook not found', schema: errorSchema },\n ],\n },\n DELETE: {\n summary: 'Delete webhook',\n description: 'Soft-deletes a webhook endpoint.',\n pathParams: z.object({ id: z.string().uuid() }),\n responses: [{ status: 200, description: 'Webhook deleted', schema: deleteResponseSchema }],\n errors: [\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 404, description: 'Webhook not found', schema: errorSchema },\n ],\n },\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -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,
@@ -268,10 +270,14 @@ function WebhookDetailPage() {
268
270
  const handleDelete = React.useCallback(async () => {
269
271
  if (!webhook) return;
270
272
  try {
271
- await deleteCrud(`webhooks/${encodeURIComponent(webhook.id)}`, { fallbackResult: null });
273
+ await deleteCrud(`webhooks/${encodeURIComponent(webhook.id)}`, {
274
+ fallbackResult: null,
275
+ headers: buildOptimisticLockHeader(webhook.updatedAt)
276
+ });
272
277
  flash(t("webhooks.list.deleteSuccess"), "success");
273
278
  router.push("/backend/webhooks");
274
- } catch {
279
+ } catch (error2) {
280
+ if (surfaceRecordConflict(error2, t)) return;
275
281
  flash(t("webhooks.list.deleteError"), "error");
276
282
  }
277
283
  }, [router, t, webhook]);
@@ -396,6 +402,7 @@ function WebhookDetailPage() {
396
402
  fields,
397
403
  groups,
398
404
  initialValues: createWebhookInitialValues(webhook),
405
+ optimisticLockUpdatedAt: webhook.updatedAt,
399
406
  submitLabel: t("common.save"),
400
407
  cancelHref: `/backend/webhooks/${webhook.id}`,
401
408
  contentHeader,