@open-mercato/core 0.6.6-develop.6330.1.a261878aa8 → 0.6.6-develop.6331.1.a33b8e99b7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. package/dist/modules/attachments/components/AttachmentPartitionSettings.js +3 -0
  2. package/dist/modules/attachments/components/AttachmentPartitionSettings.js.map +2 -2
  3. package/dist/modules/auth/api/roles/acl/route.js +2 -2
  4. package/dist/modules/auth/api/roles/acl/route.js.map +2 -2
  5. package/dist/modules/auth/api/users/acl/route.js +2 -2
  6. package/dist/modules/auth/api/users/acl/route.js.map +2 -2
  7. package/dist/modules/auth/backend/roles/[id]/edit/page.js +12 -0
  8. package/dist/modules/auth/backend/roles/[id]/edit/page.js.map +2 -2
  9. package/dist/modules/auth/backend/users/[id]/edit/page.js +12 -0
  10. package/dist/modules/auth/backend/users/[id]/edit/page.js.map +2 -2
  11. package/dist/modules/auth/data/entities.js +2 -2
  12. package/dist/modules/auth/data/entities.js.map +2 -2
  13. package/dist/modules/business_rules/api/rules/route.js +3 -3
  14. package/dist/modules/business_rules/api/rules/route.js.map +2 -2
  15. package/dist/modules/business_rules/api/sets/route.js +3 -3
  16. package/dist/modules/business_rules/api/sets/route.js.map +2 -2
  17. package/dist/modules/business_rules/backend/rules/[id]/page.js +12 -1
  18. package/dist/modules/business_rules/backend/rules/[id]/page.js.map +2 -2
  19. package/dist/modules/business_rules/backend/sets/[id]/page.js +12 -1
  20. package/dist/modules/business_rules/backend/sets/[id]/page.js.map +2 -2
  21. package/dist/modules/catalog/api/product-media/route.js.map +2 -2
  22. package/dist/modules/catalog/backend/catalog/categories/[id]/edit/page.js +12 -0
  23. package/dist/modules/catalog/backend/catalog/categories/[id]/edit/page.js.map +2 -2
  24. package/dist/modules/catalog/backend/catalog/products/[id]/page.js +15 -0
  25. package/dist/modules/catalog/backend/catalog/products/[id]/page.js.map +2 -2
  26. package/dist/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.js +12 -1
  27. package/dist/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.js.map +2 -2
  28. package/dist/modules/catalog/components/PriceKindSettings.js +13 -3
  29. package/dist/modules/catalog/components/PriceKindSettings.js.map +2 -2
  30. package/dist/modules/catalog/lib/bulkDelete.js.map +2 -2
  31. package/dist/modules/currencies/backend/currencies/[id]/page.js +15 -3
  32. package/dist/modules/currencies/backend/currencies/[id]/page.js.map +2 -2
  33. package/dist/modules/currencies/backend/currencies/page.js +2 -1
  34. package/dist/modules/currencies/backend/currencies/page.js.map +2 -2
  35. package/dist/modules/currencies/backend/exchange-rates/[id]/page.js +12 -1
  36. package/dist/modules/currencies/backend/exchange-rates/[id]/page.js.map +2 -2
  37. package/dist/modules/currencies/backend/exchange-rates/page.js +3 -2
  38. package/dist/modules/currencies/backend/exchange-rates/page.js.map +2 -2
  39. package/dist/modules/customer_accounts/api/admin/roles/[id].js +3 -3
  40. package/dist/modules/customer_accounts/api/admin/roles/[id].js.map +2 -2
  41. package/dist/modules/customer_accounts/api/admin/users/[id].js +3 -3
  42. package/dist/modules/customer_accounts/api/admin/users/[id].js.map +2 -2
  43. package/dist/modules/customers/api/addresses/route.js +4 -2
  44. package/dist/modules/customers/api/addresses/route.js.map +2 -2
  45. package/dist/modules/customers/api/tags/route.js +8 -3
  46. package/dist/modules/customers/api/tags/route.js.map +2 -2
  47. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js +13 -2
  48. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js.map +2 -2
  49. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.js +24 -9
  50. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.js.map +2 -2
  51. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.js +53 -23
  52. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.js.map +2 -2
  53. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealPipeline.js +13 -3
  54. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealPipeline.js.map +2 -2
  55. package/dist/modules/customers/backend/customers/deals/[id]/page.js +19 -2
  56. package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
  57. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +13 -2
  58. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
  59. package/dist/modules/customers/commands/interactions.js +5 -5
  60. package/dist/modules/customers/commands/interactions.js.map +2 -2
  61. package/dist/modules/customers/commands/pipeline-stages.js +24 -2
  62. package/dist/modules/customers/commands/pipeline-stages.js.map +2 -2
  63. package/dist/modules/customers/commands/pipelines.js +24 -2
  64. package/dist/modules/customers/commands/pipelines.js.map +2 -2
  65. package/dist/modules/customers/components/detail/AddressesSection.js +46 -29
  66. package/dist/modules/customers/components/detail/AddressesSection.js.map +2 -2
  67. package/dist/modules/customers/components/detail/DealForm.js +5 -1
  68. package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
  69. package/dist/modules/customers/components/detail/notesAdapter.js +25 -18
  70. package/dist/modules/customers/components/detail/notesAdapter.js.map +2 -2
  71. package/dist/modules/data_sync/api/schedules/[id]/route.js +38 -19
  72. package/dist/modules/data_sync/api/schedules/[id]/route.js.map +2 -2
  73. package/dist/modules/data_sync/api/schedules/route.js +1 -1
  74. package/dist/modules/data_sync/api/schedules/route.js.map +2 -2
  75. package/dist/modules/data_sync/backend/data-sync/page.js +32 -15
  76. package/dist/modules/data_sync/backend/data-sync/page.js.map +2 -2
  77. package/dist/modules/data_sync/lib/sync-schedule-service.js +19 -5
  78. package/dist/modules/data_sync/lib/sync-schedule-service.js.map +2 -2
  79. package/dist/modules/dictionaries/api/[dictionaryId]/entries/[entryId]/route.js +3 -3
  80. package/dist/modules/dictionaries/api/[dictionaryId]/entries/[entryId]/route.js.map +2 -2
  81. package/dist/modules/dictionaries/api/[dictionaryId]/route.js +3 -3
  82. package/dist/modules/dictionaries/api/[dictionaryId]/route.js.map +2 -2
  83. package/dist/modules/dictionaries/components/DictionariesManager.js +12 -1
  84. package/dist/modules/dictionaries/components/DictionariesManager.js.map +2 -2
  85. package/dist/modules/dictionaries/components/DictionaryEntriesEditor.js.map +2 -2
  86. package/dist/modules/directory/backend/directory/organizations/[id]/edit/page.js +12 -0
  87. package/dist/modules/directory/backend/directory/organizations/[id]/edit/page.js.map +2 -2
  88. package/dist/modules/directory/backend/directory/tenants/[id]/edit/page.js +12 -0
  89. package/dist/modules/directory/backend/directory/tenants/[id]/edit/page.js.map +2 -2
  90. package/dist/modules/entities/api/entities.js +2 -2
  91. package/dist/modules/entities/api/entities.js.map +2 -2
  92. package/dist/modules/entities/api/records.js +7 -5
  93. package/dist/modules/entities/api/records.js.map +2 -2
  94. package/dist/modules/feature_toggles/api/overrides/route.js +2 -2
  95. package/dist/modules/feature_toggles/api/overrides/route.js.map +2 -2
  96. package/dist/modules/feature_toggles/backend/feature-toggles/global/[id]/edit/page.js.map +2 -2
  97. package/dist/modules/feature_toggles/data/entities.js +1 -1
  98. package/dist/modules/feature_toggles/data/entities.js.map +2 -2
  99. package/dist/modules/feature_toggles/data/validators.js +2 -1
  100. package/dist/modules/feature_toggles/data/validators.js.map +2 -2
  101. package/dist/modules/feature_toggles/lib/queries.js +2 -1
  102. package/dist/modules/feature_toggles/lib/queries.js.map +2 -2
  103. package/dist/modules/inbox_ops/api/settings/route.js +2 -2
  104. package/dist/modules/inbox_ops/api/settings/route.js.map +2 -2
  105. package/dist/modules/resources/backend/resources/resource-types/[id]/edit/page.js +12 -1
  106. package/dist/modules/resources/backend/resources/resource-types/[id]/edit/page.js.map +2 -2
  107. package/dist/modules/resources/backend/resources/resources/[id]/page.js +12 -1
  108. package/dist/modules/resources/backend/resources/resources/[id]/page.js.map +2 -2
  109. package/dist/modules/resources/components/detail/notesAdapter.js +25 -18
  110. package/dist/modules/resources/components/detail/notesAdapter.js.map +2 -2
  111. package/dist/modules/sales/backend/sales/documents/[id]/page.js +14 -1
  112. package/dist/modules/sales/backend/sales/documents/[id]/page.js.map +2 -2
  113. package/dist/modules/sales/commands/documents.js +11 -10
  114. package/dist/modules/sales/commands/documents.js.map +2 -2
  115. package/dist/modules/sales/commands/payments.js +6 -1
  116. package/dist/modules/sales/commands/payments.js.map +2 -2
  117. package/dist/modules/sales/commands/returns.js +3 -3
  118. package/dist/modules/sales/commands/returns.js.map +2 -2
  119. package/dist/modules/sales/commands/shared.js +3 -3
  120. package/dist/modules/sales/commands/shared.js.map +2 -2
  121. package/dist/modules/sales/commands/shipments.js +6 -1
  122. package/dist/modules/sales/commands/shipments.js.map +2 -2
  123. package/dist/modules/sales/components/documents/PaymentDialog.js +6 -3
  124. package/dist/modules/sales/components/documents/PaymentDialog.js.map +2 -2
  125. package/dist/modules/sales/components/documents/PaymentsSection.js +7 -3
  126. package/dist/modules/sales/components/documents/PaymentsSection.js.map +2 -2
  127. package/dist/modules/sales/components/documents/ShipmentDialog.js +6 -3
  128. package/dist/modules/sales/components/documents/ShipmentDialog.js.map +2 -2
  129. package/dist/modules/sales/components/documents/ShipmentsSection.js +7 -3
  130. package/dist/modules/sales/components/documents/ShipmentsSection.js.map +2 -2
  131. package/dist/modules/sales/di.js +39 -3
  132. package/dist/modules/sales/di.js.map +2 -2
  133. package/dist/modules/sales/setup.js +2 -0
  134. package/dist/modules/sales/setup.js.map +2 -2
  135. package/dist/modules/staff/backend/staff/leave-requests/[id]/page.js +12 -1
  136. package/dist/modules/staff/backend/staff/leave-requests/[id]/page.js.map +2 -2
  137. package/dist/modules/staff/backend/staff/team-members/[id]/page.js +12 -1
  138. package/dist/modules/staff/backend/staff/team-members/[id]/page.js.map +2 -2
  139. package/dist/modules/staff/backend/staff/team-roles/[id]/edit/page.js +12 -1
  140. package/dist/modules/staff/backend/staff/team-roles/[id]/edit/page.js.map +2 -2
  141. package/dist/modules/staff/backend/staff/teams/[id]/edit/page.js +12 -1
  142. package/dist/modules/staff/backend/staff/teams/[id]/edit/page.js.map +2 -2
  143. package/dist/modules/staff/commands/job-histories.js +3 -3
  144. package/dist/modules/staff/commands/job-histories.js.map +2 -2
  145. package/dist/modules/staff/components/detail/addressesAdapter.js +33 -24
  146. package/dist/modules/staff/components/detail/addressesAdapter.js.map +2 -2
  147. package/dist/modules/staff/components/detail/notesAdapter.js +25 -18
  148. package/dist/modules/staff/components/detail/notesAdapter.js.map +2 -2
  149. package/dist/modules/translations/api/[entityType]/[entityId]/route.js +37 -0
  150. package/dist/modules/translations/api/[entityType]/[entityId]/route.js.map +2 -2
  151. package/dist/modules/translations/components/TranslationManager.js +7 -1
  152. package/dist/modules/translations/components/TranslationManager.js.map +2 -2
  153. package/dist/modules/workflows/api/definitions/[id]/route.js +3 -3
  154. package/dist/modules/workflows/api/definitions/[id]/route.js.map +2 -2
  155. package/dist/modules/workflows/backend/definitions/[id]/page.js +12 -1
  156. package/dist/modules/workflows/backend/definitions/[id]/page.js.map +2 -2
  157. package/dist/modules/workflows/backend/definitions/visual-editor/page.js +11 -1
  158. package/dist/modules/workflows/backend/definitions/visual-editor/page.js.map +2 -2
  159. package/package.json +7 -7
  160. package/src/modules/attachments/components/AttachmentPartitionSettings.tsx +3 -0
  161. package/src/modules/auth/api/roles/acl/route.ts +2 -2
  162. package/src/modules/auth/api/users/acl/route.ts +2 -2
  163. package/src/modules/auth/backend/roles/[id]/edit/page.tsx +17 -0
  164. package/src/modules/auth/backend/users/[id]/edit/page.tsx +17 -0
  165. package/src/modules/auth/data/entities.ts +2 -2
  166. package/src/modules/business_rules/api/rules/route.ts +3 -3
  167. package/src/modules/business_rules/api/sets/route.ts +3 -3
  168. package/src/modules/business_rules/backend/rules/[id]/page.tsx +17 -1
  169. package/src/modules/business_rules/backend/sets/[id]/page.tsx +18 -1
  170. package/src/modules/catalog/api/product-media/route.ts +4 -0
  171. package/src/modules/catalog/backend/catalog/categories/[id]/edit/page.tsx +17 -0
  172. package/src/modules/catalog/backend/catalog/products/[id]/page.tsx +21 -0
  173. package/src/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.tsx +16 -1
  174. package/src/modules/catalog/components/PriceKindSettings.tsx +15 -7
  175. package/src/modules/catalog/lib/bulkDelete.ts +6 -0
  176. package/src/modules/currencies/backend/currencies/[id]/page.tsx +20 -3
  177. package/src/modules/currencies/backend/currencies/page.tsx +2 -1
  178. package/src/modules/currencies/backend/exchange-rates/[id]/page.tsx +17 -1
  179. package/src/modules/currencies/backend/exchange-rates/page.tsx +3 -2
  180. package/src/modules/customer_accounts/api/admin/roles/[id].ts +3 -3
  181. package/src/modules/customer_accounts/api/admin/users/[id].ts +3 -3
  182. package/src/modules/customers/api/addresses/route.ts +2 -0
  183. package/src/modules/customers/api/tags/route.ts +6 -1
  184. package/src/modules/customers/backend/customers/companies-v2/[id]/page.tsx +19 -2
  185. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.ts +29 -8
  186. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.ts +47 -20
  187. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealPipeline.ts +13 -3
  188. package/src/modules/customers/backend/customers/deals/[id]/page.tsx +21 -1
  189. package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +18 -2
  190. package/src/modules/customers/commands/interactions.ts +5 -5
  191. package/src/modules/customers/commands/pipeline-stages.ts +26 -2
  192. package/src/modules/customers/commands/pipelines.ts +26 -2
  193. package/src/modules/customers/components/detail/AddressesSection.tsx +50 -28
  194. package/src/modules/customers/components/detail/DealForm.tsx +12 -0
  195. package/src/modules/customers/components/detail/notesAdapter.ts +29 -18
  196. package/src/modules/customers/i18n/de.json +2 -0
  197. package/src/modules/customers/i18n/en.json +2 -0
  198. package/src/modules/customers/i18n/es.json +2 -0
  199. package/src/modules/customers/i18n/pl.json +2 -0
  200. package/src/modules/data_sync/api/schedules/[id]/route.ts +38 -20
  201. package/src/modules/data_sync/api/schedules/route.ts +1 -1
  202. package/src/modules/data_sync/backend/data-sync/page.tsx +32 -15
  203. package/src/modules/data_sync/lib/sync-schedule-service.ts +30 -5
  204. package/src/modules/dictionaries/api/[dictionaryId]/entries/[entryId]/route.ts +3 -3
  205. package/src/modules/dictionaries/api/[dictionaryId]/route.ts +3 -3
  206. package/src/modules/dictionaries/components/DictionariesManager.tsx +21 -1
  207. package/src/modules/dictionaries/components/DictionaryEntriesEditor.tsx +2 -0
  208. package/src/modules/directory/backend/directory/organizations/[id]/edit/page.tsx +17 -0
  209. package/src/modules/directory/backend/directory/tenants/[id]/edit/page.tsx +17 -0
  210. package/src/modules/entities/api/entities.ts +2 -2
  211. package/src/modules/entities/api/records.ts +7 -5
  212. package/src/modules/feature_toggles/api/overrides/route.ts +2 -2
  213. package/src/modules/feature_toggles/backend/feature-toggles/global/[id]/edit/page.tsx +12 -0
  214. package/src/modules/feature_toggles/data/entities.ts +1 -1
  215. package/src/modules/feature_toggles/data/validators.ts +1 -0
  216. package/src/modules/feature_toggles/lib/queries.ts +1 -0
  217. package/src/modules/inbox_ops/api/settings/route.ts +2 -2
  218. package/src/modules/resources/backend/resources/resource-types/[id]/edit/page.tsx +17 -1
  219. package/src/modules/resources/backend/resources/resources/[id]/page.tsx +17 -1
  220. package/src/modules/resources/components/detail/notesAdapter.ts +29 -18
  221. package/src/modules/sales/backend/sales/documents/[id]/page.tsx +17 -1
  222. package/src/modules/sales/commands/documents.ts +11 -10
  223. package/src/modules/sales/commands/payments.ts +11 -0
  224. package/src/modules/sales/commands/returns.ts +3 -3
  225. package/src/modules/sales/commands/shared.ts +10 -4
  226. package/src/modules/sales/commands/shipments.ts +14 -0
  227. package/src/modules/sales/components/documents/PaymentDialog.tsx +7 -3
  228. package/src/modules/sales/components/documents/PaymentsSection.tsx +8 -3
  229. package/src/modules/sales/components/documents/ShipmentDialog.tsx +7 -3
  230. package/src/modules/sales/components/documents/ShipmentsSection.tsx +8 -3
  231. package/src/modules/sales/di.ts +68 -9
  232. package/src/modules/sales/setup.ts +2 -0
  233. package/src/modules/staff/backend/staff/leave-requests/[id]/page.tsx +17 -1
  234. package/src/modules/staff/backend/staff/team-members/[id]/page.tsx +17 -1
  235. package/src/modules/staff/backend/staff/team-roles/[id]/edit/page.tsx +17 -1
  236. package/src/modules/staff/backend/staff/teams/[id]/edit/page.tsx +17 -1
  237. package/src/modules/staff/commands/job-histories.ts +3 -3
  238. package/src/modules/staff/components/detail/addressesAdapter.ts +40 -23
  239. package/src/modules/staff/components/detail/notesAdapter.ts +28 -18
  240. package/src/modules/translations/api/[entityType]/[entityId]/route.ts +74 -0
  241. package/src/modules/translations/components/TranslationManager.tsx +14 -1
  242. package/src/modules/workflows/api/definitions/[id]/route.ts +3 -3
  243. package/src/modules/workflows/backend/definitions/[id]/page.tsx +18 -1
  244. package/src/modules/workflows/backend/definitions/visual-editor/page.tsx +19 -1
@@ -16,6 +16,10 @@ import {
16
16
  import { ensureOrganizationScope, ensureTenantScope, ensureDictionaryEntry } from './shared'
17
17
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
18
18
  import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
19
+ import {
20
+ enforceCommandOptimisticLockWithGuards,
21
+ enforceRecordGoneIsConflict,
22
+ } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
19
23
 
20
24
  const createPipelineStageCommand: CommandHandler<PipelineStageCreateInput, { stageId: string }> = {
21
25
  id: 'customers.pipeline-stages.create',
@@ -106,11 +110,21 @@ const updatePipelineStageCommand: CommandHandler<PipelineStageUpdateInput, void>
106
110
  ...(callerTenantId ? { tenantId: callerTenantId } : {}),
107
111
  ...(callerOrganizationId ? { organizationId: callerOrganizationId } : {}),
108
112
  })
109
- if (!stage) throw new CrudHttpError(404, { error: 'Pipeline stage not found' })
113
+ if (!stage) {
114
+ enforceRecordGoneIsConflict({ resourceKind: 'customers.pipelineStage', resourceId: parsed.id, request: ctx.request ?? null })
115
+ throw new CrudHttpError(404, { error: 'Pipeline stage not found' })
116
+ }
110
117
 
111
118
  ensureTenantScope(ctx, stage.tenantId)
112
119
  ensureOrganizationScope(ctx, stage.organizationId)
113
120
 
121
+ await enforceCommandOptimisticLockWithGuards(ctx.container, {
122
+ resourceKind: 'customers.pipelineStage',
123
+ resourceId: stage.id,
124
+ current: stage.updatedAt,
125
+ request: ctx.request ?? null,
126
+ })
127
+
114
128
  await withAtomicFlush(em, [
115
129
  () => {
116
130
  if (parsed.label !== undefined) stage.label = parsed.label
@@ -148,11 +162,21 @@ const deletePipelineStageCommand: CommandHandler<PipelineStageDeleteInput, void>
148
162
  ...(callerTenantId ? { tenantId: callerTenantId } : {}),
149
163
  ...(callerOrganizationId ? { organizationId: callerOrganizationId } : {}),
150
164
  })
151
- if (!stage) throw new CrudHttpError(404, { error: 'Pipeline stage not found' })
165
+ if (!stage) {
166
+ enforceRecordGoneIsConflict({ resourceKind: 'customers.pipelineStage', resourceId: parsed.id, request: ctx.request ?? null })
167
+ throw new CrudHttpError(404, { error: 'Pipeline stage not found' })
168
+ }
152
169
 
153
170
  ensureTenantScope(ctx, stage.tenantId)
154
171
  ensureOrganizationScope(ctx, stage.organizationId)
155
172
 
173
+ await enforceCommandOptimisticLockWithGuards(ctx.container, {
174
+ resourceKind: 'customers.pipelineStage',
175
+ resourceId: stage.id,
176
+ current: stage.updatedAt,
177
+ request: ctx.request ?? null,
178
+ })
179
+
156
180
  const activeDealsCount = await em.count(CustomerDeal, {
157
181
  pipelineStageId: parsed.id,
158
182
  deletedAt: null,
@@ -12,6 +12,10 @@ import {
12
12
  } from '../data/validators'
13
13
  import { ensureOrganizationScope, ensureTenantScope } from './shared'
14
14
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
15
+ import {
16
+ enforceCommandOptimisticLockWithGuards,
17
+ enforceRecordGoneIsConflict,
18
+ } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
15
19
  import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
16
20
 
17
21
  const createPipelineCommand: CommandHandler<PipelineCreateInput, { pipelineId: string }> = {
@@ -56,11 +60,21 @@ const updatePipelineCommand: CommandHandler<PipelineUpdateInput, void> = {
56
60
 
57
61
  const em = (ctx.container.resolve('em') as EntityManager).fork()
58
62
  const pipeline = await em.findOne(CustomerPipeline, { id: parsed.id })
59
- if (!pipeline) throw new CrudHttpError(404, { error: 'Pipeline not found' })
63
+ if (!pipeline) {
64
+ enforceRecordGoneIsConflict({ resourceKind: 'customers.pipeline', resourceId: parsed.id, request: ctx.request ?? null })
65
+ throw new CrudHttpError(404, { error: 'Pipeline not found' })
66
+ }
60
67
 
61
68
  ensureTenantScope(ctx, pipeline.tenantId)
62
69
  ensureOrganizationScope(ctx, pipeline.organizationId)
63
70
 
71
+ await enforceCommandOptimisticLockWithGuards(ctx.container, {
72
+ resourceKind: 'customers.pipeline',
73
+ resourceId: pipeline.id,
74
+ current: pipeline.updatedAt,
75
+ request: ctx.request ?? null,
76
+ })
77
+
64
78
  await withAtomicFlush(em, [
65
79
  async () => {
66
80
  if (parsed.isDefault && !pipeline.isDefault) {
@@ -86,11 +100,21 @@ const deletePipelineCommand: CommandHandler<PipelineDeleteInput, void> = {
86
100
 
87
101
  const em = (ctx.container.resolve('em') as EntityManager).fork()
88
102
  const pipeline = await em.findOne(CustomerPipeline, { id: parsed.id })
89
- if (!pipeline) throw new CrudHttpError(404, { error: 'Pipeline not found' })
103
+ if (!pipeline) {
104
+ enforceRecordGoneIsConflict({ resourceKind: 'customers.pipeline', resourceId: parsed.id, request: ctx.request ?? null })
105
+ throw new CrudHttpError(404, { error: 'Pipeline not found' })
106
+ }
90
107
 
91
108
  ensureTenantScope(ctx, pipeline.tenantId)
92
109
  ensureOrganizationScope(ctx, pipeline.organizationId)
93
110
 
111
+ await enforceCommandOptimisticLockWithGuards(ctx.container, {
112
+ resourceKind: 'customers.pipeline',
113
+ resourceId: pipeline.id,
114
+ current: pipeline.updatedAt,
115
+ request: ctx.request ?? null,
116
+ })
117
+
94
118
  const activeDealsCount = await em.count(CustomerDeal, {
95
119
  pipelineId: parsed.id,
96
120
  deletedAt: null,
@@ -1,7 +1,9 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from 'react'
4
- import { apiCall, apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
4
+ import { apiCall, apiCallOrThrow, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
5
+ import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
6
+ import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
5
7
  import { AddressesSection as SharedAddressesSection } from '@open-mercato/ui/backend/detail'
6
8
  import type { AddressDataAdapter, AddressTypesAdapter, AddressFormatStrategy, SectionAction } from '@open-mercato/ui/backend/detail'
7
9
  import { createTranslatorWithFallback } from '@open-mercato/shared/lib/i18n/translate'
@@ -99,6 +101,12 @@ export function AddressesSection({
99
101
  : typeof record.isPrimary === 'boolean'
100
102
  ? record.isPrimary
101
103
  : false,
104
+ updatedAt:
105
+ typeof record.updated_at === 'string'
106
+ ? record.updated_at
107
+ : typeof record.updatedAt === 'string'
108
+ ? record.updatedAt
109
+ : null,
102
110
  }
103
111
  })
104
112
  .filter((value): value is NonNullable<typeof value> => value !== null)
@@ -107,7 +115,7 @@ export function AddressesSection({
107
115
  const response = await apiCallOrThrow<Record<string, unknown>>(
108
116
  '/api/customers/addresses',
109
117
  {
110
- // optimistic-lock-exempt: address link add/remove sub-resource
118
+ // optimistic-lock-exempt: address create-only (no prior version to compare)
111
119
  method: 'POST',
112
120
  headers: { 'content-type': 'application/json' },
113
121
  body: JSON.stringify({
@@ -120,33 +128,47 @@ export function AddressesSection({
120
128
  )
121
129
  return response.result ?? {}
122
130
  },
123
- update: async ({ id, payload }) => {
124
- await apiCallOrThrow(
125
- '/api/customers/addresses',
126
- {
127
- // optimistic-lock-exempt: address link add/remove sub-resource
128
- method: 'PUT',
129
- headers: { 'content-type': 'application/json' },
130
- body: JSON.stringify({
131
- id,
132
- ...payload,
133
- country: payload.country ? payload.country.toUpperCase() : undefined,
134
- }),
135
- },
136
- { errorMessage: t('customers.people.detail.addresses.error') },
137
- )
131
+ update: async ({ id, payload, updatedAt }) => {
132
+ try {
133
+ await withScopedApiRequestHeaders(
134
+ buildOptimisticLockHeader(updatedAt ?? null),
135
+ () => apiCallOrThrow(
136
+ '/api/customers/addresses',
137
+ {
138
+ method: 'PUT',
139
+ headers: { 'content-type': 'application/json' },
140
+ body: JSON.stringify({
141
+ id,
142
+ ...payload,
143
+ country: payload.country ? payload.country.toUpperCase() : undefined,
144
+ }),
145
+ },
146
+ { errorMessage: t('customers.people.detail.addresses.error') },
147
+ ),
148
+ )
149
+ } catch (err) {
150
+ surfaceRecordConflict(err, t)
151
+ throw err
152
+ }
138
153
  },
139
- delete: async ({ id }) => {
140
- await apiCallOrThrow(
141
- '/api/customers/addresses',
142
- {
143
- // optimistic-lock-exempt: address link add/remove sub-resource
144
- method: 'DELETE',
145
- headers: { 'content-type': 'application/json' },
146
- body: JSON.stringify({ id }),
147
- },
148
- { errorMessage: t('customers.people.detail.addresses.error') },
149
- )
154
+ delete: async ({ id, updatedAt }) => {
155
+ try {
156
+ await withScopedApiRequestHeaders(
157
+ buildOptimisticLockHeader(updatedAt ?? null),
158
+ () => apiCallOrThrow(
159
+ '/api/customers/addresses',
160
+ {
161
+ method: 'DELETE',
162
+ headers: { 'content-type': 'application/json' },
163
+ body: JSON.stringify({ id }),
164
+ },
165
+ { errorMessage: t('customers.people.detail.addresses.error') },
166
+ ),
167
+ )
168
+ } catch (err) {
169
+ surfaceRecordConflict(err, t)
170
+ throw err
171
+ }
150
172
  },
151
173
  }), [t])
152
174
 
@@ -69,6 +69,14 @@ export type DealFormProps = {
69
69
  showCancelAction?: boolean
70
70
  initialPipelineOptions?: PipelineOption[]
71
71
  initialPipelineStageOptions?: PipelineStageOption[]
72
+ /**
73
+ * Injection spot id for the form-scoped record_locks widget (e.g.
74
+ * `customers.deal`). Mirrors how people-v2/companies-v2 mount their save-time
75
+ * `crud-form:*` widget so a deal save conflict surfaces the merge dialog.
76
+ */
77
+ injectionSpotId?: string
78
+ /** Optimistic-lock version (`deal.updatedAt`) for the embedded CrudForm. */
79
+ optimisticLockUpdatedAt?: string | null
72
80
  }
73
81
 
74
82
  type EntityOption = {
@@ -732,6 +740,8 @@ export function DealForm({
732
740
  showCancelAction = true,
733
741
  initialPipelineOptions,
734
742
  initialPipelineStageOptions,
743
+ injectionSpotId,
744
+ optimisticLockUpdatedAt,
735
745
  }: DealFormProps) {
736
746
  const t = useT()
737
747
  const [pending, setPending] = React.useState(false)
@@ -1168,6 +1178,8 @@ export function DealForm({
1168
1178
  backHref={backHref}
1169
1179
  hideFooterActions={hideFooterActions}
1170
1180
  onDirtyChange={onDirtyChange}
1181
+ injectionSpotId={injectionSpotId}
1182
+ optimisticLockUpdatedAt={optimisticLockUpdatedAt}
1171
1183
  collapsibleGroups={collapsibleGroups}
1172
1184
  sortableGroups={sortableGroups}
1173
1185
  versionHistory={showVersionHistory && mode === 'edit' && initialValues?.id
@@ -1,6 +1,7 @@
1
1
  "use client"
2
2
 
3
- import { apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
3
+ import { apiCallOrThrow, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
4
+ import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
4
5
  import { mapCommentSummary, type NotesDataAdapter } from '@open-mercato/ui/backend/detail/NotesSection'
5
6
 
6
7
  type Translator = (key: string, fallback?: string, params?: Record<string, string | number>) => string
@@ -92,33 +93,43 @@ export function createCustomerNotesAdapter(
92
93
  )
93
94
  return response.result ?? {}
94
95
  },
95
- update: async ({ id, patch }) => {
96
+ update: async ({ id, patch, updatedAt }) => {
96
97
  const payload: Record<string, unknown> = { id }
97
98
  if (patch.body !== undefined) payload.body = patch.body
98
99
  if (patch.appearanceIcon !== undefined) payload.appearanceIcon = patch.appearanceIcon
99
100
  if (patch.appearanceColor !== undefined) payload.appearanceColor = patch.appearanceColor
101
+ // Send the optimistic-lock header (note's loaded updatedAt) so a stale edit —
102
+ // or an edit after the note was deleted in another tab — surfaces the unified
103
+ // conflict bar (409) instead of silently overwriting. The guarded runner
104
+ // (useGuardedMutation) routes the 409 through surfaceRecordConflict.
100
105
  await runWrite(
101
- () => apiCallOrThrow(
102
- '/api/customers/comments',
103
- {
104
- method: 'PUT',
105
- headers: { 'content-type': 'application/json' },
106
- body: JSON.stringify(payload),
107
- },
108
- { errorMessage: translator('customers.people.detail.notes.updateError') },
106
+ () => withScopedApiRequestHeaders(
107
+ buildOptimisticLockHeader(updatedAt ?? null),
108
+ () => apiCallOrThrow(
109
+ '/api/customers/comments',
110
+ {
111
+ method: 'PUT',
112
+ headers: { 'content-type': 'application/json' },
113
+ body: JSON.stringify(payload),
114
+ },
115
+ { errorMessage: translator('customers.people.detail.notes.updateError') },
116
+ ),
109
117
  ),
110
118
  { operation: 'updateNote', id },
111
119
  )
112
120
  },
113
- delete: async ({ id }) => {
121
+ delete: async ({ id, updatedAt }) => {
114
122
  await runWrite(
115
- () => apiCallOrThrow(
116
- `/api/customers/comments?id=${encodeURIComponent(id)}`,
117
- {
118
- method: 'DELETE',
119
- headers: { 'content-type': 'application/json' },
120
- },
121
- { errorMessage: translator('customers.people.detail.notes.deleteError', 'Failed to delete note') },
123
+ () => withScopedApiRequestHeaders(
124
+ buildOptimisticLockHeader(updatedAt ?? null),
125
+ () => apiCallOrThrow(
126
+ `/api/customers/comments?id=${encodeURIComponent(id)}`,
127
+ {
128
+ method: 'DELETE',
129
+ headers: { 'content-type': 'application/json' },
130
+ },
131
+ { errorMessage: translator('customers.people.detail.notes.deleteError', 'Failed to delete note') },
132
+ ),
122
133
  ),
123
134
  { operation: 'deleteNote', id },
124
135
  )
@@ -923,6 +923,8 @@
923
923
  "customers.deals.detail.badge.deal": "Deal",
924
924
  "customers.deals.detail.badge.lost": "Lost",
925
925
  "customers.deals.detail.badge.won": "Won",
926
+ "customers.deals.detail.closeLostError": "Deal konnte nicht als verloren markiert werden.",
927
+ "customers.deals.detail.closeWonError": "Deal konnte nicht als gewonnen markiert werden.",
926
928
  "customers.deals.detail.closure.lost": "Lost",
927
929
  "customers.deals.detail.closure.prompt": "Close deal — choose outcome",
928
930
  "customers.deals.detail.closure.won": "Won",
@@ -923,6 +923,8 @@
923
923
  "customers.deals.detail.badge.deal": "Deal",
924
924
  "customers.deals.detail.badge.lost": "Lost",
925
925
  "customers.deals.detail.badge.won": "Won",
926
+ "customers.deals.detail.closeLostError": "Failed to mark deal as lost.",
927
+ "customers.deals.detail.closeWonError": "Failed to mark deal as won.",
926
928
  "customers.deals.detail.closure.lost": "Lost",
927
929
  "customers.deals.detail.closure.prompt": "Close deal — choose outcome",
928
930
  "customers.deals.detail.closure.won": "Won",
@@ -923,6 +923,8 @@
923
923
  "customers.deals.detail.badge.deal": "Deal",
924
924
  "customers.deals.detail.badge.lost": "Lost",
925
925
  "customers.deals.detail.badge.won": "Won",
926
+ "customers.deals.detail.closeLostError": "No se pudo marcar el deal como perdido.",
927
+ "customers.deals.detail.closeWonError": "No se pudo marcar el deal como ganado.",
926
928
  "customers.deals.detail.closure.lost": "Lost",
927
929
  "customers.deals.detail.closure.prompt": "Close deal — choose outcome",
928
930
  "customers.deals.detail.closure.won": "Won",
@@ -923,6 +923,8 @@
923
923
  "customers.deals.detail.badge.deal": "Deal",
924
924
  "customers.deals.detail.badge.lost": "Lost",
925
925
  "customers.deals.detail.badge.won": "Won",
926
+ "customers.deals.detail.closeLostError": "Nie udało się oznaczyć deala jako przegranego.",
927
+ "customers.deals.detail.closeWonError": "Nie udało się oznaczyć deala jako wygranego.",
926
928
  "customers.deals.detail.closure.lost": "Lost",
927
929
  "customers.deals.detail.closure.prompt": "Close deal — choose outcome",
928
930
  "customers.deals.detail.closure.won": "Won",
@@ -3,6 +3,8 @@ import { z } from 'zod'
3
3
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
4
4
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
5
  import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
6
+ import { readOptimisticLockExpected } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
7
+ import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
6
8
  import { updateSyncScheduleSchema } from '../../../data/validators'
7
9
  import type { SyncScheduleService } from '../../../lib/sync-schedule-service'
8
10
  import { serializeSchedule } from '../serialize'
@@ -111,7 +113,8 @@ export async function PUT(req: Request, ctx: { params?: Promise<{ id?: string }>
111
113
  timezone: parsed.data.timezone ?? current.timezone,
112
114
  fullSync: parsed.data.fullSync ?? current.fullSync,
113
115
  isEnabled: parsed.data.isEnabled ?? current.isEnabled,
114
- }, scope)
116
+ expectedUpdatedAt: readOptimisticLockExpected(req),
117
+ }, scope, container)
115
118
 
116
119
  if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
117
120
  await runCrudMutationGuardAfterSuccess(container, {
@@ -126,9 +129,11 @@ export async function PUT(req: Request, ctx: { params?: Promise<{ id?: string }>
126
129
  metadata: guardResult.metadata ?? null,
127
130
  })
128
131
  }
129
-
130
132
  return NextResponse.json(serializeSchedule(schedule))
131
133
  } catch (error) {
134
+ if (isCrudHttpError(error)) {
135
+ return NextResponse.json(error.body, { status: error.status })
136
+ }
132
137
  const message = error instanceof Error ? error.message : 'Failed to update sync schedule'
133
138
  return NextResponse.json({ error: message }, { status: 422 })
134
139
  }
@@ -168,25 +173,38 @@ export async function DELETE(req: Request, ctx: { params?: Promise<{ id?: string
168
173
  return NextResponse.json(guardResult.body, { status: guardResult.status })
169
174
  }
170
175
 
171
- const deleted = await scheduleService.deleteSchedule(parsedParams.data.id, scope)
176
+ try {
177
+ const deleted = await scheduleService.deleteSchedule(
178
+ parsedParams.data.id,
179
+ scope,
180
+ container,
181
+ readOptimisticLockExpected(req),
182
+ )
183
+
184
+ if (!deleted) {
185
+ return NextResponse.json({ error: 'Schedule not found' }, { status: 404 })
186
+ }
172
187
 
173
- if (!deleted) {
174
- return NextResponse.json({ error: 'Schedule not found' }, { status: 404 })
175
- }
188
+ if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
189
+ await runCrudMutationGuardAfterSuccess(container, {
190
+ tenantId: auth.tenantId,
191
+ organizationId: scope.organizationId,
192
+ userId: auth.sub,
193
+ resourceKind: 'data_sync.schedule',
194
+ resourceId: parsedParams.data.id,
195
+ operation: 'delete',
196
+ requestMethod: req.method,
197
+ requestHeaders: req.headers,
198
+ metadata: guardResult.metadata ?? null,
199
+ })
200
+ }
176
201
 
177
- if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
178
- await runCrudMutationGuardAfterSuccess(container, {
179
- tenantId: auth.tenantId,
180
- organizationId: scope.organizationId,
181
- userId: auth.sub,
182
- resourceKind: 'data_sync.schedule',
183
- resourceId: parsedParams.data.id,
184
- operation: 'delete',
185
- requestMethod: req.method,
186
- requestHeaders: req.headers,
187
- metadata: guardResult.metadata ?? null,
188
- })
202
+ return NextResponse.json({ deleted: true })
203
+ } catch (error) {
204
+ if (isCrudHttpError(error)) {
205
+ return NextResponse.json(error.body, { status: error.status })
206
+ }
207
+ const message = error instanceof Error ? error.message : 'Failed to delete sync schedule'
208
+ return NextResponse.json({ error: message }, { status: 422 })
189
209
  }
190
-
191
- return NextResponse.json({ deleted: true })
192
210
  }
@@ -90,7 +90,7 @@ export async function POST(req: Request) {
90
90
  const schedule = await scheduleService.saveSchedule({
91
91
  ...parsed.data,
92
92
  expectedUpdatedAt: readOptimisticLockExpected(req),
93
- }, scope)
93
+ }, scope, container)
94
94
 
95
95
  if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
96
96
  await runCrudMutationGuardAfterSuccess(container, {
@@ -26,6 +26,7 @@ import { Switch } from '@open-mercato/ui/primitives/switch'
26
26
  import { RowActions } from '@open-mercato/ui/backend/RowActions'
27
27
  import { apiCall, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
28
28
  import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
29
+ import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
29
30
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
30
31
  import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
31
32
  import { useT } from '@open-mercato/shared/lib/i18n/context'
@@ -402,21 +403,27 @@ export default function SyncRunsDashboardPage() {
402
403
  setIsSavingSchedule(true)
403
404
  try {
404
405
  const call = await runMutation({
405
- // optimistic-lock-exempt: keyed upsert (POST, no record id/version in body) guard targets id-addressed PUT/PATCH/DELETE
406
- operation: () => apiCall<SyncScheduleRecord>('/api/data_sync/schedules', {
407
- method: 'POST',
408
- headers: { 'Content-Type': 'application/json' },
409
- body: JSON.stringify({
410
- integrationId: selectedIntegration.integrationId,
411
- entityType: selectedEntityType,
412
- direction: selectedDirection,
413
- scheduleType: scheduleEditor.scheduleType,
414
- scheduleValue: scheduleEditor.scheduleValue.trim(),
415
- timezone: scheduleEditor.timezone.trim() || DEFAULT_TIMEZONE,
416
- fullSync: scheduleEditor.fullSync,
417
- isEnabled: scheduleEditor.isEnabled,
418
- }),
419
- }, { fallback: null }),
406
+ // Keyed upsert (POST). When the editor holds an existing schedule's
407
+ // `updatedAt`, the lock header version-checks the resolved row on the
408
+ // server; a brand-new schedule has a null `updatedAt`, so the header is
409
+ // empty and the create path stays unaffected.
410
+ operation: () => withScopedApiRequestHeaders(
411
+ buildOptimisticLockHeader(scheduleEditor.updatedAt),
412
+ () => apiCall<SyncScheduleRecord>('/api/data_sync/schedules', {
413
+ method: 'POST',
414
+ headers: { 'Content-Type': 'application/json' },
415
+ body: JSON.stringify({
416
+ integrationId: selectedIntegration.integrationId,
417
+ entityType: selectedEntityType,
418
+ direction: selectedDirection,
419
+ scheduleType: scheduleEditor.scheduleType,
420
+ scheduleValue: scheduleEditor.scheduleValue.trim(),
421
+ timezone: scheduleEditor.timezone.trim() || DEFAULT_TIMEZONE,
422
+ fullSync: scheduleEditor.fullSync,
423
+ isEnabled: scheduleEditor.isEnabled,
424
+ }),
425
+ }, { fallback: null }),
426
+ ),
420
427
  mutationPayload: {
421
428
  integrationId: selectedIntegration.integrationId,
422
429
  entityType: selectedEntityType,
@@ -435,6 +442,16 @@ export default function SyncRunsDashboardPage() {
435
442
  })
436
443
 
437
444
  if (!call.ok || !call.result) {
445
+ const conflictError = Object.assign(
446
+ new Error((call.result as { error?: string } | null)?.error ?? t('data_sync.dashboard.schedule.error', 'Failed to save recurring schedule')),
447
+ {
448
+ status: call.status,
449
+ ...(call.result && typeof call.result === 'object' ? call.result : {}),
450
+ },
451
+ )
452
+ if (surfaceRecordConflict(conflictError, t)) {
453
+ return
454
+ }
438
455
  flash((call.result as { error?: string } | null)?.error ?? t('data_sync.dashboard.schedule.error', 'Failed to save recurring schedule'), 'error')
439
456
  return
440
457
  }
@@ -1,7 +1,8 @@
1
1
  import { randomUUID } from 'node:crypto'
2
+ import type { AwilixContainer } from 'awilix'
2
3
  import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
3
4
  import { findAndCountWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
- import { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
5
+ import { enforceCommandOptimisticLockWithGuards, enforceRecordGoneIsConflict } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
5
6
  import { SyncSchedule } from '../data/entities'
6
7
 
7
8
  type SyncScope = {
@@ -131,18 +132,28 @@ export function createSyncScheduleService(em: EntityManager, schedulerService?:
131
132
  fullSync: boolean
132
133
  isEnabled: boolean
133
134
  expectedUpdatedAt?: string | null
134
- }, scope: SyncScope): Promise<SyncSchedule> {
135
+ }, scope: SyncScope, container?: AwilixContainer): Promise<SyncSchedule> {
135
136
  const existing = input.id
136
137
  ? await getById(input.id, scope)
137
138
  : await getByKey(input.integrationId, input.entityType, input.direction, scope)
138
139
 
139
- if (existing) {
140
- enforceCommandOptimisticLock({
140
+ if (existing && container) {
141
+ await enforceCommandOptimisticLockWithGuards(container, {
141
142
  resourceKind: 'data_sync.schedule',
142
143
  resourceId: existing.id,
143
144
  current: existing.updatedAt ?? null,
144
145
  expected: input.expectedUpdatedAt ?? null,
145
146
  })
147
+ } else if (!existing && input.expectedUpdatedAt) {
148
+ // Concurrent delete-then-edit race: the client edited a schedule that was
149
+ // removed before this keyed upsert ran. Surface the unified conflict
150
+ // instead of silently re-creating it. No-op when no expected version was
151
+ // sent (a genuine create) or when OM_OPTIMISTIC_LOCK is off.
152
+ enforceRecordGoneIsConflict({
153
+ resourceKind: 'data_sync.schedule',
154
+ resourceId: input.id ?? `${input.integrationId}:${input.entityType}:${input.direction}`,
155
+ expected: input.expectedUpdatedAt,
156
+ })
146
157
  }
147
158
 
148
159
  const row = existing ?? em.create(SyncSchedule, {
@@ -200,10 +211,24 @@ export function createSyncScheduleService(em: EntityManager, schedulerService?:
200
211
  return row
201
212
  },
202
213
 
203
- async deleteSchedule(id: string, scope: SyncScope): Promise<boolean> {
214
+ async deleteSchedule(
215
+ id: string,
216
+ scope: SyncScope,
217
+ container?: AwilixContainer,
218
+ expectedUpdatedAt?: string | null,
219
+ ): Promise<boolean> {
204
220
  const row = await getById(id, scope)
205
221
  if (!row) return false
206
222
 
223
+ if (container) {
224
+ await enforceCommandOptimisticLockWithGuards(container, {
225
+ resourceKind: 'data_sync.schedule',
226
+ resourceId: row.id,
227
+ current: row.updatedAt ?? null,
228
+ expected: expectedUpdatedAt ?? null,
229
+ })
230
+ }
231
+
207
232
  const scheduledJobId = row.scheduledJobId ?? row.id
208
233
  await requireScheduler().unregister(scheduledJobId)
209
234
 
@@ -4,7 +4,7 @@ import { Dictionary, DictionaryEntry } from '@open-mercato/core/modules/dictiona
4
4
  import { resolveDictionariesRouteContext, resolveDictionaryActorId } from '@open-mercato/core/modules/dictionaries/api/context'
5
5
  import { updateDictionaryEntrySchema } from '@open-mercato/core/modules/dictionaries/data/validators'
6
6
  import { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
7
- import { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
7
+ import { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
8
8
  import {
9
9
  runCrudMutationGuardAfterSuccess,
10
10
  validateCrudMutationGuard,
@@ -76,7 +76,7 @@ export async function PATCH(req: Request, ctx: { params?: { dictionaryId?: strin
76
76
  })
77
77
  const dictionary = await loadDictionary(context, dictionaryId)
78
78
  const entry = await loadEntry(context, dictionary, entryId)
79
- enforceCommandOptimisticLock({
79
+ await enforceCommandOptimisticLockWithGuards(context.container, {
80
80
  resourceKind: 'dictionaries.entry',
81
81
  resourceId: entry.id,
82
82
  current: entry.updatedAt ?? null,
@@ -178,7 +178,7 @@ export async function DELETE(req: Request, ctx: { params?: { dictionaryId?: stri
178
178
  })
179
179
  const dictionary = await loadDictionary(context, dictionaryId)
180
180
  const entry = await loadEntry(context, dictionary, entryId)
181
- enforceCommandOptimisticLock({
181
+ await enforceCommandOptimisticLockWithGuards(context.container, {
182
182
  resourceKind: 'dictionaries.entry',
183
183
  resourceId: entry.id,
184
184
  current: entry.updatedAt ?? null,