@open-mercato/checkout 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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/checkout/__integration__/TC-CHKT-039-stale-delete-lock.spec.js +66 -0
- package/dist/modules/checkout/__integration__/TC-CHKT-039-stale-delete-lock.spec.js.map +7 -0
- package/dist/modules/checkout/commands/links.js +13 -0
- package/dist/modules/checkout/commands/links.js.map +2 -2
- package/dist/modules/checkout/commands/templates.js +13 -0
- package/dist/modules/checkout/commands/templates.js.map +2 -2
- package/dist/modules/checkout/components/LinkTemplateForm.js +26 -7
- package/dist/modules/checkout/components/LinkTemplateForm.js.map +2 -2
- package/package.json +5 -5
- package/src/modules/checkout/__integration__/TC-CHKT-039-stale-delete-lock.spec.ts +98 -0
- package/src/modules/checkout/commands/__tests__/optimistic-lock.test.ts +261 -0
- package/src/modules/checkout/commands/links.ts +13 -0
- package/src/modules/checkout/commands/templates.ts +13 -0
- package/src/modules/checkout/components/LinkTemplateForm.tsx +26 -7
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { expect, test } from '@playwright/test'
|
|
2
|
+
import { getAuthToken } from '@open-mercato/core/modules/core/__integration__/helpers/api'
|
|
3
|
+
import { createFixedTemplateInput, createLinkFixture, readLink } from './helpers/fixtures'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* TC-CHKT-039: OSS optimistic locking now guards the pay-link/template DELETE.
|
|
7
|
+
*
|
|
8
|
+
* QA round-6 (PR #2055): after a save conflict surfaced the unified conflict
|
|
9
|
+
* bar, clicking Delete still removed the stale record. The pay-link/template
|
|
10
|
+
* delete path was unguarded — the client sent no version header and the
|
|
11
|
+
* `checkout.link.delete` / `checkout.template.delete` commands never called
|
|
12
|
+
* `enforceCommandOptimisticLock` (only the *.update commands did).
|
|
13
|
+
*
|
|
14
|
+
* This spec proves the server contract the UI relies on:
|
|
15
|
+
* - GET detail exposes `updatedAt`.
|
|
16
|
+
* - DELETE without the header succeeds (strictly additive).
|
|
17
|
+
* - DELETE with a stale header returns 409 with the structured conflict body.
|
|
18
|
+
* - DELETE with a fresh header deletes the record.
|
|
19
|
+
*
|
|
20
|
+
* Requires `OM_OPTIMISTIC_LOCK=all` (CI default).
|
|
21
|
+
*/
|
|
22
|
+
const OPTIMISTIC_LOCK_HEADER = 'x-om-ext-optimistic-lock-expected-updated-at'
|
|
23
|
+
|
|
24
|
+
function readUpdatedAt(record: Record<string, unknown>): string {
|
|
25
|
+
const raw = record.updatedAt ?? record.updated_at
|
|
26
|
+
expect(typeof raw, 'link detail should expose updatedAt as a string').toBe('string')
|
|
27
|
+
return new Date(Date.parse(raw as string)).toISOString()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test.describe('TC-CHKT-039: pay-link stale DELETE optimistic-lock guard', () => {
|
|
31
|
+
test('stale DELETE returns 409; fresh DELETE succeeds; header-less stays backward-compatible', async ({ request }) => {
|
|
32
|
+
const token = await getAuthToken(request)
|
|
33
|
+
|
|
34
|
+
// Two links: one to prove the stale→409→fresh path, one to prove the
|
|
35
|
+
// strictly-additive header-less delete still works.
|
|
36
|
+
const guarded = await createLinkFixture(request, token, createFixedTemplateInput())
|
|
37
|
+
const additive = await createLinkFixture(request, token, createFixedTemplateInput())
|
|
38
|
+
let guardedDeleted = false
|
|
39
|
+
let additiveDeleted = false
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const detail = await readLink(request, token, guarded.id)
|
|
43
|
+
const t0 = readUpdatedAt(detail as Record<string, unknown>)
|
|
44
|
+
|
|
45
|
+
// A save advances the version, making t0 stale.
|
|
46
|
+
const bump = await request.fetch(`/api/checkout/links/${encodeURIComponent(guarded.id)}`, {
|
|
47
|
+
method: 'PUT',
|
|
48
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
49
|
+
data: { subtitle: 'bumped for stale-delete test' },
|
|
50
|
+
})
|
|
51
|
+
expect(bump.status(), 'PUT bump should succeed').toBeLessThan(300)
|
|
52
|
+
|
|
53
|
+
// Stale DELETE → 409 with the structured conflict body; record survives.
|
|
54
|
+
const staleDelete = await request.fetch(`/api/checkout/links/${encodeURIComponent(guarded.id)}`, {
|
|
55
|
+
method: 'DELETE',
|
|
56
|
+
headers: { Authorization: `Bearer ${token}`, [OPTIMISTIC_LOCK_HEADER]: t0 },
|
|
57
|
+
})
|
|
58
|
+
expect(staleDelete.status(), 'DELETE with a stale header should return 409').toBe(409)
|
|
59
|
+
expect((await staleDelete.json()) as Record<string, unknown>).toMatchObject({
|
|
60
|
+
code: 'optimistic_lock_conflict',
|
|
61
|
+
expectedUpdatedAt: t0,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const stillThere = await readLink(request, token, guarded.id)
|
|
65
|
+
const t1 = readUpdatedAt(stillThere as Record<string, unknown>)
|
|
66
|
+
expect(t1, 'stale delete must NOT have removed the record').not.toBe(t0)
|
|
67
|
+
|
|
68
|
+
// Fresh DELETE → succeeds.
|
|
69
|
+
const freshDelete = await request.fetch(`/api/checkout/links/${encodeURIComponent(guarded.id)}`, {
|
|
70
|
+
method: 'DELETE',
|
|
71
|
+
headers: { Authorization: `Bearer ${token}`, [OPTIMISTIC_LOCK_HEADER]: t1 },
|
|
72
|
+
})
|
|
73
|
+
expect(freshDelete.status(), 'DELETE with a fresh header should succeed').toBeLessThan(300)
|
|
74
|
+
guardedDeleted = true
|
|
75
|
+
|
|
76
|
+
// Header-less DELETE still works (strictly additive).
|
|
77
|
+
const nohdrDelete = await request.fetch(`/api/checkout/links/${encodeURIComponent(additive.id)}`, {
|
|
78
|
+
method: 'DELETE',
|
|
79
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
80
|
+
})
|
|
81
|
+
expect(nohdrDelete.status(), 'DELETE without a header should succeed').toBeLessThan(300)
|
|
82
|
+
additiveDeleted = true
|
|
83
|
+
} finally {
|
|
84
|
+
if (!guardedDeleted) {
|
|
85
|
+
await request.fetch(`/api/checkout/links/${encodeURIComponent(guarded.id)}`, {
|
|
86
|
+
method: 'DELETE',
|
|
87
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
88
|
+
}).catch(() => undefined)
|
|
89
|
+
}
|
|
90
|
+
if (!additiveDeleted) {
|
|
91
|
+
await request.fetch(`/api/checkout/links/${encodeURIComponent(additive.id)}`, {
|
|
92
|
+
method: 'DELETE',
|
|
93
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
94
|
+
}).catch(() => undefined)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
})
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
|
|
3
|
+
import { commandRegistry } from '@open-mercato/shared/lib/commands/registry'
|
|
4
|
+
import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands/types'
|
|
5
|
+
import { OPTIMISTIC_LOCK_HEADER_NAME } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
|
|
6
|
+
|
|
7
|
+
const ORG_ID = '123e4567-e89b-12d3-a456-426614174000'
|
|
8
|
+
const TENANT_ID = '123e4567-e89b-12d3-a456-426614174001'
|
|
9
|
+
const LINK_ID = '123e4567-e89b-12d3-a456-426614174010'
|
|
10
|
+
const TEMPLATE_ID = '123e4567-e89b-12d3-a456-426614174020'
|
|
11
|
+
const CURRENT_VERSION = '2026-06-01T10:00:00.000Z'
|
|
12
|
+
const STALE_VERSION = '2026-06-01T09:00:00.000Z'
|
|
13
|
+
|
|
14
|
+
const FLUSH_SENTINEL = 'FLUSH_REACHED_AFTER_LOCK'
|
|
15
|
+
|
|
16
|
+
const mockFindOneWithDecryption = jest.fn()
|
|
17
|
+
|
|
18
|
+
jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
|
|
19
|
+
findOneWithDecryption: jest.fn((...args: unknown[]) => mockFindOneWithDecryption(...args)),
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
jest.mock('../../lib/gatewayProviderAvailability', () => ({
|
|
23
|
+
ensureGatewayProviderConfigured: jest.fn(async () => undefined),
|
|
24
|
+
getGatewayProviderConfigurationMessageKey: jest.fn(() => null),
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
jest.mock('@open-mercato/shared/lib/commands/helpers', () => ({
|
|
28
|
+
setCustomFieldsIfAny: jest.fn(async () => undefined),
|
|
29
|
+
}))
|
|
30
|
+
|
|
31
|
+
jest.mock('@open-mercato/shared/lib/commands/customFieldSnapshots', () => ({
|
|
32
|
+
loadCustomFieldSnapshot: jest.fn(async () => ({})),
|
|
33
|
+
buildCustomFieldResetMap: jest.fn(() => ({})),
|
|
34
|
+
}))
|
|
35
|
+
|
|
36
|
+
jest.mock('@open-mercato/shared/lib/crud/custom-fields', () => ({
|
|
37
|
+
loadCustomFieldValues: jest.fn(async () => ({})),
|
|
38
|
+
}))
|
|
39
|
+
|
|
40
|
+
jest.mock('../../events', () => ({
|
|
41
|
+
emitCheckoutEvent: jest.fn(async () => undefined),
|
|
42
|
+
}))
|
|
43
|
+
|
|
44
|
+
// Importing the command modules registers the handlers via registerCommand.
|
|
45
|
+
import '../links'
|
|
46
|
+
import '../templates'
|
|
47
|
+
|
|
48
|
+
// flush runs immediately AFTER the lock guard in both update commands. Throwing
|
|
49
|
+
// a recognizable sentinel here lets the match / no-header cases prove the lock
|
|
50
|
+
// guard PASSED, without mocking the full post-flush path (custom fields, events).
|
|
51
|
+
const mockEm = {
|
|
52
|
+
flush: jest.fn(async () => {
|
|
53
|
+
throw new Error(FLUSH_SENTINEL)
|
|
54
|
+
}),
|
|
55
|
+
// delete commands count active transactions before flushing; 0 lets the
|
|
56
|
+
// happy path reach the flush sentinel so we can prove the lock guard passed.
|
|
57
|
+
count: jest.fn(async () => 0),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const mockDataEngine = {}
|
|
61
|
+
|
|
62
|
+
function makeContext(headerVersion: string | null): CommandRuntimeContext {
|
|
63
|
+
const headers = new Headers()
|
|
64
|
+
if (headerVersion) headers.set(OPTIMISTIC_LOCK_HEADER_NAME, headerVersion)
|
|
65
|
+
const request = new Request('http://localhost/api/checkout/links/x', { method: 'PUT', headers })
|
|
66
|
+
return {
|
|
67
|
+
container: {
|
|
68
|
+
resolve: (token: string) => {
|
|
69
|
+
if (token === 'em') return mockEm
|
|
70
|
+
if (token === 'dataEngine') return mockDataEngine
|
|
71
|
+
if (token === 'paymentGatewayDescriptorService') return {}
|
|
72
|
+
return null
|
|
73
|
+
},
|
|
74
|
+
} as unknown as CommandRuntimeContext['container'],
|
|
75
|
+
auth: { orgId: ORG_ID, tenantId: TENANT_ID } as CommandRuntimeContext['auth'],
|
|
76
|
+
organizationScope: null,
|
|
77
|
+
selectedOrganizationId: ORG_ID,
|
|
78
|
+
organizationIds: [ORG_ID],
|
|
79
|
+
request,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function linkRecord() {
|
|
84
|
+
return {
|
|
85
|
+
id: LINK_ID,
|
|
86
|
+
organizationId: ORG_ID,
|
|
87
|
+
tenantId: TENANT_ID,
|
|
88
|
+
name: 'Pay link',
|
|
89
|
+
title: null,
|
|
90
|
+
slug: 'pay-link',
|
|
91
|
+
templateId: null,
|
|
92
|
+
status: 'draft',
|
|
93
|
+
isLocked: false,
|
|
94
|
+
pricingMode: 'fixed',
|
|
95
|
+
gatewayProviderKey: null,
|
|
96
|
+
gatewaySettings: {},
|
|
97
|
+
fixedPriceAmount: null,
|
|
98
|
+
fixedPriceOriginalAmount: null,
|
|
99
|
+
customAmountMin: null,
|
|
100
|
+
customAmountMax: null,
|
|
101
|
+
passwordHash: null,
|
|
102
|
+
updatedAt: new Date(CURRENT_VERSION),
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function templateRecord() {
|
|
107
|
+
return {
|
|
108
|
+
id: TEMPLATE_ID,
|
|
109
|
+
organizationId: ORG_ID,
|
|
110
|
+
tenantId: TENANT_ID,
|
|
111
|
+
name: 'Template',
|
|
112
|
+
title: null,
|
|
113
|
+
status: 'draft',
|
|
114
|
+
pricingMode: 'fixed',
|
|
115
|
+
gatewayProviderKey: null,
|
|
116
|
+
gatewaySettings: {},
|
|
117
|
+
fixedPriceAmount: null,
|
|
118
|
+
fixedPriceOriginalAmount: null,
|
|
119
|
+
customAmountMin: null,
|
|
120
|
+
customAmountMax: null,
|
|
121
|
+
passwordHash: null,
|
|
122
|
+
updatedAt: new Date(CURRENT_VERSION),
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function runUpdate(commandId: string, input: Record<string, unknown>, headerVersion: string | null) {
|
|
127
|
+
const handler = commandRegistry.get(commandId)
|
|
128
|
+
if (!handler) throw new Error(`Command ${commandId} not registered`)
|
|
129
|
+
return handler.execute(input, makeContext(headerVersion))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
describe('checkout command optimistic locking', () => {
|
|
133
|
+
beforeEach(() => {
|
|
134
|
+
jest.clearAllMocks()
|
|
135
|
+
delete process.env.OM_OPTIMISTIC_LOCK
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe('checkout.link.update', () => {
|
|
139
|
+
beforeEach(() => {
|
|
140
|
+
mockFindOneWithDecryption.mockResolvedValue(linkRecord())
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('rejects a stale expected version with a structured 409 conflict', async () => {
|
|
144
|
+
expect.assertions(4)
|
|
145
|
+
try {
|
|
146
|
+
await runUpdate('checkout.link.update', { id: LINK_ID, status: 'draft' }, STALE_VERSION)
|
|
147
|
+
} catch (error) {
|
|
148
|
+
const httpError = error as { status?: number; body?: Record<string, unknown> }
|
|
149
|
+
expect(httpError.status).toBe(409)
|
|
150
|
+
expect(httpError.body?.code).toBe('optimistic_lock_conflict')
|
|
151
|
+
expect(httpError.body?.currentUpdatedAt).toBe(CURRENT_VERSION)
|
|
152
|
+
expect(mockEm.flush).not.toHaveBeenCalled()
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('passes the lock guard when the expected version matches', async () => {
|
|
157
|
+
await expect(
|
|
158
|
+
runUpdate('checkout.link.update', { id: LINK_ID, status: 'draft' }, CURRENT_VERSION),
|
|
159
|
+
).rejects.toThrow(FLUSH_SENTINEL)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('passes the lock guard when no expected-version header is sent (strictly additive)', async () => {
|
|
163
|
+
await expect(
|
|
164
|
+
runUpdate('checkout.link.update', { id: LINK_ID, status: 'draft' }, null),
|
|
165
|
+
).rejects.toThrow(FLUSH_SENTINEL)
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
describe('checkout.template.update', () => {
|
|
170
|
+
beforeEach(() => {
|
|
171
|
+
mockFindOneWithDecryption.mockResolvedValue(templateRecord())
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('rejects a stale expected version with a structured 409 conflict', async () => {
|
|
175
|
+
expect.assertions(3)
|
|
176
|
+
try {
|
|
177
|
+
await runUpdate('checkout.template.update', { id: TEMPLATE_ID, status: 'draft' }, STALE_VERSION)
|
|
178
|
+
} catch (error) {
|
|
179
|
+
const httpError = error as { status?: number; body?: Record<string, unknown> }
|
|
180
|
+
expect(httpError.status).toBe(409)
|
|
181
|
+
expect(httpError.body?.code).toBe('optimistic_lock_conflict')
|
|
182
|
+
expect(mockEm.flush).not.toHaveBeenCalled()
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('passes the lock guard when the expected version matches', async () => {
|
|
187
|
+
await expect(
|
|
188
|
+
runUpdate('checkout.template.update', { id: TEMPLATE_ID, status: 'draft' }, CURRENT_VERSION),
|
|
189
|
+
).rejects.toThrow(FLUSH_SENTINEL)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('passes the lock guard when no expected-version header is sent', async () => {
|
|
193
|
+
await expect(
|
|
194
|
+
runUpdate('checkout.template.update', { id: TEMPLATE_ID, status: 'draft' }, null),
|
|
195
|
+
).rejects.toThrow(FLUSH_SENTINEL)
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
// QA round-6 (#2055): a stale DELETE after a conflict still deleted the
|
|
200
|
+
// record because the delete commands did not enforce the lock. These guard
|
|
201
|
+
// that the header now produces a 409 on a stale delete.
|
|
202
|
+
describe('checkout.link.delete', () => {
|
|
203
|
+
beforeEach(() => {
|
|
204
|
+
mockFindOneWithDecryption.mockResolvedValue(linkRecord())
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('rejects a stale expected version with a structured 409 conflict', async () => {
|
|
208
|
+
expect.assertions(3)
|
|
209
|
+
try {
|
|
210
|
+
await runUpdate('checkout.link.delete', { id: LINK_ID }, STALE_VERSION)
|
|
211
|
+
} catch (error) {
|
|
212
|
+
const httpError = error as { status?: number; body?: Record<string, unknown> }
|
|
213
|
+
expect(httpError.status).toBe(409)
|
|
214
|
+
expect(httpError.body?.code).toBe('optimistic_lock_conflict')
|
|
215
|
+
expect(mockEm.flush).not.toHaveBeenCalled()
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('passes the lock guard when the expected version matches', async () => {
|
|
220
|
+
await expect(
|
|
221
|
+
runUpdate('checkout.link.delete', { id: LINK_ID }, CURRENT_VERSION),
|
|
222
|
+
).rejects.toThrow(FLUSH_SENTINEL)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('passes the lock guard when no expected-version header is sent (strictly additive)', async () => {
|
|
226
|
+
await expect(
|
|
227
|
+
runUpdate('checkout.link.delete', { id: LINK_ID }, null),
|
|
228
|
+
).rejects.toThrow(FLUSH_SENTINEL)
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
describe('checkout.template.delete', () => {
|
|
233
|
+
beforeEach(() => {
|
|
234
|
+
mockFindOneWithDecryption.mockResolvedValue(templateRecord())
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('rejects a stale expected version with a structured 409 conflict', async () => {
|
|
238
|
+
expect.assertions(3)
|
|
239
|
+
try {
|
|
240
|
+
await runUpdate('checkout.template.delete', { id: TEMPLATE_ID }, STALE_VERSION)
|
|
241
|
+
} catch (error) {
|
|
242
|
+
const httpError = error as { status?: number; body?: Record<string, unknown> }
|
|
243
|
+
expect(httpError.status).toBe(409)
|
|
244
|
+
expect(httpError.body?.code).toBe('optimistic_lock_conflict')
|
|
245
|
+
expect(mockEm.flush).not.toHaveBeenCalled()
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('passes the lock guard when the expected version matches', async () => {
|
|
250
|
+
await expect(
|
|
251
|
+
runUpdate('checkout.template.delete', { id: TEMPLATE_ID }, CURRENT_VERSION),
|
|
252
|
+
).rejects.toThrow(FLUSH_SENTINEL)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('passes the lock guard when no expected-version header is sent', async () => {
|
|
256
|
+
await expect(
|
|
257
|
+
runUpdate('checkout.template.delete', { id: TEMPLATE_ID }, null),
|
|
258
|
+
).rejects.toThrow(FLUSH_SENTINEL)
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
})
|
|
@@ -6,6 +6,7 @@ import { buildCustomFieldResetMap, loadCustomFieldSnapshot } from '@open-mercato
|
|
|
6
6
|
import { setCustomFieldsIfAny } from '@open-mercato/shared/lib/commands/helpers'
|
|
7
7
|
import { loadCustomFieldValues } from '@open-mercato/shared/lib/crud/custom-fields'
|
|
8
8
|
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
9
|
+
import { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
|
|
9
10
|
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
10
11
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
11
12
|
import { CheckoutLink, CheckoutLinkTemplate, CheckoutTransaction } from '../data/entities'
|
|
@@ -234,6 +235,12 @@ const updateLinkCommand: CommandHandler<Record<string, unknown>, { ok: true; slu
|
|
|
234
235
|
deletedAt: null,
|
|
235
236
|
}, undefined, scope)
|
|
236
237
|
if (!link) throw new CrudHttpError(404, { error: 'Link not found' })
|
|
238
|
+
enforceCommandOptimisticLock({
|
|
239
|
+
resourceKind: 'checkout.link',
|
|
240
|
+
resourceId: link.id,
|
|
241
|
+
current: link.updatedAt ?? null,
|
|
242
|
+
request: ctx.request ?? null,
|
|
243
|
+
})
|
|
237
244
|
if (link.isLocked) {
|
|
238
245
|
throw new CrudHttpError(422, { error: 'This link has active transactions and cannot be edited' })
|
|
239
246
|
}
|
|
@@ -380,6 +387,12 @@ const deleteLinkCommand: CommandHandler<Record<string, unknown>, { ok: true }> =
|
|
|
380
387
|
deletedAt: null,
|
|
381
388
|
}, undefined, scope)
|
|
382
389
|
if (!link) throw new CrudHttpError(404, { error: 'Link not found' })
|
|
390
|
+
enforceCommandOptimisticLock({
|
|
391
|
+
resourceKind: 'checkout.link',
|
|
392
|
+
resourceId: link.id,
|
|
393
|
+
current: link.updatedAt ?? null,
|
|
394
|
+
request: ctx.request ?? null,
|
|
395
|
+
})
|
|
383
396
|
const activeCount = await em.count(CheckoutTransaction, {
|
|
384
397
|
linkId: link.id,
|
|
385
398
|
organizationId: scope.organizationId,
|
|
@@ -5,6 +5,7 @@ import { registerCommand } from '@open-mercato/shared/lib/commands'
|
|
|
5
5
|
import { buildCustomFieldResetMap, loadCustomFieldSnapshot } from '@open-mercato/shared/lib/commands/customFieldSnapshots'
|
|
6
6
|
import { setCustomFieldsIfAny } from '@open-mercato/shared/lib/commands/helpers'
|
|
7
7
|
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
8
|
+
import { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
|
|
8
9
|
import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
9
10
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
10
11
|
import { CheckoutLink, CheckoutLinkTemplate } from '../data/entities'
|
|
@@ -226,6 +227,12 @@ const updateTemplateCommand: CommandHandler<Record<string, unknown>, { ok: true
|
|
|
226
227
|
deletedAt: null,
|
|
227
228
|
}, undefined, scope)
|
|
228
229
|
if (!template) throw new CrudHttpError(404, { error: 'Template not found' })
|
|
230
|
+
enforceCommandOptimisticLock({
|
|
231
|
+
resourceKind: 'checkout.template',
|
|
232
|
+
resourceId: template.id,
|
|
233
|
+
current: template.updatedAt ?? null,
|
|
234
|
+
request: ctx.request ?? null,
|
|
235
|
+
})
|
|
229
236
|
const beforeCustom = await loadCustomFieldSnapshot(em, {
|
|
230
237
|
entityId: CHECKOUT_ENTITY_IDS.template,
|
|
231
238
|
recordId: template.id,
|
|
@@ -376,6 +383,12 @@ const deleteTemplateCommand: CommandHandler<Record<string, unknown>, { ok: true
|
|
|
376
383
|
deletedAt: null,
|
|
377
384
|
}, undefined, scope)
|
|
378
385
|
if (!template) throw new CrudHttpError(404, { error: 'Template not found' })
|
|
386
|
+
enforceCommandOptimisticLock({
|
|
387
|
+
resourceKind: 'checkout.template',
|
|
388
|
+
resourceId: template.id,
|
|
389
|
+
current: template.updatedAt ?? null,
|
|
390
|
+
request: ctx.request ?? null,
|
|
391
|
+
})
|
|
379
392
|
template.deletedAt = new Date()
|
|
380
393
|
await em.flush()
|
|
381
394
|
await emitCheckoutEvent('checkout.template.deleted', {
|
|
@@ -24,7 +24,9 @@ import { CrudForm, type CrudField, type CrudFormGroup, type CrudFormGroupCompone
|
|
|
24
24
|
import { ComboboxInput, type ComboboxOption } from '@open-mercato/ui/backend/inputs'
|
|
25
25
|
import { Page, PageBody } from '@open-mercato/ui/backend/Page'
|
|
26
26
|
import { SwitchableMarkdownInput } from '@open-mercato/ui/backend/inputs'
|
|
27
|
-
import { apiCall, apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
|
|
27
|
+
import { apiCall, apiCallOrThrow, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
|
|
28
|
+
import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
|
|
29
|
+
import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
|
|
28
30
|
import { collectCustomFieldValues } from '@open-mercato/ui/backend/utils/customFieldValues'
|
|
29
31
|
import { Button } from '@open-mercato/ui/primitives/button'
|
|
30
32
|
import { ColorPicker } from '@open-mercato/ui/primitives/color-picker'
|
|
@@ -1638,11 +1640,20 @@ export function LinkTemplateForm({ mode, recordId }: Props) {
|
|
|
1638
1640
|
customFields: collectCustomFieldValues(values),
|
|
1639
1641
|
}
|
|
1640
1642
|
const endpoint = `/api/checkout/${mode === 'link' ? 'links' : 'templates'}${recordId ? `/${encodeURIComponent(recordId)}` : ''}`
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1643
|
+
let response: { id?: string; slug?: string; ok?: boolean }
|
|
1644
|
+
try {
|
|
1645
|
+
response = await withScopedApiRequestHeaders(
|
|
1646
|
+
recordId ? buildOptimisticLockHeader(readString(initialValues?.updatedAt) || null) : {},
|
|
1647
|
+
() => readApiResultOrThrow<{ id?: string; slug?: string; ok?: boolean }>(endpoint, {
|
|
1648
|
+
method: recordId ? 'PUT' : 'POST',
|
|
1649
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1650
|
+
body: JSON.stringify(payload),
|
|
1651
|
+
}),
|
|
1652
|
+
)
|
|
1653
|
+
} catch (error) {
|
|
1654
|
+
if (surfaceRecordConflict(error, t)) return
|
|
1655
|
+
throw error
|
|
1656
|
+
}
|
|
1646
1657
|
const targetId = recordId ?? (typeof response?.id === 'string' ? response.id : null)
|
|
1647
1658
|
const logoAttachmentId = readString(values.logoAttachmentId)
|
|
1648
1659
|
if (
|
|
@@ -1687,7 +1698,15 @@ export function LinkTemplateForm({ mode, recordId }: Props) {
|
|
|
1687
1698
|
: `/backend/checkout/templates?flash=${encodeURIComponent(t('checkout.common.flash.saved'))}&type=success`
|
|
1688
1699
|
}}
|
|
1689
1700
|
onDelete={recordId ? async () => {
|
|
1690
|
-
|
|
1701
|
+
try {
|
|
1702
|
+
await withScopedApiRequestHeaders(
|
|
1703
|
+
buildOptimisticLockHeader(readString(initialValues?.updatedAt) || null),
|
|
1704
|
+
() => apiCallOrThrow(`/api/checkout/${mode === 'link' ? 'links' : 'templates'}/${encodeURIComponent(recordId)}`, { method: 'DELETE' }),
|
|
1705
|
+
)
|
|
1706
|
+
} catch (error) {
|
|
1707
|
+
if (surfaceRecordConflict(error, t)) return
|
|
1708
|
+
throw error
|
|
1709
|
+
}
|
|
1691
1710
|
window.location.href = mode === 'link'
|
|
1692
1711
|
? `/backend/checkout/pay-links?flash=${encodeURIComponent(t('checkout.common.flash.deleted'))}&type=success`
|
|
1693
1712
|
: `/backend/checkout/templates?flash=${encodeURIComponent(t('checkout.common.flash.deleted'))}&type=success`
|