@open-mercato/core 0.6.6-develop.6204.1.30b1f58642 → 0.6.6-develop.6227.1.2695efff30

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 (201) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/currencies/backend/currencies/[id]/page.js +33 -8
  3. package/dist/modules/currencies/backend/currencies/[id]/page.js.map +2 -2
  4. package/dist/modules/currencies/backend/currencies/page.js +63 -27
  5. package/dist/modules/currencies/backend/currencies/page.js.map +2 -2
  6. package/dist/modules/currencies/backend/exchange-rates/page.js +35 -14
  7. package/dist/modules/currencies/backend/exchange-rates/page.js.map +2 -2
  8. package/dist/modules/currencies/components/CurrencyFetchingConfig.js +104 -41
  9. package/dist/modules/currencies/components/CurrencyFetchingConfig.js.map +2 -2
  10. package/dist/modules/customer_accounts/api/admin/roles/[id]/acl.js +57 -8
  11. package/dist/modules/customer_accounts/api/admin/roles/[id]/acl.js.map +2 -2
  12. package/dist/modules/customer_accounts/backend/customer_accounts/roles/[id]/page.js +5 -2
  13. package/dist/modules/customer_accounts/backend/customer_accounts/roles/[id]/page.js.map +2 -2
  14. package/dist/modules/customer_accounts/backend/customer_accounts/roles/page.js +30 -14
  15. package/dist/modules/customer_accounts/backend/customer_accounts/roles/page.js.map +2 -2
  16. package/dist/modules/customer_accounts/widgets/injection/account-status/widget.client.js +29 -19
  17. package/dist/modules/customer_accounts/widgets/injection/account-status/widget.client.js.map +2 -2
  18. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.js +166 -64
  19. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.js.map +2 -2
  20. package/dist/modules/customers/components/AddressFormatSettings.js +31 -16
  21. package/dist/modules/customers/components/AddressFormatSettings.js.map +2 -2
  22. package/dist/modules/customers/components/DictionarySettings.js +57 -31
  23. package/dist/modules/customers/components/DictionarySettings.js.map +2 -2
  24. package/dist/modules/customers/components/PipelineSettings.js +156 -76
  25. package/dist/modules/customers/components/PipelineSettings.js.map +2 -2
  26. package/dist/modules/customers/components/calendar/AgendaList.js +10 -4
  27. package/dist/modules/customers/components/calendar/AgendaList.js.map +2 -2
  28. package/dist/modules/customers/components/calendar/CalendarSettingsModal.js +18 -2
  29. package/dist/modules/customers/components/calendar/CalendarSettingsModal.js.map +2 -2
  30. package/dist/modules/customers/components/calendar/MonthGrid.js +5 -2
  31. package/dist/modules/customers/components/calendar/MonthGrid.js.map +2 -2
  32. package/dist/modules/customers/components/calendar/editor/PeopleField.js +6 -0
  33. package/dist/modules/customers/components/calendar/editor/PeopleField.js.map +2 -2
  34. package/dist/modules/customers/components/calendar/editor/RelatedToField.js +2 -0
  35. package/dist/modules/customers/components/calendar/editor/RelatedToField.js.map +2 -2
  36. package/dist/modules/customers/components/calendar/editor/ScheduleSection.js +4 -1
  37. package/dist/modules/customers/components/calendar/editor/ScheduleSection.js.map +2 -2
  38. package/dist/modules/customers/lib/calendar/labels.js +33 -0
  39. package/dist/modules/customers/lib/calendar/labels.js.map +7 -0
  40. package/dist/modules/dashboards/components/WidgetVisibilityEditor.js +32 -11
  41. package/dist/modules/dashboards/components/WidgetVisibilityEditor.js.map +2 -2
  42. package/dist/modules/data_sync/api/mappings/[id]/route.js +59 -0
  43. package/dist/modules/data_sync/api/mappings/[id]/route.js.map +2 -2
  44. package/dist/modules/data_sync/api/mappings/route.js +44 -0
  45. package/dist/modules/data_sync/api/mappings/route.js.map +2 -2
  46. package/dist/modules/data_sync/api/run.js +31 -0
  47. package/dist/modules/data_sync/api/run.js.map +2 -2
  48. package/dist/modules/data_sync/api/runs/[id]/cancel.js +31 -0
  49. package/dist/modules/data_sync/api/runs/[id]/cancel.js.map +2 -2
  50. package/dist/modules/data_sync/api/runs/[id]/retry.js +31 -0
  51. package/dist/modules/data_sync/api/runs/[id]/retry.js.map +2 -2
  52. package/dist/modules/data_sync/api/schedules/[id]/route.js +59 -3
  53. package/dist/modules/data_sync/api/schedules/[id]/route.js.map +2 -2
  54. package/dist/modules/data_sync/api/schedules/route.js +33 -4
  55. package/dist/modules/data_sync/api/schedules/route.js.map +2 -2
  56. package/dist/modules/dictionaries/components/DictionariesManager.js +7 -4
  57. package/dist/modules/dictionaries/components/DictionariesManager.js.map +2 -2
  58. package/dist/modules/directory/api/organization-branding/route.js +54 -2
  59. package/dist/modules/directory/api/organization-branding/route.js.map +2 -2
  60. package/dist/modules/directory/backend/directory/branding/page.js +15 -9
  61. package/dist/modules/directory/backend/directory/branding/page.js.map +2 -2
  62. package/dist/modules/directory/backend/directory/organizations/page.js +28 -9
  63. package/dist/modules/directory/backend/directory/organizations/page.js.map +2 -2
  64. package/dist/modules/directory/backend/directory/tenants/page.js +29 -13
  65. package/dist/modules/directory/backend/directory/tenants/page.js.map +2 -2
  66. package/dist/modules/entities/api/definitions.batch.js +15 -0
  67. package/dist/modules/entities/api/definitions.batch.js.map +2 -2
  68. package/dist/modules/entities/api/definitions.js +26 -0
  69. package/dist/modules/entities/api/definitions.js.map +2 -2
  70. package/dist/modules/entities/api/definitions.mutation-guard.js +57 -0
  71. package/dist/modules/entities/api/definitions.mutation-guard.js.map +7 -0
  72. package/dist/modules/entities/api/definitions.restore.js +15 -0
  73. package/dist/modules/entities/api/definitions.restore.js.map +2 -2
  74. package/dist/modules/entities/api/entities.js +31 -3
  75. package/dist/modules/entities/api/entities.js.map +2 -2
  76. package/dist/modules/entities/api/records.js +18 -0
  77. package/dist/modules/entities/api/records.js.map +2 -2
  78. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js +28 -10
  79. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js.map +2 -2
  80. package/dist/modules/feature_toggles/api/overrides/route.js +38 -1
  81. package/dist/modules/feature_toggles/api/overrides/route.js.map +2 -2
  82. package/dist/modules/inbox_ops/api/emails/[id]/route.js +30 -0
  83. package/dist/modules/inbox_ops/api/emails/[id]/route.js.map +2 -2
  84. package/dist/modules/inbox_ops/api/proposals/[id]/accept-all/route.js +30 -0
  85. package/dist/modules/inbox_ops/api/proposals/[id]/accept-all/route.js.map +2 -2
  86. package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js +31 -0
  87. package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js.map +2 -2
  88. package/dist/modules/inbox_ops/api/proposals/[id]/categorize/route.js +31 -0
  89. package/dist/modules/inbox_ops/api/proposals/[id]/categorize/route.js.map +2 -2
  90. package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsActions.js +68 -39
  91. package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsActions.js.map +2 -2
  92. package/dist/modules/notifications/frontend/NotificationSettingsPageClient.js +14 -4
  93. package/dist/modules/notifications/frontend/NotificationSettingsPageClient.js.map +2 -2
  94. package/dist/modules/payment_gateways/api/cancel/route.js +37 -0
  95. package/dist/modules/payment_gateways/api/cancel/route.js.map +2 -2
  96. package/dist/modules/payment_gateways/api/capture/route.js +37 -0
  97. package/dist/modules/payment_gateways/api/capture/route.js.map +2 -2
  98. package/dist/modules/payment_gateways/api/guards.js +31 -0
  99. package/dist/modules/payment_gateways/api/guards.js.map +7 -0
  100. package/dist/modules/payment_gateways/api/refund/route.js +37 -0
  101. package/dist/modules/payment_gateways/api/refund/route.js.map +2 -2
  102. package/dist/modules/payment_gateways/api/sessions/route.js +37 -0
  103. package/dist/modules/payment_gateways/api/sessions/route.js.map +2 -2
  104. package/dist/modules/planner/api/availability-date-specific.js +11 -1
  105. package/dist/modules/planner/api/availability-date-specific.js.map +2 -2
  106. package/dist/modules/planner/backend/planner/availability-rulesets/page.js +20 -3
  107. package/dist/modules/planner/backend/planner/availability-rulesets/page.js.map +2 -2
  108. package/dist/modules/planner/commands/availability-date-specific.js +16 -0
  109. package/dist/modules/planner/commands/availability-date-specific.js.map +2 -2
  110. package/dist/modules/planner/components/AvailabilityRulesEditor.js +109 -40
  111. package/dist/modules/planner/components/AvailabilityRulesEditor.js.map +2 -2
  112. package/dist/modules/query_index/api/purge.js +35 -3
  113. package/dist/modules/query_index/api/purge.js.map +2 -2
  114. package/dist/modules/query_index/api/reindex.js +41 -3
  115. package/dist/modules/query_index/api/reindex.js.map +2 -2
  116. package/dist/modules/query_index/components/QueryIndexesTable.js +57 -24
  117. package/dist/modules/query_index/components/QueryIndexesTable.js.map +2 -2
  118. package/dist/modules/shipping_carriers/api/shipments/route.js +31 -0
  119. package/dist/modules/shipping_carriers/api/shipments/route.js.map +2 -2
  120. package/dist/modules/shipping_carriers/api/tracking/refresh/route.js +55 -0
  121. package/dist/modules/shipping_carriers/api/tracking/refresh/route.js.map +7 -0
  122. package/dist/modules/shipping_carriers/data/validators.js +6 -1
  123. package/dist/modules/shipping_carriers/data/validators.js.map +2 -2
  124. package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.js +28 -8
  125. package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.js.map +2 -2
  126. package/dist/modules/shipping_carriers/lib/shipping-service.js +37 -7
  127. package/dist/modules/shipping_carriers/lib/shipping-service.js.map +2 -2
  128. package/dist/modules/shipping_carriers/workers/status-poller.js +1 -1
  129. package/dist/modules/shipping_carriers/workers/status-poller.js.map +2 -2
  130. package/dist/modules/translations/components/TranslationManager.js +49 -20
  131. package/dist/modules/translations/components/TranslationManager.js.map +2 -2
  132. package/package.json +7 -7
  133. package/src/modules/currencies/backend/currencies/[id]/page.tsx +40 -10
  134. package/src/modules/currencies/backend/currencies/page.tsx +68 -29
  135. package/src/modules/currencies/backend/exchange-rates/page.tsx +40 -15
  136. package/src/modules/currencies/components/CurrencyFetchingConfig.tsx +110 -41
  137. package/src/modules/customer_accounts/api/admin/roles/[id]/acl.ts +69 -7
  138. package/src/modules/customer_accounts/backend/customer_accounts/roles/[id]/page.tsx +16 -8
  139. package/src/modules/customer_accounts/backend/customer_accounts/roles/page.tsx +32 -14
  140. package/src/modules/customer_accounts/widgets/injection/account-status/widget.client.tsx +32 -20
  141. package/src/modules/customers/backend/config/customers/pipeline-stages/page.tsx +171 -64
  142. package/src/modules/customers/components/AddressFormatSettings.tsx +39 -19
  143. package/src/modules/customers/components/DictionarySettings.tsx +63 -29
  144. package/src/modules/customers/components/PipelineSettings.tsx +165 -80
  145. package/src/modules/customers/components/calendar/AgendaList.tsx +13 -8
  146. package/src/modules/customers/components/calendar/CalendarSettingsModal.tsx +16 -2
  147. package/src/modules/customers/components/calendar/MonthGrid.tsx +5 -2
  148. package/src/modules/customers/components/calendar/editor/PeopleField.tsx +6 -0
  149. package/src/modules/customers/components/calendar/editor/RelatedToField.tsx +2 -0
  150. package/src/modules/customers/components/calendar/editor/ScheduleSection.tsx +7 -0
  151. package/src/modules/customers/i18n/de.json +11 -7
  152. package/src/modules/customers/i18n/en.json +6 -2
  153. package/src/modules/customers/i18n/es.json +11 -7
  154. package/src/modules/customers/i18n/pl.json +11 -7
  155. package/src/modules/customers/lib/calendar/labels.ts +42 -0
  156. package/src/modules/dashboards/components/WidgetVisibilityEditor.tsx +39 -11
  157. package/src/modules/data_sync/api/mappings/[id]/route.ts +63 -0
  158. package/src/modules/data_sync/api/mappings/route.ts +48 -0
  159. package/src/modules/data_sync/api/run.ts +33 -0
  160. package/src/modules/data_sync/api/runs/[id]/cancel.ts +34 -0
  161. package/src/modules/data_sync/api/runs/[id]/retry.ts +33 -0
  162. package/src/modules/data_sync/api/schedules/[id]/route.ts +64 -2
  163. package/src/modules/data_sync/api/schedules/route.ts +36 -4
  164. package/src/modules/dictionaries/components/DictionariesManager.tsx +7 -4
  165. package/src/modules/directory/api/organization-branding/route.ts +61 -0
  166. package/src/modules/directory/backend/directory/branding/page.tsx +16 -10
  167. package/src/modules/directory/backend/directory/organizations/page.tsx +35 -8
  168. package/src/modules/directory/backend/directory/tenants/page.tsx +37 -13
  169. package/src/modules/entities/api/definitions.batch.ts +17 -0
  170. package/src/modules/entities/api/definitions.mutation-guard.ts +80 -0
  171. package/src/modules/entities/api/definitions.restore.ts +17 -0
  172. package/src/modules/entities/api/definitions.ts +30 -0
  173. package/src/modules/entities/api/entities.ts +35 -3
  174. package/src/modules/entities/api/records.ts +20 -0
  175. package/src/modules/entities/backend/entities/user/[entityId]/records/page.tsx +33 -10
  176. package/src/modules/feature_toggles/api/overrides/route.ts +44 -1
  177. package/src/modules/inbox_ops/api/emails/[id]/route.ts +32 -0
  178. package/src/modules/inbox_ops/api/proposals/[id]/accept-all/route.ts +32 -0
  179. package/src/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.ts +33 -0
  180. package/src/modules/inbox_ops/api/proposals/[id]/categorize/route.ts +33 -0
  181. package/src/modules/messages/components/message-detail/hooks/useMessageDetailsActions.ts +80 -42
  182. package/src/modules/notifications/frontend/NotificationSettingsPageClient.tsx +21 -4
  183. package/src/modules/payment_gateways/api/cancel/route.ts +39 -0
  184. package/src/modules/payment_gateways/api/capture/route.ts +39 -0
  185. package/src/modules/payment_gateways/api/guards.ts +59 -0
  186. package/src/modules/payment_gateways/api/refund/route.ts +39 -0
  187. package/src/modules/payment_gateways/api/sessions/route.ts +40 -0
  188. package/src/modules/planner/api/availability-date-specific.ts +10 -0
  189. package/src/modules/planner/backend/planner/availability-rulesets/page.tsx +26 -5
  190. package/src/modules/planner/commands/availability-date-specific.ts +24 -0
  191. package/src/modules/planner/components/AvailabilityRulesEditor.tsx +122 -41
  192. package/src/modules/query_index/api/purge.ts +37 -3
  193. package/src/modules/query_index/api/reindex.ts +43 -3
  194. package/src/modules/query_index/components/QueryIndexesTable.tsx +66 -24
  195. package/src/modules/shipping_carriers/api/shipments/route.ts +31 -0
  196. package/src/modules/shipping_carriers/api/tracking/refresh/route.ts +53 -0
  197. package/src/modules/shipping_carriers/data/validators.ts +5 -0
  198. package/src/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.ts +29 -8
  199. package/src/modules/shipping_carriers/lib/shipping-service.ts +43 -7
  200. package/src/modules/shipping_carriers/workers/status-poller.ts +1 -1
  201. package/src/modules/translations/components/TranslationManager.tsx +65 -21
@@ -3,7 +3,8 @@
3
3
  import { useState, useEffect, useCallback, useMemo } from 'react'
4
4
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
5
5
  import { apiCall, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
6
- import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
6
+ import { buildOptimisticLockHeader, extractOptimisticLockConflict } from '@open-mercato/ui/backend/utils/optimisticLock'
7
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
7
8
  import { Button } from '@open-mercato/ui/primitives/button'
8
9
  import { Spinner } from '@open-mercato/ui/primitives/spinner'
9
10
  import { Switch } from '@open-mercato/ui/primitives/switch'
@@ -31,16 +32,35 @@ export default function CurrencyFetchingConfig() {
31
32
  // Available providers that should be configured
32
33
  const availableProviders = useMemo(() => ['NBP', 'Raiffeisen Bank Polska'], [])
33
34
 
35
+ const mutationContextId = 'currencies-fetch-configs:mutation'
36
+ const { runMutation, retryLastMutation } = useGuardedMutation<{
37
+ formId: string
38
+ resourceKind: string
39
+ resourceId?: string
40
+ retryLastMutation: () => Promise<boolean>
41
+ }>({
42
+ contextId: mutationContextId,
43
+ blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
44
+ })
45
+
34
46
  const createProviderConfig = useCallback(async (provider: string) => {
35
47
  try {
36
- await apiCall('/api/currencies/fetch-configs', {
37
- method: 'POST',
38
- headers: { 'Content-Type': 'application/json' },
39
- body: JSON.stringify({
40
- provider,
41
- isEnabled: false,
42
- syncTime: '09:00',
48
+ await runMutation({
49
+ operation: () => apiCall('/api/currencies/fetch-configs', {
50
+ method: 'POST',
51
+ headers: { 'Content-Type': 'application/json' },
52
+ body: JSON.stringify({
53
+ provider,
54
+ isEnabled: false,
55
+ syncTime: '09:00',
56
+ }),
43
57
  }),
58
+ context: {
59
+ formId: mutationContextId,
60
+ resourceKind: 'currencies.fetch_config',
61
+ retryLastMutation,
62
+ },
63
+ mutationPayload: { provider, isEnabled: false, syncTime: '09:00' },
44
64
  })
45
65
  } catch (err: any) {
46
66
  // Ignore errors for duplicate providers
@@ -48,7 +68,7 @@ export default function CurrencyFetchingConfig() {
48
68
  throw err
49
69
  }
50
70
  }
51
- }, [])
71
+ }, [mutationContextId, retryLastMutation, runMutation])
52
72
 
53
73
  const initializeMissingProviders = useCallback(async (providers: string[]) => {
54
74
  setInitializing(true)
@@ -96,19 +116,38 @@ export default function CurrencyFetchingConfig() {
96
116
 
97
117
  const toggleEnabled = useCallback(async (configId: string, currentValue: boolean, updatedAt?: string | null) => {
98
118
  try {
99
- const { result } = await withScopedApiRequestHeaders(
100
- buildOptimisticLockHeader(updatedAt),
101
- () =>
102
- apiCall('/api/currencies/fetch-configs', {
103
- method: 'PUT',
104
- headers: { 'Content-Type': 'application/json' },
105
- body: JSON.stringify({
106
- id: configId,
107
- isEnabled: !currentValue,
108
- }),
109
- }),
110
- )
119
+ const call = await runMutation({
120
+ operation: async () => {
121
+ const response = await withScopedApiRequestHeaders(
122
+ buildOptimisticLockHeader(updatedAt),
123
+ () =>
124
+ apiCall('/api/currencies/fetch-configs', {
125
+ method: 'PUT',
126
+ headers: { 'Content-Type': 'application/json' },
127
+ body: JSON.stringify({
128
+ id: configId,
129
+ isEnabled: !currentValue,
130
+ }),
131
+ }),
132
+ )
133
+ if (!response.ok) {
134
+ throw Object.assign(new Error('[internal] currencies.fetchConfig.toggle failed'), {
135
+ status: response.status,
136
+ ...((response.result as Record<string, unknown> | null) ?? {}),
137
+ })
138
+ }
139
+ return response
140
+ },
141
+ context: {
142
+ formId: mutationContextId,
143
+ resourceKind: 'currencies.fetch_config',
144
+ resourceId: configId,
145
+ retryLastMutation,
146
+ },
147
+ mutationPayload: { id: configId, isEnabled: !currentValue },
148
+ })
111
149
 
150
+ const result = call.result as { config?: FetchConfig } | null
112
151
  if (result?.config) {
113
152
  setConfigs((prev) =>
114
153
  prev.map((c) => (c.id === configId ? (result.config as FetchConfig) : c))
@@ -121,47 +160,77 @@ export default function CurrencyFetchingConfig() {
121
160
  )
122
161
  }
123
162
  } catch (err: any) {
163
+ if (extractOptimisticLockConflict(err)) return
124
164
  flash(err.message || t('currencies.fetch.error_update_config'), 'error')
125
165
  }
126
- }, [t])
166
+ }, [mutationContextId, retryLastMutation, runMutation, t])
127
167
 
128
168
  const updateSyncTime = useCallback(async (configId: string, syncTime: string, updatedAt?: string | null) => {
129
169
  try {
130
- const { result } = await withScopedApiRequestHeaders(
131
- buildOptimisticLockHeader(updatedAt),
132
- () =>
133
- apiCall('/api/currencies/fetch-configs', {
134
- method: 'PUT',
135
- headers: { 'Content-Type': 'application/json' },
136
- body: JSON.stringify({
137
- id: configId,
138
- syncTime: syncTime || null,
139
- }),
140
- }),
141
- )
170
+ const call = await runMutation({
171
+ operation: async () => {
172
+ const response = await withScopedApiRequestHeaders(
173
+ buildOptimisticLockHeader(updatedAt),
174
+ () =>
175
+ apiCall('/api/currencies/fetch-configs', {
176
+ method: 'PUT',
177
+ headers: { 'Content-Type': 'application/json' },
178
+ body: JSON.stringify({
179
+ id: configId,
180
+ syncTime: syncTime || null,
181
+ }),
182
+ }),
183
+ )
184
+ if (!response.ok) {
185
+ throw Object.assign(new Error('[internal] currencies.fetchConfig.updateSyncTime failed'), {
186
+ status: response.status,
187
+ ...((response.result as Record<string, unknown> | null) ?? {}),
188
+ })
189
+ }
190
+ return response
191
+ },
192
+ context: {
193
+ formId: mutationContextId,
194
+ resourceKind: 'currencies.fetch_config',
195
+ resourceId: configId,
196
+ retryLastMutation,
197
+ },
198
+ mutationPayload: { id: configId, syncTime: syncTime || null },
199
+ })
142
200
 
201
+ const result = call.result as { config?: FetchConfig } | null
143
202
  if (result?.config) {
144
203
  setConfigs((prev) =>
145
204
  prev.map((c) => (c.id === configId ? (result.config as FetchConfig) : c))
146
205
  )
147
206
  }
148
207
  } catch (err: any) {
208
+ if (extractOptimisticLockConflict(err)) return
149
209
  flash(err.message || t('currencies.fetch.error_update_sync_time'), 'error')
150
210
  }
151
- }, [t])
211
+ }, [mutationContextId, retryLastMutation, runMutation, t])
152
212
 
153
213
  const fetchNow = useCallback(async (provider: string) => {
154
214
  setFetching(provider)
155
215
 
156
216
  try {
157
- const { result } = await apiCall('/api/currencies/fetch-rates', {
158
- method: 'POST',
159
- headers: { 'Content-Type': 'application/json' },
160
- body: JSON.stringify({
161
- providers: [provider],
217
+ const call = await runMutation({
218
+ operation: () => apiCall('/api/currencies/fetch-rates', {
219
+ method: 'POST',
220
+ headers: { 'Content-Type': 'application/json' },
221
+ body: JSON.stringify({
222
+ providers: [provider],
223
+ }),
162
224
  }),
225
+ context: {
226
+ formId: mutationContextId,
227
+ resourceKind: 'currencies.fetch_rates',
228
+ retryLastMutation,
229
+ },
230
+ mutationPayload: { providers: [provider] },
163
231
  })
164
232
 
233
+ const result = call.result as { byProvider?: Record<string, { count: number; errors?: string[] }> } | null
165
234
  if (result) {
166
235
  const byProvider = result.byProvider as Record<string, { count: number; errors?: string[] }>
167
236
  const count = byProvider?.[provider]?.count || 0
@@ -178,7 +247,7 @@ export default function CurrencyFetchingConfig() {
178
247
  } finally {
179
248
  setFetching(null)
180
249
  }
181
- }, [t, loadConfigs])
250
+ }, [mutationContextId, retryLastMutation, runMutation, t, loadConfigs])
182
251
 
183
252
  useEffect(() => {
184
253
  loadConfigs()
@@ -7,18 +7,27 @@ import { RbacService } from '@open-mercato/core/modules/auth/services/rbacServic
7
7
  import { CustomerRole, CustomerRoleAcl } from '@open-mercato/core/modules/customer_accounts/data/entities'
8
8
  import { CustomerRbacService } from '@open-mercato/core/modules/customer_accounts/services/customerRbacService'
9
9
  import { updateRoleAclSchema } from '@open-mercato/core/modules/customer_accounts/data/validators'
10
+ import { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
11
+ import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
12
+ import {
13
+ runCrudMutationGuardAfterSuccess,
14
+ validateCrudMutationGuard,
15
+ } from '@open-mercato/shared/lib/crud/mutation-guard'
10
16
 
11
17
  export const metadata = {}
12
18
 
19
+ const ROLE_RESOURCE_KIND = 'customer_accounts.role'
20
+
13
21
  export async function PUT(req: Request, { params }: { params: { id: string } }) {
14
22
  const auth = await getAuthFromRequest(req)
15
- if (!auth) {
23
+ if (!auth || !auth.tenantId) {
16
24
  return NextResponse.json({ ok: false, error: 'Authentication required' }, { status: 401 })
17
25
  }
26
+ const tenantId = auth.tenantId
18
27
 
19
28
  const container = await createRequestContainer()
20
29
  const rbacService = container.resolve('rbacService') as RbacService
21
- const hasAccess = await rbacService.userHasAllFeatures(auth.sub, ['customer_accounts.roles.manage'], { tenantId: auth.tenantId, organizationId: auth.orgId })
30
+ const hasAccess = await rbacService.userHasAllFeatures(auth.sub, ['customer_accounts.roles.manage'], { tenantId, organizationId: auth.orgId })
22
31
  if (!hasAccess) {
23
32
  return NextResponse.json({ ok: false, error: 'Insufficient permissions' }, { status: 403 })
24
33
  }
@@ -39,16 +48,48 @@ export async function PUT(req: Request, { params }: { params: { id: string } })
39
48
 
40
49
  const role = await em.findOne(CustomerRole, {
41
50
  id: params.id,
42
- tenantId: auth.tenantId,
51
+ tenantId,
43
52
  deletedAt: null,
44
53
  })
45
54
  if (!role) {
46
55
  return NextResponse.json({ ok: false, error: 'Role not found' }, { status: 404 })
47
56
  }
48
57
 
58
+ // The ACL is part of the role aggregate: the role-edit screen loads and saves
59
+ // the role and its permissions together. Guard the write against the parent
60
+ // role's `updatedAt` so two admins editing the same role cannot silently
61
+ // overwrite each other's permission changes (#3194). Strictly additive — when
62
+ // the client sends no expected-version header the helper is a no-op.
63
+ try {
64
+ enforceCommandOptimisticLock({
65
+ resourceKind: ROLE_RESOURCE_KIND,
66
+ resourceId: role.id,
67
+ current: role.updatedAt ?? null,
68
+ request: req,
69
+ })
70
+ } catch (err) {
71
+ if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })
72
+ throw err
73
+ }
74
+
75
+ const guardResult = await validateCrudMutationGuard(container, {
76
+ tenantId,
77
+ organizationId: auth.orgId,
78
+ userId: auth.sub,
79
+ resourceKind: ROLE_RESOURCE_KIND,
80
+ resourceId: role.id,
81
+ operation: 'update',
82
+ requestMethod: req.method,
83
+ requestHeaders: req.headers,
84
+ mutationPayload: parsed.data,
85
+ })
86
+ if (guardResult && !guardResult.ok) {
87
+ return NextResponse.json(guardResult.body, { status: guardResult.status })
88
+ }
89
+
49
90
  const acl = await em.findOne(CustomerRoleAcl, {
50
91
  role: role.id as any,
51
- tenantId: auth.tenantId,
92
+ tenantId,
52
93
  })
53
94
 
54
95
  if (acl) {
@@ -59,7 +100,7 @@ export async function PUT(req: Request, { params }: { params: { id: string } })
59
100
  } else {
60
101
  const newAcl = em.create(CustomerRoleAcl, {
61
102
  role,
62
- tenantId: auth.tenantId,
103
+ tenantId,
63
104
  featuresJson: parsed.data.features,
64
105
  isPortalAdmin: parsed.data.isPortalAdmin ?? false,
65
106
  createdAt: new Date(),
@@ -68,13 +109,33 @@ export async function PUT(req: Request, { params }: { params: { id: string } })
68
109
  await em.flush()
69
110
  }
70
111
 
112
+ // Bump the aggregate version so a stale role-edit screen (which holds the role's
113
+ // pre-edit `updatedAt`) cannot save old permission arrays afterwards. `nativeUpdate`
114
+ // bypasses MikroORM's `onUpdate` hook, so set it explicitly.
115
+ const nextUpdatedAt = new Date()
116
+ await em.nativeUpdate(CustomerRole, { id: role.id }, { updatedAt: nextUpdatedAt })
117
+
71
118
  const customerRbacService = container.resolve('customerRbacService') as CustomerRbacService
72
119
  await customerRbacService.invalidateRoleCache(role.id)
73
120
 
74
- return NextResponse.json({ ok: true })
121
+ if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
122
+ await runCrudMutationGuardAfterSuccess(container, {
123
+ tenantId,
124
+ organizationId: auth.orgId,
125
+ userId: auth.sub,
126
+ resourceKind: ROLE_RESOURCE_KIND,
127
+ resourceId: role.id,
128
+ operation: 'update',
129
+ requestMethod: req.method,
130
+ requestHeaders: req.headers,
131
+ metadata: guardResult.metadata ?? null,
132
+ })
133
+ }
134
+
135
+ return NextResponse.json({ ok: true, updatedAt: nextUpdatedAt.toISOString() })
75
136
  }
76
137
 
77
- const successSchema = z.object({ ok: z.literal(true) })
138
+ const successSchema = z.object({ ok: z.literal(true), updatedAt: z.string().datetime() })
78
139
  const errorSchema = z.object({ ok: z.literal(false), error: z.string() })
79
140
 
80
141
  const methodDoc: OpenApiMethodDoc = {
@@ -88,6 +149,7 @@ const methodDoc: OpenApiMethodDoc = {
88
149
  { status: 401, description: 'Not authenticated', schema: errorSchema },
89
150
  { status: 403, description: 'Insufficient permissions', schema: errorSchema },
90
151
  { status: 404, description: 'Role not found', schema: errorSchema },
152
+ { status: 409, description: 'Stale ACL write (optimistic-lock conflict)', schema: errorSchema },
91
153
  ],
92
154
  }
93
155
 
@@ -273,15 +273,23 @@ export default function CustomerRoleDetailPage({ params }: { params?: { id?: str
273
273
  return
274
274
  }
275
275
  const features = Array.isArray(values.features) ? values.features : []
276
- const aclCall = await apiCall(
277
- `/api/customer_accounts/admin/roles/${encodeURIComponent(id)}/acl`,
278
- {
279
- method: 'PUT',
280
- headers: { 'content-type': 'application/json' },
281
- body: JSON.stringify({ features }),
282
- },
283
- )
276
+ // The role PUT just bumped the aggregate's `updatedAt`; the ACL write guards
277
+ // the SAME role aggregate, so send its NEW version (not the stale pre-edit one)
278
+ // to avoid a false 409 while still blocking a concurrent overwrite (#3194).
279
+ const roleResult = (roleCall.result ?? null) as { updatedAt?: string | null } | null
280
+ const aclHeaders = buildOptimisticLockHeader(roleResult?.updatedAt ?? data?.updatedAt ?? data?.updated_at ?? null)
281
+ const aclCall = await withScopedApiRequestHeaders(aclHeaders, () => (
282
+ apiCall(
283
+ `/api/customer_accounts/admin/roles/${encodeURIComponent(id)}/acl`,
284
+ {
285
+ method: 'PUT',
286
+ headers: { 'content-type': 'application/json' },
287
+ body: JSON.stringify({ features }),
288
+ },
289
+ )
290
+ ))
284
291
  if (!aclCall.ok) {
292
+ if (surfaceRecordConflict({ status: aclCall.status, body: aclCall.result }, t)) return
285
293
  flash(t('customer_accounts.admin.roleDetail.error.saveAcl', 'Failed to save permissions'), 'error')
286
294
  return
287
295
  }
@@ -14,6 +14,7 @@ import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimi
14
14
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
15
15
  import { useT } from '@open-mercato/shared/lib/i18n/context'
16
16
  import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
17
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
17
18
  import { ListEmptyState } from '@open-mercato/ui/backend/filters/ListEmptyState'
18
19
 
19
20
  type RoleRow = {
@@ -47,6 +48,21 @@ export default function CustomerRolesPage() {
47
48
  const [isLoading, setIsLoading] = React.useState(true)
48
49
  const [reloadToken, setReloadToken] = React.useState(0)
49
50
 
51
+ const { runMutation } = useGuardedMutation<{ entityType: string }>({
52
+ contextId: 'customer_accounts:roles-list',
53
+ })
54
+
55
+ const runMutationWithContext = React.useCallback(
56
+ async <T,>(operation: () => Promise<T>, mutationPayload?: Record<string, unknown>): Promise<T> => {
57
+ return runMutation({
58
+ operation,
59
+ mutationPayload,
60
+ context: { entityType: 'customer_accounts:role' },
61
+ })
62
+ },
63
+ [runMutation],
64
+ )
65
+
50
66
  const queryParams = React.useMemo(() => {
51
67
  const params = new URLSearchParams()
52
68
  params.set('page', String(page))
@@ -95,24 +111,26 @@ export default function CustomerRolesPage() {
95
111
  })
96
112
  if (!confirmed) return
97
113
  try {
98
- const call = await withScopedApiRequestHeaders(
99
- buildOptimisticLockHeader(role.updatedAt),
100
- () => apiCall(
101
- `/api/customer_accounts/admin/roles/${encodeURIComponent(role.id)}`,
102
- { method: 'DELETE' },
103
- ),
104
- )
105
- if (!call.ok) {
106
- flash(t('customer_accounts.admin.roles.error.delete', 'Failed to delete role'), 'error')
107
- return
108
- }
109
- flash(t('customer_accounts.admin.roles.flash.deleted', 'Role deleted'), 'success')
110
- setReloadToken((token) => token + 1)
114
+ await runMutationWithContext(async () => {
115
+ const call = await withScopedApiRequestHeaders(
116
+ buildOptimisticLockHeader(role.updatedAt),
117
+ () => apiCall(
118
+ `/api/customer_accounts/admin/roles/${encodeURIComponent(role.id)}`,
119
+ { method: 'DELETE' },
120
+ ),
121
+ )
122
+ if (!call.ok) {
123
+ flash(t('customer_accounts.admin.roles.error.delete', 'Failed to delete role'), 'error')
124
+ return
125
+ }
126
+ flash(t('customer_accounts.admin.roles.flash.deleted', 'Role deleted'), 'success')
127
+ setReloadToken((token) => token + 1)
128
+ }, { id: role.id })
111
129
  } catch (err) {
112
130
  const message = err instanceof Error ? err.message : t('customer_accounts.admin.roles.error.delete', 'Failed to delete role')
113
131
  flash(message, 'error')
114
132
  }
115
- }, [confirm, t])
133
+ }, [confirm, runMutationWithContext, t])
116
134
 
117
135
  const columns = React.useMemo<ColumnDef<RoleRow>[]>(() => [
118
136
  {
@@ -3,6 +3,7 @@
3
3
  import React from 'react'
4
4
  import { useQuery, useQueryClient } from '@tanstack/react-query'
5
5
  import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
6
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
6
7
  import { useT } from '@open-mercato/shared/lib/i18n/context'
7
8
  import { Button } from '@open-mercato/ui/primitives/button'
8
9
  import { Input } from '@open-mercato/ui/primitives/input'
@@ -50,6 +51,10 @@ function InviteForm({ personEntityId, onSuccess }: { personEntityId: string; onS
50
51
  const [isLoadingRoles, setIsLoadingRoles] = React.useState(true)
51
52
  const [isSubmitting, setIsSubmitting] = React.useState(false)
52
53
 
54
+ const { runMutation } = useGuardedMutation<{ entityType: string }>({
55
+ contextId: 'customer_accounts:account-status-invite',
56
+ })
57
+
53
58
  React.useEffect(() => {
54
59
  let cancelled = false
55
60
  async function loadPerson() {
@@ -124,28 +129,35 @@ function InviteForm({ personEntityId, onSuccess }: { personEntityId: string; onS
124
129
 
125
130
  setIsSubmitting(true)
126
131
  try {
127
- const call = await apiCall<{ ok: boolean; error?: string }>(
128
- '/api/customer_accounts/admin/users-invite',
129
- {
130
- method: 'POST',
131
- headers: { 'content-type': 'application/json' },
132
- body: JSON.stringify({
133
- email: trimmedEmail,
134
- roleIds: selectedRoleIds,
135
- displayName: displayName.trim() || undefined,
136
- customerEntityId: personEntityId,
137
- }),
138
- },
139
- )
132
+ await runMutation({
133
+ context: { entityType: 'customer_accounts:user' },
134
+ mutationPayload: { customerEntityId: personEntityId, roleIds: selectedRoleIds },
135
+ operation: async () => {
136
+ // optimistic-lock-exempt: creates a new portal invitation, not a concurrent record edit
137
+ const call = await apiCall<{ ok: boolean; error?: string }>(
138
+ '/api/customer_accounts/admin/users-invite',
139
+ {
140
+ method: 'POST',
141
+ headers: { 'content-type': 'application/json' },
142
+ body: JSON.stringify({
143
+ email: trimmedEmail,
144
+ roleIds: selectedRoleIds,
145
+ displayName: displayName.trim() || undefined,
146
+ customerEntityId: personEntityId,
147
+ }),
148
+ },
149
+ )
140
150
 
141
- if (!call.ok) {
142
- const errorMessage = (call.result as Record<string, unknown> | null)?.error as string | undefined
143
- flash(errorMessage || t('customer_accounts.widgets.invite.error.failed', 'Failed to send invitation'), 'error')
144
- return
145
- }
151
+ if (!call.ok) {
152
+ const errorMessage = (call.result as Record<string, unknown> | null)?.error as string | undefined
153
+ flash(errorMessage || t('customer_accounts.widgets.invite.error.failed', 'Failed to send invitation'), 'error')
154
+ return
155
+ }
146
156
 
147
- flash(t('customer_accounts.widgets.invite.success', 'Invitation sent successfully'), 'success')
148
- onSuccess()
157
+ flash(t('customer_accounts.widgets.invite.success', 'Invitation sent successfully'), 'success')
158
+ onSuccess()
159
+ },
160
+ })
149
161
  } catch {
150
162
  flash(t('customer_accounts.widgets.invite.error.failed', 'Failed to send invitation'), 'error')
151
163
  } finally {