@open-mercato/core 0.6.6-develop.6330.1.a261878aa8 → 0.6.6-develop.6332.1.6e73f0f55b

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 (253) 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/logout.js +2 -3
  4. package/dist/modules/auth/api/logout.js.map +2 -2
  5. package/dist/modules/auth/api/roles/acl/route.js +2 -2
  6. package/dist/modules/auth/api/roles/acl/route.js.map +2 -2
  7. package/dist/modules/auth/api/session/refresh.js +5 -6
  8. package/dist/modules/auth/api/session/refresh.js.map +2 -2
  9. package/dist/modules/auth/api/users/acl/route.js +2 -2
  10. package/dist/modules/auth/api/users/acl/route.js.map +2 -2
  11. package/dist/modules/auth/backend/roles/[id]/edit/page.js +12 -0
  12. package/dist/modules/auth/backend/roles/[id]/edit/page.js.map +2 -2
  13. package/dist/modules/auth/backend/users/[id]/edit/page.js +12 -0
  14. package/dist/modules/auth/backend/users/[id]/edit/page.js.map +2 -2
  15. package/dist/modules/auth/data/entities.js +2 -2
  16. package/dist/modules/auth/data/entities.js.map +2 -2
  17. package/dist/modules/auth/lib/requestRedirect.js +31 -3
  18. package/dist/modules/auth/lib/requestRedirect.js.map +2 -2
  19. package/dist/modules/business_rules/api/rules/route.js +3 -3
  20. package/dist/modules/business_rules/api/rules/route.js.map +2 -2
  21. package/dist/modules/business_rules/api/sets/route.js +3 -3
  22. package/dist/modules/business_rules/api/sets/route.js.map +2 -2
  23. package/dist/modules/business_rules/backend/rules/[id]/page.js +12 -1
  24. package/dist/modules/business_rules/backend/rules/[id]/page.js.map +2 -2
  25. package/dist/modules/business_rules/backend/sets/[id]/page.js +12 -1
  26. package/dist/modules/business_rules/backend/sets/[id]/page.js.map +2 -2
  27. package/dist/modules/catalog/api/product-media/route.js.map +2 -2
  28. package/dist/modules/catalog/backend/catalog/categories/[id]/edit/page.js +12 -0
  29. package/dist/modules/catalog/backend/catalog/categories/[id]/edit/page.js.map +2 -2
  30. package/dist/modules/catalog/backend/catalog/products/[id]/page.js +15 -0
  31. package/dist/modules/catalog/backend/catalog/products/[id]/page.js.map +2 -2
  32. package/dist/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.js +12 -1
  33. package/dist/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.js.map +2 -2
  34. package/dist/modules/catalog/components/PriceKindSettings.js +13 -3
  35. package/dist/modules/catalog/components/PriceKindSettings.js.map +2 -2
  36. package/dist/modules/catalog/lib/bulkDelete.js.map +2 -2
  37. package/dist/modules/currencies/backend/currencies/[id]/page.js +15 -3
  38. package/dist/modules/currencies/backend/currencies/[id]/page.js.map +2 -2
  39. package/dist/modules/currencies/backend/currencies/page.js +2 -1
  40. package/dist/modules/currencies/backend/currencies/page.js.map +2 -2
  41. package/dist/modules/currencies/backend/exchange-rates/[id]/page.js +12 -1
  42. package/dist/modules/currencies/backend/exchange-rates/[id]/page.js.map +2 -2
  43. package/dist/modules/currencies/backend/exchange-rates/page.js +3 -2
  44. package/dist/modules/currencies/backend/exchange-rates/page.js.map +2 -2
  45. package/dist/modules/customer_accounts/api/admin/roles/[id].js +3 -3
  46. package/dist/modules/customer_accounts/api/admin/roles/[id].js.map +2 -2
  47. package/dist/modules/customer_accounts/api/admin/users/[id].js +3 -3
  48. package/dist/modules/customer_accounts/api/admin/users/[id].js.map +2 -2
  49. package/dist/modules/customers/api/addresses/route.js +4 -2
  50. package/dist/modules/customers/api/addresses/route.js.map +2 -2
  51. package/dist/modules/customers/api/tags/route.js +8 -3
  52. package/dist/modules/customers/api/tags/route.js.map +2 -2
  53. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js +13 -2
  54. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js.map +2 -2
  55. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.js +24 -9
  56. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.js.map +2 -2
  57. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.js +53 -23
  58. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.js.map +2 -2
  59. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealPipeline.js +13 -3
  60. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealPipeline.js.map +2 -2
  61. package/dist/modules/customers/backend/customers/deals/[id]/page.js +19 -2
  62. package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
  63. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +13 -2
  64. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
  65. package/dist/modules/customers/commands/interactions.js +5 -5
  66. package/dist/modules/customers/commands/interactions.js.map +2 -2
  67. package/dist/modules/customers/commands/pipeline-stages.js +24 -2
  68. package/dist/modules/customers/commands/pipeline-stages.js.map +2 -2
  69. package/dist/modules/customers/commands/pipelines.js +24 -2
  70. package/dist/modules/customers/commands/pipelines.js.map +2 -2
  71. package/dist/modules/customers/components/detail/AddressesSection.js +46 -29
  72. package/dist/modules/customers/components/detail/AddressesSection.js.map +2 -2
  73. package/dist/modules/customers/components/detail/DealForm.js +5 -1
  74. package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
  75. package/dist/modules/customers/components/detail/notesAdapter.js +25 -18
  76. package/dist/modules/customers/components/detail/notesAdapter.js.map +2 -2
  77. package/dist/modules/data_sync/api/schedules/[id]/route.js +38 -19
  78. package/dist/modules/data_sync/api/schedules/[id]/route.js.map +2 -2
  79. package/dist/modules/data_sync/api/schedules/route.js +1 -1
  80. package/dist/modules/data_sync/api/schedules/route.js.map +2 -2
  81. package/dist/modules/data_sync/backend/data-sync/page.js +32 -15
  82. package/dist/modules/data_sync/backend/data-sync/page.js.map +2 -2
  83. package/dist/modules/data_sync/lib/sync-schedule-service.js +19 -5
  84. package/dist/modules/data_sync/lib/sync-schedule-service.js.map +2 -2
  85. package/dist/modules/dictionaries/api/[dictionaryId]/entries/[entryId]/route.js +3 -3
  86. package/dist/modules/dictionaries/api/[dictionaryId]/entries/[entryId]/route.js.map +2 -2
  87. package/dist/modules/dictionaries/api/[dictionaryId]/route.js +3 -3
  88. package/dist/modules/dictionaries/api/[dictionaryId]/route.js.map +2 -2
  89. package/dist/modules/dictionaries/components/DictionariesManager.js +12 -1
  90. package/dist/modules/dictionaries/components/DictionariesManager.js.map +2 -2
  91. package/dist/modules/dictionaries/components/DictionaryEntriesEditor.js.map +2 -2
  92. package/dist/modules/directory/backend/directory/organizations/[id]/edit/page.js +12 -0
  93. package/dist/modules/directory/backend/directory/organizations/[id]/edit/page.js.map +2 -2
  94. package/dist/modules/directory/backend/directory/tenants/[id]/edit/page.js +12 -0
  95. package/dist/modules/directory/backend/directory/tenants/[id]/edit/page.js.map +2 -2
  96. package/dist/modules/entities/api/entities.js +2 -2
  97. package/dist/modules/entities/api/entities.js.map +2 -2
  98. package/dist/modules/entities/api/records.js +7 -5
  99. package/dist/modules/entities/api/records.js.map +2 -2
  100. package/dist/modules/feature_toggles/api/overrides/route.js +2 -2
  101. package/dist/modules/feature_toggles/api/overrides/route.js.map +2 -2
  102. package/dist/modules/feature_toggles/backend/feature-toggles/global/[id]/edit/page.js.map +2 -2
  103. package/dist/modules/feature_toggles/data/entities.js +1 -1
  104. package/dist/modules/feature_toggles/data/entities.js.map +2 -2
  105. package/dist/modules/feature_toggles/data/validators.js +2 -1
  106. package/dist/modules/feature_toggles/data/validators.js.map +2 -2
  107. package/dist/modules/feature_toggles/lib/queries.js +2 -1
  108. package/dist/modules/feature_toggles/lib/queries.js.map +2 -2
  109. package/dist/modules/inbox_ops/api/settings/route.js +2 -2
  110. package/dist/modules/inbox_ops/api/settings/route.js.map +2 -2
  111. package/dist/modules/resources/backend/resources/resource-types/[id]/edit/page.js +12 -1
  112. package/dist/modules/resources/backend/resources/resource-types/[id]/edit/page.js.map +2 -2
  113. package/dist/modules/resources/backend/resources/resources/[id]/page.js +12 -1
  114. package/dist/modules/resources/backend/resources/resources/[id]/page.js.map +2 -2
  115. package/dist/modules/resources/components/detail/notesAdapter.js +25 -18
  116. package/dist/modules/resources/components/detail/notesAdapter.js.map +2 -2
  117. package/dist/modules/sales/backend/sales/documents/[id]/page.js +14 -1
  118. package/dist/modules/sales/backend/sales/documents/[id]/page.js.map +2 -2
  119. package/dist/modules/sales/commands/documents.js +11 -10
  120. package/dist/modules/sales/commands/documents.js.map +2 -2
  121. package/dist/modules/sales/commands/payments.js +6 -1
  122. package/dist/modules/sales/commands/payments.js.map +2 -2
  123. package/dist/modules/sales/commands/returns.js +3 -3
  124. package/dist/modules/sales/commands/returns.js.map +2 -2
  125. package/dist/modules/sales/commands/shared.js +3 -3
  126. package/dist/modules/sales/commands/shared.js.map +2 -2
  127. package/dist/modules/sales/commands/shipments.js +6 -1
  128. package/dist/modules/sales/commands/shipments.js.map +2 -2
  129. package/dist/modules/sales/components/documents/PaymentDialog.js +6 -3
  130. package/dist/modules/sales/components/documents/PaymentDialog.js.map +2 -2
  131. package/dist/modules/sales/components/documents/PaymentsSection.js +7 -3
  132. package/dist/modules/sales/components/documents/PaymentsSection.js.map +2 -2
  133. package/dist/modules/sales/components/documents/ShipmentDialog.js +6 -3
  134. package/dist/modules/sales/components/documents/ShipmentDialog.js.map +2 -2
  135. package/dist/modules/sales/components/documents/ShipmentsSection.js +7 -3
  136. package/dist/modules/sales/components/documents/ShipmentsSection.js.map +2 -2
  137. package/dist/modules/sales/di.js +39 -3
  138. package/dist/modules/sales/di.js.map +2 -2
  139. package/dist/modules/sales/setup.js +2 -0
  140. package/dist/modules/sales/setup.js.map +2 -2
  141. package/dist/modules/staff/backend/staff/leave-requests/[id]/page.js +12 -1
  142. package/dist/modules/staff/backend/staff/leave-requests/[id]/page.js.map +2 -2
  143. package/dist/modules/staff/backend/staff/team-members/[id]/page.js +12 -1
  144. package/dist/modules/staff/backend/staff/team-members/[id]/page.js.map +2 -2
  145. package/dist/modules/staff/backend/staff/team-roles/[id]/edit/page.js +12 -1
  146. package/dist/modules/staff/backend/staff/team-roles/[id]/edit/page.js.map +2 -2
  147. package/dist/modules/staff/backend/staff/teams/[id]/edit/page.js +12 -1
  148. package/dist/modules/staff/backend/staff/teams/[id]/edit/page.js.map +2 -2
  149. package/dist/modules/staff/commands/job-histories.js +3 -3
  150. package/dist/modules/staff/commands/job-histories.js.map +2 -2
  151. package/dist/modules/staff/components/detail/addressesAdapter.js +33 -24
  152. package/dist/modules/staff/components/detail/addressesAdapter.js.map +2 -2
  153. package/dist/modules/staff/components/detail/notesAdapter.js +25 -18
  154. package/dist/modules/staff/components/detail/notesAdapter.js.map +2 -2
  155. package/dist/modules/translations/api/[entityType]/[entityId]/route.js +37 -0
  156. package/dist/modules/translations/api/[entityType]/[entityId]/route.js.map +2 -2
  157. package/dist/modules/translations/components/TranslationManager.js +7 -1
  158. package/dist/modules/translations/components/TranslationManager.js.map +2 -2
  159. package/dist/modules/workflows/api/definitions/[id]/route.js +3 -3
  160. package/dist/modules/workflows/api/definitions/[id]/route.js.map +2 -2
  161. package/dist/modules/workflows/backend/definitions/[id]/page.js +12 -1
  162. package/dist/modules/workflows/backend/definitions/[id]/page.js.map +2 -2
  163. package/dist/modules/workflows/backend/definitions/visual-editor/page.js +11 -1
  164. package/dist/modules/workflows/backend/definitions/visual-editor/page.js.map +2 -2
  165. package/package.json +7 -7
  166. package/src/modules/attachments/components/AttachmentPartitionSettings.tsx +3 -0
  167. package/src/modules/auth/api/logout.ts +2 -3
  168. package/src/modules/auth/api/roles/acl/route.ts +2 -2
  169. package/src/modules/auth/api/session/refresh.ts +5 -6
  170. package/src/modules/auth/api/users/acl/route.ts +2 -2
  171. package/src/modules/auth/backend/roles/[id]/edit/page.tsx +17 -0
  172. package/src/modules/auth/backend/users/[id]/edit/page.tsx +17 -0
  173. package/src/modules/auth/data/entities.ts +2 -2
  174. package/src/modules/auth/lib/requestRedirect.ts +44 -2
  175. package/src/modules/business_rules/api/rules/route.ts +3 -3
  176. package/src/modules/business_rules/api/sets/route.ts +3 -3
  177. package/src/modules/business_rules/backend/rules/[id]/page.tsx +17 -1
  178. package/src/modules/business_rules/backend/sets/[id]/page.tsx +18 -1
  179. package/src/modules/catalog/api/product-media/route.ts +4 -0
  180. package/src/modules/catalog/backend/catalog/categories/[id]/edit/page.tsx +17 -0
  181. package/src/modules/catalog/backend/catalog/products/[id]/page.tsx +21 -0
  182. package/src/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.tsx +16 -1
  183. package/src/modules/catalog/components/PriceKindSettings.tsx +15 -7
  184. package/src/modules/catalog/lib/bulkDelete.ts +6 -0
  185. package/src/modules/currencies/backend/currencies/[id]/page.tsx +20 -3
  186. package/src/modules/currencies/backend/currencies/page.tsx +2 -1
  187. package/src/modules/currencies/backend/exchange-rates/[id]/page.tsx +17 -1
  188. package/src/modules/currencies/backend/exchange-rates/page.tsx +3 -2
  189. package/src/modules/customer_accounts/api/admin/roles/[id].ts +3 -3
  190. package/src/modules/customer_accounts/api/admin/users/[id].ts +3 -3
  191. package/src/modules/customers/api/addresses/route.ts +2 -0
  192. package/src/modules/customers/api/tags/route.ts +6 -1
  193. package/src/modules/customers/backend/customers/companies-v2/[id]/page.tsx +19 -2
  194. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.ts +29 -8
  195. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.ts +47 -20
  196. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealPipeline.ts +13 -3
  197. package/src/modules/customers/backend/customers/deals/[id]/page.tsx +21 -1
  198. package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +18 -2
  199. package/src/modules/customers/commands/interactions.ts +5 -5
  200. package/src/modules/customers/commands/pipeline-stages.ts +26 -2
  201. package/src/modules/customers/commands/pipelines.ts +26 -2
  202. package/src/modules/customers/components/detail/AddressesSection.tsx +50 -28
  203. package/src/modules/customers/components/detail/DealForm.tsx +12 -0
  204. package/src/modules/customers/components/detail/notesAdapter.ts +29 -18
  205. package/src/modules/customers/i18n/de.json +2 -0
  206. package/src/modules/customers/i18n/en.json +2 -0
  207. package/src/modules/customers/i18n/es.json +2 -0
  208. package/src/modules/customers/i18n/pl.json +2 -0
  209. package/src/modules/data_sync/api/schedules/[id]/route.ts +38 -20
  210. package/src/modules/data_sync/api/schedules/route.ts +1 -1
  211. package/src/modules/data_sync/backend/data-sync/page.tsx +32 -15
  212. package/src/modules/data_sync/lib/sync-schedule-service.ts +30 -5
  213. package/src/modules/dictionaries/api/[dictionaryId]/entries/[entryId]/route.ts +3 -3
  214. package/src/modules/dictionaries/api/[dictionaryId]/route.ts +3 -3
  215. package/src/modules/dictionaries/components/DictionariesManager.tsx +21 -1
  216. package/src/modules/dictionaries/components/DictionaryEntriesEditor.tsx +2 -0
  217. package/src/modules/directory/backend/directory/organizations/[id]/edit/page.tsx +17 -0
  218. package/src/modules/directory/backend/directory/tenants/[id]/edit/page.tsx +17 -0
  219. package/src/modules/entities/api/entities.ts +2 -2
  220. package/src/modules/entities/api/records.ts +7 -5
  221. package/src/modules/feature_toggles/api/overrides/route.ts +2 -2
  222. package/src/modules/feature_toggles/backend/feature-toggles/global/[id]/edit/page.tsx +12 -0
  223. package/src/modules/feature_toggles/data/entities.ts +1 -1
  224. package/src/modules/feature_toggles/data/validators.ts +1 -0
  225. package/src/modules/feature_toggles/lib/queries.ts +1 -0
  226. package/src/modules/inbox_ops/api/settings/route.ts +2 -2
  227. package/src/modules/resources/backend/resources/resource-types/[id]/edit/page.tsx +17 -1
  228. package/src/modules/resources/backend/resources/resources/[id]/page.tsx +17 -1
  229. package/src/modules/resources/components/detail/notesAdapter.ts +29 -18
  230. package/src/modules/sales/backend/sales/documents/[id]/page.tsx +17 -1
  231. package/src/modules/sales/commands/documents.ts +11 -10
  232. package/src/modules/sales/commands/payments.ts +11 -0
  233. package/src/modules/sales/commands/returns.ts +3 -3
  234. package/src/modules/sales/commands/shared.ts +10 -4
  235. package/src/modules/sales/commands/shipments.ts +14 -0
  236. package/src/modules/sales/components/documents/PaymentDialog.tsx +7 -3
  237. package/src/modules/sales/components/documents/PaymentsSection.tsx +8 -3
  238. package/src/modules/sales/components/documents/ShipmentDialog.tsx +7 -3
  239. package/src/modules/sales/components/documents/ShipmentsSection.tsx +8 -3
  240. package/src/modules/sales/di.ts +68 -9
  241. package/src/modules/sales/setup.ts +2 -0
  242. package/src/modules/staff/backend/staff/leave-requests/[id]/page.tsx +17 -1
  243. package/src/modules/staff/backend/staff/team-members/[id]/page.tsx +17 -1
  244. package/src/modules/staff/backend/staff/team-roles/[id]/edit/page.tsx +17 -1
  245. package/src/modules/staff/backend/staff/teams/[id]/edit/page.tsx +17 -1
  246. package/src/modules/staff/commands/job-histories.ts +3 -3
  247. package/src/modules/staff/components/detail/addressesAdapter.ts +40 -23
  248. package/src/modules/staff/components/detail/notesAdapter.ts +28 -18
  249. package/src/modules/translations/api/[entityType]/[entityId]/route.ts +74 -0
  250. package/src/modules/translations/components/TranslationManager.tsx +14 -1
  251. package/src/modules/workflows/api/definitions/[id]/route.ts +3 -3
  252. package/src/modules/workflows/backend/definitions/[id]/page.tsx +18 -1
  253. package/src/modules/workflows/backend/definitions/visual-editor/page.tsx +19 -1
@@ -2,7 +2,7 @@
2
2
 
3
3
  import * as React from 'react'
4
4
  import Link from 'next/link'
5
- import { useRouter, useSearchParams } from 'next/navigation'
5
+ import { useRouter, useSearchParams, usePathname } from 'next/navigation'
6
6
  import { User, Hash, Users, Building2 } from 'lucide-react'
7
7
  import { Page, PageBody } from '@open-mercato/ui/backend/Page'
8
8
  import { CrudForm } from '@open-mercato/ui/backend/CrudForm'
@@ -24,6 +24,7 @@ import { AttachmentsSection, ErrorMessage, LoadingMessage, RecordNotFoundState,
24
24
  import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
25
25
  import { InjectionSpot, useInjectionWidgets } from '@open-mercato/ui/backend/injection/InjectionSpot'
26
26
  import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
27
+ import { buildRecordInjectionContext, useSetCurrentRecordInjectionContext } from '@open-mercato/ui/backend/injection/recordContext'
27
28
  import { createTranslatorWithFallback } from '@open-mercato/shared/lib/i18n/translate'
28
29
 
29
30
  import { ActivitiesSection } from '../../../../components/detail/ActivitiesSection'
@@ -57,6 +58,7 @@ export default function PersonDetailV2Page({ params }: { params?: { id?: string
57
58
  const t = useT()
58
59
  const router = useRouter()
59
60
  const searchParams = useSearchParams()
61
+ const pathname = usePathname()
60
62
  const { organizationId } = useOrganizationScopeDetail()
61
63
  const isMobile = useIsMobile()
62
64
  const { confirm, ConfirmDialogElement } = useConfirmDialog()
@@ -219,6 +221,20 @@ export default function PersonDetailV2Page({ params }: { params?: { id?: string
219
221
  [injectionContext, runMutation],
220
222
  )
221
223
 
224
+ // Publish page-load record context to the AppShell-owned `backend:record:current`
225
+ // mount so the enterprise record_locks widget resolves `customers.person` + id
226
+ // explicitly (the hardcoded path allowlist misses the `people-v2` route).
227
+ // Presence/acquire/heartbeat run on load; the hook clears on unmount/record switch.
228
+ useSetCurrentRecordInjectionContext(
229
+ buildRecordInjectionContext({
230
+ resourceKind: 'customers.person',
231
+ resourceId: currentPersonId,
232
+ updatedAt: data?.person?.updatedAt ?? data?.person?.updated_at ?? null,
233
+ data: data as Record<string, unknown> | null,
234
+ path: pathname,
235
+ }),
236
+ )
237
+
222
238
  const handleAddActivity = React.useCallback((kind: ActivityKind) => {
223
239
  setScheduleEditData({
224
240
  id: '',
@@ -518,7 +534,7 @@ export default function PersonDetailV2Page({ params }: { params?: { id?: string
518
534
  <CrudForm<PersonEditFormValues>
519
535
  embedded
520
536
  trackDirtyWhenEmbedded
521
- injectionSpotId="customers.person"
537
+ injectionSpotId="crud-form:customers.person"
522
538
  entityIds={[E.customers.customer_entity, E.customers.customer_person_profile]}
523
539
  schema={formSchema}
524
540
  fields={fields}
@@ -39,7 +39,7 @@ import {
39
39
  buildCustomFieldResetMap,
40
40
  } from '@open-mercato/shared/lib/commands/customFieldSnapshots'
41
41
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
42
- import { enforceRecordGoneIsConflict, enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
42
+ import { enforceRecordGoneIsConflict, enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
43
43
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
44
44
  import type { CrudIndexerConfig, CrudEventsConfig } from '@open-mercato/shared/lib/crud/types'
45
45
  import { recomputeNextInteraction } from '../lib/interactionProjection'
@@ -644,7 +644,7 @@ const updateInteractionCommand: CommandHandler<InteractionUpdateInput, { interac
644
644
  // when the client opted into optimistic locking, a stale edit fails with the
645
645
  // unified 409 instead of silently overwriting (#2055). Strictly additive —
646
646
  // no-op when no expected-version header is present.
647
- enforceCommandOptimisticLock({
647
+ await enforceCommandOptimisticLockWithGuards(ctx.container, {
648
648
  resourceKind: 'customers.interaction',
649
649
  resourceId: interaction.id,
650
650
  current: interaction.updatedAt,
@@ -912,7 +912,7 @@ const completeInteractionCommand: CommandHandler<InteractionCompleteInput, { int
912
912
  ensureTenantScope(ctx, interaction.tenantId)
913
913
  ensureOrganizationScope(ctx, interaction.organizationId)
914
914
 
915
- enforceCommandOptimisticLock({
915
+ await enforceCommandOptimisticLockWithGuards(ctx.container, {
916
916
  resourceKind: 'customers.interaction',
917
917
  resourceId: interaction.id,
918
918
  current: interaction.updatedAt,
@@ -1052,7 +1052,7 @@ const cancelInteractionCommand: CommandHandler<InteractionCancelInput, { interac
1052
1052
  ensureTenantScope(ctx, interaction.tenantId)
1053
1053
  ensureOrganizationScope(ctx, interaction.organizationId)
1054
1054
 
1055
- enforceCommandOptimisticLock({
1055
+ await enforceCommandOptimisticLockWithGuards(ctx.container, {
1056
1056
  resourceKind: 'customers.interaction',
1057
1057
  resourceId: interaction.id,
1058
1058
  current: interaction.updatedAt,
@@ -1190,7 +1190,7 @@ const deleteInteractionCommand: CommandHandler<{ body?: Record<string, unknown>;
1190
1190
  ensureTenantScope(ctx, interaction.tenantId)
1191
1191
  ensureOrganizationScope(ctx, interaction.organizationId)
1192
1192
 
1193
- enforceCommandOptimisticLock({
1193
+ await enforceCommandOptimisticLockWithGuards(ctx.container, {
1194
1194
  resourceKind: 'customers.interaction',
1195
1195
  resourceId: interaction.id,
1196
1196
  current: interaction.updatedAt,
@@ -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
  }