@open-mercato/core 0.6.6-develop.6205.1.109e4b6a84 → 0.6.6-develop.6228.1.cf483c68b5

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 (205) 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/fields/dictionary.js +29 -4
  57. package/dist/modules/dictionaries/fields/dictionary.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/fields/dictionary.tsx +31 -8
  165. package/src/modules/dictionaries/i18n/de.json +1 -0
  166. package/src/modules/dictionaries/i18n/en.json +1 -0
  167. package/src/modules/dictionaries/i18n/es.json +1 -0
  168. package/src/modules/dictionaries/i18n/pl.json +1 -0
  169. package/src/modules/directory/api/organization-branding/route.ts +61 -0
  170. package/src/modules/directory/backend/directory/branding/page.tsx +16 -10
  171. package/src/modules/directory/backend/directory/organizations/page.tsx +35 -8
  172. package/src/modules/directory/backend/directory/tenants/page.tsx +37 -13
  173. package/src/modules/entities/api/definitions.batch.ts +17 -0
  174. package/src/modules/entities/api/definitions.mutation-guard.ts +80 -0
  175. package/src/modules/entities/api/definitions.restore.ts +17 -0
  176. package/src/modules/entities/api/definitions.ts +30 -0
  177. package/src/modules/entities/api/entities.ts +35 -3
  178. package/src/modules/entities/api/records.ts +20 -0
  179. package/src/modules/entities/backend/entities/user/[entityId]/records/page.tsx +33 -10
  180. package/src/modules/feature_toggles/api/overrides/route.ts +44 -1
  181. package/src/modules/inbox_ops/api/emails/[id]/route.ts +32 -0
  182. package/src/modules/inbox_ops/api/proposals/[id]/accept-all/route.ts +32 -0
  183. package/src/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.ts +33 -0
  184. package/src/modules/inbox_ops/api/proposals/[id]/categorize/route.ts +33 -0
  185. package/src/modules/messages/components/message-detail/hooks/useMessageDetailsActions.ts +80 -42
  186. package/src/modules/notifications/frontend/NotificationSettingsPageClient.tsx +21 -4
  187. package/src/modules/payment_gateways/api/cancel/route.ts +39 -0
  188. package/src/modules/payment_gateways/api/capture/route.ts +39 -0
  189. package/src/modules/payment_gateways/api/guards.ts +59 -0
  190. package/src/modules/payment_gateways/api/refund/route.ts +39 -0
  191. package/src/modules/payment_gateways/api/sessions/route.ts +40 -0
  192. package/src/modules/planner/api/availability-date-specific.ts +10 -0
  193. package/src/modules/planner/backend/planner/availability-rulesets/page.tsx +26 -5
  194. package/src/modules/planner/commands/availability-date-specific.ts +24 -0
  195. package/src/modules/planner/components/AvailabilityRulesEditor.tsx +122 -41
  196. package/src/modules/query_index/api/purge.ts +37 -3
  197. package/src/modules/query_index/api/reindex.ts +43 -3
  198. package/src/modules/query_index/components/QueryIndexesTable.tsx +66 -24
  199. package/src/modules/shipping_carriers/api/shipments/route.ts +31 -0
  200. package/src/modules/shipping_carriers/api/tracking/refresh/route.ts +53 -0
  201. package/src/modules/shipping_carriers/data/validators.ts +5 -0
  202. package/src/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.ts +29 -8
  203. package/src/modules/shipping_carriers/lib/shipping-service.ts +43 -7
  204. package/src/modules/shipping_carriers/workers/status-poller.ts +1 -1
  205. package/src/modules/translations/components/TranslationManager.tsx +65 -21
@@ -5,6 +5,7 @@ import type { UseQueryResult } from '@tanstack/react-query'
5
5
  import type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'
6
6
  import type { MessageActionsProps, MessageContentProps } from '@open-mercato/shared/modules/messages/types'
7
7
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
8
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
8
9
  import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
9
10
  import type {
10
11
  ActionResult,
@@ -21,6 +22,13 @@ type RequestAndRefreshOptions = {
21
22
 
22
23
  type ConversationActionKind = 'archiveConversation' | 'deleteConversation' | 'markAllUnread'
23
24
 
25
+ type MessageDetailMutationContext = {
26
+ resourceKind: 'message' | 'conversation'
27
+ messageId: string
28
+ action: string
29
+ retryLastMutation: () => Promise<boolean>
30
+ }
31
+
24
32
  type UseMessageDetailsActionsInput = {
25
33
  id: string
26
34
  t: TranslateFn
@@ -51,6 +59,10 @@ export function useMessageDetailsActions({
51
59
  const [deleteConfirmationOpen, setDeleteConfirmationOpen] = React.useState(false)
52
60
  const [activeConversationAction, setActiveConversationAction] = React.useState<ConversationActionKind | null>(null)
53
61
 
62
+ const { runMutation, retryLastMutation } = useGuardedMutation<MessageDetailMutationContext>({
63
+ contextId: 'messages-detail-actions',
64
+ })
65
+
54
66
  const requestAndRefresh = React.useCallback(async (
55
67
  url: string,
56
68
  method: 'PUT' | 'DELETE',
@@ -58,18 +70,24 @@ export function useMessageDetailsActions({
58
70
  ) => {
59
71
  setUpdatingState(true)
60
72
  try {
61
- const call = await apiCall<{ ok?: boolean }>(url, { method })
62
- if (!call.ok) {
63
- throw new Error(
64
- toErrorMessage(call.result)
65
- ?? t('messages.errors.stateChangeFailed', 'Failed to update message state.'),
66
- )
67
- }
68
- if (options?.skipDetailAutoMarkRead) {
69
- await refreshDetailWithoutAutoMarkRead()
70
- } else {
71
- await detailQuery.refetch()
72
- }
73
+ await runMutation({
74
+ operation: async () => {
75
+ const call = await apiCall<{ ok?: boolean }>(url, { method })
76
+ if (!call.ok) {
77
+ throw new Error(
78
+ toErrorMessage(call.result)
79
+ ?? t('messages.errors.stateChangeFailed', 'Failed to update message state.'),
80
+ )
81
+ }
82
+ if (options?.skipDetailAutoMarkRead) {
83
+ await refreshDetailWithoutAutoMarkRead()
84
+ } else {
85
+ await detailQuery.refetch()
86
+ }
87
+ },
88
+ context: { resourceKind: 'message', messageId: id, action: 'state-change', retryLastMutation },
89
+ mutationPayload: { messageId: id, action: 'state-change', url, method },
90
+ })
73
91
  } catch (err) {
74
92
  flash(
75
93
  err instanceof Error
@@ -80,17 +98,23 @@ export function useMessageDetailsActions({
80
98
  } finally {
81
99
  setUpdatingState(false)
82
100
  }
83
- }, [detailQuery, refreshDetailWithoutAutoMarkRead, t])
101
+ }, [detailQuery, id, refreshDetailWithoutAutoMarkRead, retryLastMutation, runMutation, t])
84
102
 
85
103
  const handleDelete = React.useCallback(async () => {
86
104
  setUpdatingState(true)
87
105
  try {
88
- const call = await apiCall<{ ok?: boolean }>(`/api/messages/${encodeURIComponent(id)}`, {
89
- method: 'DELETE',
106
+ await runMutation({
107
+ operation: async () => {
108
+ const call = await apiCall<{ ok?: boolean }>(`/api/messages/${encodeURIComponent(id)}`, {
109
+ method: 'DELETE',
110
+ })
111
+ if (!call.ok) {
112
+ throw new Error(toErrorMessage(call.result) ?? t('messages.errors.deleteFailed', 'Failed to delete message.'))
113
+ }
114
+ },
115
+ context: { resourceKind: 'message', messageId: id, action: 'delete', retryLastMutation },
116
+ mutationPayload: { messageId: id, action: 'delete' },
90
117
  })
91
- if (!call.ok) {
92
- throw new Error(toErrorMessage(call.result) ?? t('messages.errors.deleteFailed', 'Failed to delete message.'))
93
- }
94
118
  flash(t('messages.flash.deleted', 'Message deleted.'), 'success')
95
119
  onDeleted()
96
120
  } catch (err) {
@@ -103,7 +127,7 @@ export function useMessageDetailsActions({
103
127
  } finally {
104
128
  setUpdatingState(false)
105
129
  }
106
- }, [id, onDeleted, t])
130
+ }, [id, onDeleted, retryLastMutation, runMutation, t])
107
131
 
108
132
  const runConversationAction = React.useCallback(async (
109
133
  action: ConversationActionKind,
@@ -117,13 +141,19 @@ export function useMessageDetailsActions({
117
141
  ) => {
118
142
  setActiveConversationAction(action)
119
143
  try {
120
- const call = await apiCall<{ ok?: boolean; affectedCount?: number }>(options.url, { method: options.method })
121
- if (!call.ok) {
122
- throw new Error(
123
- toErrorMessage(call.result)
124
- ?? t('messages.errors.conversationActionFailed', 'Failed to update conversation.'),
125
- )
126
- }
144
+ await runMutation({
145
+ operation: async () => {
146
+ const call = await apiCall<{ ok?: boolean; affectedCount?: number }>(options.url, { method: options.method })
147
+ if (!call.ok) {
148
+ throw new Error(
149
+ toErrorMessage(call.result)
150
+ ?? t('messages.errors.conversationActionFailed', 'Failed to update conversation.'),
151
+ )
152
+ }
153
+ },
154
+ context: { resourceKind: 'conversation', messageId: id, action, retryLastMutation },
155
+ mutationPayload: { messageId: id, action, url: options.url, method: options.method },
156
+ })
127
157
 
128
158
  flash(options.successMessage, 'success')
129
159
 
@@ -147,7 +177,7 @@ export function useMessageDetailsActions({
147
177
  } finally {
148
178
  setActiveConversationAction(null)
149
179
  }
150
- }, [detailQuery, refreshDetailWithoutAutoMarkRead, t])
180
+ }, [detailQuery, id, refreshDetailWithoutAutoMarkRead, retryLastMutation, runMutation, t])
151
181
 
152
182
  const archiveConversation = React.useCallback(async (messageId?: string) => {
153
183
  const targetMessageId = messageId ?? id
@@ -194,21 +224,29 @@ export function useMessageDetailsActions({
194
224
  const executeAction = React.useCallback(async (action: MessageAction, payload?: Record<string, unknown>) => {
195
225
  setExecutingActionId(action.id)
196
226
  try {
197
- const call = await apiCall<ActionResult>(
198
- `/api/messages/${encodeURIComponent(id)}/actions/${encodeURIComponent(action.id)}`,
199
- {
200
- method: 'POST',
201
- headers: { 'Content-Type': 'application/json' },
202
- body: JSON.stringify(payload ?? {}),
227
+ const call = await runMutation({
228
+ operation: async () => {
229
+ const result = await apiCall<ActionResult>(
230
+ `/api/messages/${encodeURIComponent(id)}/actions/${encodeURIComponent(action.id)}`,
231
+ {
232
+ method: 'POST',
233
+ headers: { 'Content-Type': 'application/json' },
234
+ body: JSON.stringify(payload ?? {}),
235
+ },
236
+ )
237
+
238
+ if (!result.ok) {
239
+ throw new Error(
240
+ toErrorMessage(result.result)
241
+ ?? t('messages.errors.actionFailed', 'Failed to execute action.'),
242
+ )
243
+ }
244
+
245
+ return result
203
246
  },
204
- )
205
-
206
- if (!call.ok) {
207
- throw new Error(
208
- toErrorMessage(call.result)
209
- ?? t('messages.errors.actionFailed', 'Failed to execute action.'),
210
- )
211
- }
247
+ context: { resourceKind: 'message', messageId: id, action: 'execute-action', retryLastMutation },
248
+ mutationPayload: { messageId: id, action: 'execute-action', actionId: action.id },
249
+ })
212
250
 
213
251
  flash(t('messages.flash.actionSuccess', 'Action completed.'), 'success')
214
252
 
@@ -233,7 +271,7 @@ export function useMessageDetailsActions({
233
271
  } finally {
234
272
  setExecutingActionId(null)
235
273
  }
236
- }, [detailQuery, id, t])
274
+ }, [detailQuery, id, retryLastMutation, runMutation, t])
237
275
 
238
276
  const handleExecuteAction = React.useCallback(async (action: MessageAction, payload?: Record<string, unknown>) => {
239
277
  if (executingActionId || detail?.actionTaken) return
@@ -3,6 +3,7 @@
3
3
  import * as React from 'react'
4
4
  import { useT } from '@open-mercato/shared/lib/i18n/context'
5
5
  import { apiCall, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
6
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
6
7
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
7
8
  import { Button } from '@open-mercato/ui/primitives/button'
8
9
  import { Input } from '@open-mercato/ui/primitives/input'
@@ -35,6 +36,8 @@ const emptySettings: NotificationDeliveryConfig = {
35
36
  },
36
37
  }
37
38
 
39
+ const SETTINGS_CONTEXT_ID = 'notifications-settings'
40
+
38
41
  export function NotificationSettingsPageClient() {
39
42
  const t = useT()
40
43
  const [settings, setSettings] = React.useState<NotificationDeliveryConfig | null>(null)
@@ -42,6 +45,15 @@ export function NotificationSettingsPageClient() {
42
45
  const [saving, setSaving] = React.useState(false)
43
46
  const [error, setError] = React.useState<string | null>(null)
44
47
 
48
+ const { runMutation, retryLastMutation } = useGuardedMutation<{
49
+ formId: string
50
+ resourceKind: string
51
+ retryLastMutation: () => Promise<boolean>
52
+ }>({
53
+ contextId: SETTINGS_CONTEXT_ID,
54
+ blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
55
+ })
56
+
45
57
  const fetchSettings = React.useCallback(async () => {
46
58
  setLoading(true)
47
59
  setError(null)
@@ -96,10 +108,15 @@ export function NotificationSettingsPageClient() {
96
108
  if (!settings) return
97
109
  setSaving(true)
98
110
  try {
99
- const response = await apiCall<SettingsResponse>('/api/notifications/settings', {
100
- method: 'POST',
101
- headers: { 'Content-Type': 'application/json' },
102
- body: JSON.stringify(settings),
111
+ const response = await runMutation({
112
+ operation: () =>
113
+ apiCall<SettingsResponse>('/api/notifications/settings', {
114
+ method: 'POST',
115
+ headers: { 'Content-Type': 'application/json' },
116
+ body: JSON.stringify(settings),
117
+ }),
118
+ context: { formId: SETTINGS_CONTEXT_ID, resourceKind: 'notifications.settings', retryLastMutation },
119
+ mutationPayload: { settings },
103
120
  })
104
121
  if (!response.ok) {
105
122
  const message = response.result?.error || t('notifications.settings.saveError', 'Failed to save notification settings')
@@ -5,6 +5,13 @@ import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
5
5
  import { cancelSchema } from '../../data/validators'
6
6
  import type { PaymentGatewayService } from '../../lib/gateway-service'
7
7
  import { paymentGatewaysTag } from '../openapi'
8
+ import {
9
+ resolveUserFeatures,
10
+ runPaymentGatewayMutationGuardAfterSuccess,
11
+ runPaymentGatewayMutationGuards,
12
+ } from '../guards'
13
+
14
+ const gatewayTransactionResourceKind = 'payment_gateways.gateway_transaction'
8
15
 
9
16
  export const metadata = {
10
17
  path: '/payment_gateways/cancel',
@@ -24,6 +31,28 @@ export async function POST(req: Request) {
24
31
  }
25
32
 
26
33
  const container = await createRequestContainer()
34
+ const guardResult = await runPaymentGatewayMutationGuards(
35
+ container,
36
+ {
37
+ tenantId: auth.tenantId,
38
+ organizationId: auth.orgId,
39
+ userId: auth.sub ?? '',
40
+ resourceKind: gatewayTransactionResourceKind,
41
+ resourceId: parsed.data.transactionId,
42
+ operation: 'update',
43
+ requestMethod: req.method,
44
+ requestHeaders: req.headers,
45
+ mutationPayload: parsed.data as Record<string, unknown>,
46
+ },
47
+ resolveUserFeatures(auth),
48
+ )
49
+ if (!guardResult.ok) {
50
+ return NextResponse.json(
51
+ guardResult.errorBody ?? { error: 'Operation blocked by guard' },
52
+ { status: guardResult.errorStatus ?? 422 },
53
+ )
54
+ }
55
+
27
56
  const service = container.resolve('paymentGatewayService') as PaymentGatewayService
28
57
 
29
58
  try {
@@ -32,6 +61,16 @@ export async function POST(req: Request) {
32
61
  parsed.data.reason,
33
62
  { organizationId: auth.orgId as string, tenantId: auth.tenantId },
34
63
  )
64
+ await runPaymentGatewayMutationGuardAfterSuccess(guardResult.afterSuccessCallbacks, {
65
+ tenantId: auth.tenantId,
66
+ organizationId: auth.orgId,
67
+ userId: auth.sub ?? '',
68
+ resourceKind: gatewayTransactionResourceKind,
69
+ resourceId: parsed.data.transactionId,
70
+ operation: 'update',
71
+ requestMethod: req.method,
72
+ requestHeaders: req.headers,
73
+ })
35
74
  return NextResponse.json(result)
36
75
  } catch (err: unknown) {
37
76
  const message = err instanceof Error ? err.message : 'Cancel failed'
@@ -5,6 +5,13 @@ import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
5
5
  import { captureSchema } from '../../data/validators'
6
6
  import type { PaymentGatewayService } from '../../lib/gateway-service'
7
7
  import { paymentGatewaysTag } from '../openapi'
8
+ import {
9
+ resolveUserFeatures,
10
+ runPaymentGatewayMutationGuardAfterSuccess,
11
+ runPaymentGatewayMutationGuards,
12
+ } from '../guards'
13
+
14
+ const gatewayTransactionResourceKind = 'payment_gateways.gateway_transaction'
8
15
 
9
16
  export const metadata = {
10
17
  path: '/payment_gateways/capture',
@@ -24,6 +31,28 @@ export async function POST(req: Request) {
24
31
  }
25
32
 
26
33
  const container = await createRequestContainer()
34
+ const guardResult = await runPaymentGatewayMutationGuards(
35
+ container,
36
+ {
37
+ tenantId: auth.tenantId,
38
+ organizationId: auth.orgId,
39
+ userId: auth.sub ?? '',
40
+ resourceKind: gatewayTransactionResourceKind,
41
+ resourceId: parsed.data.transactionId,
42
+ operation: 'update',
43
+ requestMethod: req.method,
44
+ requestHeaders: req.headers,
45
+ mutationPayload: parsed.data as Record<string, unknown>,
46
+ },
47
+ resolveUserFeatures(auth),
48
+ )
49
+ if (!guardResult.ok) {
50
+ return NextResponse.json(
51
+ guardResult.errorBody ?? { error: 'Operation blocked by guard' },
52
+ { status: guardResult.errorStatus ?? 422 },
53
+ )
54
+ }
55
+
27
56
  const service = container.resolve('paymentGatewayService') as PaymentGatewayService
28
57
 
29
58
  try {
@@ -32,6 +61,16 @@ export async function POST(req: Request) {
32
61
  parsed.data.amount,
33
62
  { organizationId: auth.orgId as string, tenantId: auth.tenantId },
34
63
  )
64
+ await runPaymentGatewayMutationGuardAfterSuccess(guardResult.afterSuccessCallbacks, {
65
+ tenantId: auth.tenantId,
66
+ organizationId: auth.orgId,
67
+ userId: auth.sub ?? '',
68
+ resourceKind: gatewayTransactionResourceKind,
69
+ resourceId: parsed.data.transactionId,
70
+ operation: 'update',
71
+ requestMethod: req.method,
72
+ requestHeaders: req.headers,
73
+ })
35
74
  return NextResponse.json(result)
36
75
  } catch (err: unknown) {
37
76
  const message = err instanceof Error ? err.message : 'Capture failed'
@@ -0,0 +1,59 @@
1
+ import type { AwilixContainer } from 'awilix'
2
+ import {
3
+ bridgeLegacyGuard,
4
+ runMutationGuards,
5
+ type MutationGuard,
6
+ type MutationGuardInput,
7
+ } from '@open-mercato/shared/lib/crud/mutation-guard-registry'
8
+
9
+ type GuardAfterCallback = {
10
+ guard: MutationGuard
11
+ metadata: Record<string, unknown> | null
12
+ }
13
+
14
+ export function resolveUserFeatures(auth: unknown): string[] {
15
+ const features = (auth as { features?: unknown })?.features
16
+ if (!Array.isArray(features)) return []
17
+ return features.filter((value): value is string => typeof value === 'string')
18
+ }
19
+
20
+ export async function runPaymentGatewayMutationGuards(
21
+ container: AwilixContainer,
22
+ input: MutationGuardInput,
23
+ userFeatures: string[],
24
+ ): Promise<{
25
+ ok: boolean
26
+ errorBody?: Record<string, unknown>
27
+ errorStatus?: number
28
+ modifiedPayload?: Record<string, unknown>
29
+ afterSuccessCallbacks: GuardAfterCallback[]
30
+ }> {
31
+ const legacyGuard = bridgeLegacyGuard(container)
32
+ if (!legacyGuard) {
33
+ return { ok: true, afterSuccessCallbacks: [] }
34
+ }
35
+
36
+ return runMutationGuards([legacyGuard], input, { userFeatures })
37
+ }
38
+
39
+ export async function runPaymentGatewayMutationGuardAfterSuccess(
40
+ callbacks: GuardAfterCallback[],
41
+ input: {
42
+ tenantId: string
43
+ organizationId: string | null
44
+ userId: string
45
+ resourceKind: string
46
+ resourceId: string
47
+ operation: 'create' | 'update' | 'delete'
48
+ requestMethod: string
49
+ requestHeaders: Headers
50
+ },
51
+ ): Promise<void> {
52
+ for (const callback of callbacks) {
53
+ if (!callback.guard.afterSuccess) continue
54
+ await callback.guard.afterSuccess({
55
+ ...input,
56
+ metadata: callback.metadata ?? null,
57
+ })
58
+ }
59
+ }
@@ -5,6 +5,13 @@ import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
5
5
  import { refundSchema } from '../../data/validators'
6
6
  import type { PaymentGatewayService } from '../../lib/gateway-service'
7
7
  import { paymentGatewaysTag } from '../openapi'
8
+ import {
9
+ resolveUserFeatures,
10
+ runPaymentGatewayMutationGuardAfterSuccess,
11
+ runPaymentGatewayMutationGuards,
12
+ } from '../guards'
13
+
14
+ const gatewayTransactionResourceKind = 'payment_gateways.gateway_transaction'
8
15
 
9
16
  export const metadata = {
10
17
  path: '/payment_gateways/refund',
@@ -24,6 +31,28 @@ export async function POST(req: Request) {
24
31
  }
25
32
 
26
33
  const container = await createRequestContainer()
34
+ const guardResult = await runPaymentGatewayMutationGuards(
35
+ container,
36
+ {
37
+ tenantId: auth.tenantId,
38
+ organizationId: auth.orgId,
39
+ userId: auth.sub ?? '',
40
+ resourceKind: gatewayTransactionResourceKind,
41
+ resourceId: parsed.data.transactionId,
42
+ operation: 'update',
43
+ requestMethod: req.method,
44
+ requestHeaders: req.headers,
45
+ mutationPayload: parsed.data as Record<string, unknown>,
46
+ },
47
+ resolveUserFeatures(auth),
48
+ )
49
+ if (!guardResult.ok) {
50
+ return NextResponse.json(
51
+ guardResult.errorBody ?? { error: 'Operation blocked by guard' },
52
+ { status: guardResult.errorStatus ?? 422 },
53
+ )
54
+ }
55
+
27
56
  const service = container.resolve('paymentGatewayService') as PaymentGatewayService
28
57
 
29
58
  try {
@@ -33,6 +62,16 @@ export async function POST(req: Request) {
33
62
  parsed.data.reason,
34
63
  { organizationId: auth.orgId as string, tenantId: auth.tenantId },
35
64
  )
65
+ await runPaymentGatewayMutationGuardAfterSuccess(guardResult.afterSuccessCallbacks, {
66
+ tenantId: auth.tenantId,
67
+ organizationId: auth.orgId,
68
+ userId: auth.sub ?? '',
69
+ resourceKind: gatewayTransactionResourceKind,
70
+ resourceId: parsed.data.transactionId,
71
+ operation: 'update',
72
+ requestMethod: req.method,
73
+ requestHeaders: req.headers,
74
+ })
36
75
  return NextResponse.json(result)
37
76
  } catch (err: unknown) {
38
77
  const message = err instanceof Error ? err.message : 'Refund failed'
@@ -5,6 +5,13 @@ import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
5
5
  import { createSessionSchema } from '../../data/validators'
6
6
  import type { PaymentGatewayService } from '../../lib/gateway-service'
7
7
  import { paymentGatewaysTag } from '../openapi'
8
+ import {
9
+ resolveUserFeatures,
10
+ runPaymentGatewayMutationGuardAfterSuccess,
11
+ runPaymentGatewayMutationGuards,
12
+ } from '../guards'
13
+
14
+ const gatewayTransactionResourceKind = 'payment_gateways.gateway_transaction'
8
15
 
9
16
  export const metadata = {
10
17
  path: '/payment_gateways/sessions',
@@ -24,6 +31,28 @@ export async function POST(req: Request) {
24
31
  }
25
32
 
26
33
  const container = await createRequestContainer()
34
+ const guardResult = await runPaymentGatewayMutationGuards(
35
+ container,
36
+ {
37
+ tenantId: auth.tenantId,
38
+ organizationId: auth.orgId,
39
+ userId: auth.sub ?? '',
40
+ resourceKind: gatewayTransactionResourceKind,
41
+ resourceId: null,
42
+ operation: 'create',
43
+ requestMethod: req.method,
44
+ requestHeaders: req.headers,
45
+ mutationPayload: parsed.data as Record<string, unknown>,
46
+ },
47
+ resolveUserFeatures(auth),
48
+ )
49
+ if (!guardResult.ok) {
50
+ return NextResponse.json(
51
+ guardResult.errorBody ?? { error: 'Operation blocked by guard' },
52
+ { status: guardResult.errorStatus ?? 422 },
53
+ )
54
+ }
55
+
27
56
  const service = container.resolve('paymentGatewayService') as PaymentGatewayService
28
57
 
29
58
  try {
@@ -46,6 +75,17 @@ export async function POST(req: Request) {
46
75
  const redirectUrl = session.redirectUrl
47
76
  ?? (session.clientSession?.type === 'redirect' ? session.clientSession.redirectUrl : null)
48
77
 
78
+ await runPaymentGatewayMutationGuardAfterSuccess(guardResult.afterSuccessCallbacks, {
79
+ tenantId: auth.tenantId,
80
+ organizationId: auth.orgId,
81
+ userId: auth.sub ?? '',
82
+ resourceKind: gatewayTransactionResourceKind,
83
+ resourceId: transaction.id,
84
+ operation: 'create',
85
+ requestMethod: req.method,
86
+ requestHeaders: req.headers,
87
+ })
88
+
49
89
  return NextResponse.json({
50
90
  transactionId: transaction.id,
51
91
  sessionId: session.sessionId,
@@ -161,6 +161,16 @@ export const openApi = {
161
161
  { status: 400, description: 'Invalid payload', schema: z.object({ error: z.string() }) },
162
162
  { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },
163
163
  { status: 403, description: 'Forbidden', schema: z.object({ error: z.string() }) },
164
+ {
165
+ status: 409,
166
+ description: 'Conflict — the subject/date availability was modified by another edit',
167
+ schema: z.object({
168
+ error: z.string(),
169
+ code: z.string(),
170
+ currentUpdatedAt: z.string(),
171
+ expectedUpdatedAt: z.string(),
172
+ }),
173
+ },
164
174
  ],
165
175
  },
166
176
  },
@@ -11,6 +11,7 @@ import { RowActions } from '@open-mercato/ui/backend/RowActions'
11
11
  import { Button } from '@open-mercato/ui/primitives/button'
12
12
  import { readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
13
13
  import { deleteCrud } from '@open-mercato/ui/backend/utils/crud'
14
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
14
15
  import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
15
16
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
16
17
  import { normalizeCrudServerError } from '@open-mercato/ui/backend/utils/serverErrors'
@@ -41,6 +42,23 @@ export default function PlannerAvailabilityRuleSetsPage() {
41
42
  const { confirm, ConfirmDialogElement } = useConfirmDialog()
42
43
  const router = useRouter()
43
44
  const scopeVersion = useOrganizationScopeVersion()
45
+ const mutationContextId = 'planner-availability-rule-sets'
46
+ const { runMutation, retryLastMutation } = useGuardedMutation<{
47
+ formId: string
48
+ resourceKind: string
49
+ retryLastMutation: () => Promise<boolean>
50
+ }>({
51
+ contextId: mutationContextId,
52
+ blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
53
+ })
54
+ const mutationContext = React.useMemo(
55
+ () => ({
56
+ formId: mutationContextId,
57
+ resourceKind: 'planner.availability_rule_set',
58
+ retryLastMutation,
59
+ }),
60
+ [mutationContextId, retryLastMutation],
61
+ )
44
62
  const [rows, setRows] = React.useState<RuleSetRow[]>([])
45
63
  const [page, setPage] = React.useState(1)
46
64
  const [total, setTotal] = React.useState(0)
@@ -127,10 +145,13 @@ export default function PlannerAvailabilityRuleSetsPage() {
127
145
  })
128
146
  if (!confirmed) return
129
147
  try {
130
- const headers = buildOptimisticLockHeader(entry.updatedAt)
131
- await withScopedApiRequestHeaders(headers, () => (
132
- deleteCrud('planner/availability-rule-sets', entry.id, { errorMessage: labels.errors.delete })
133
- ))
148
+ await runMutation({
149
+ operation: () => withScopedApiRequestHeaders(buildOptimisticLockHeader(entry.updatedAt), () => (
150
+ deleteCrud('planner/availability-rule-sets', entry.id, { errorMessage: labels.errors.delete })
151
+ )),
152
+ context: mutationContext,
153
+ mutationPayload: { action: 'delete', id: entry.id },
154
+ })
134
155
  flash(labels.messages.deleted, 'success')
135
156
  handleRefresh()
136
157
  } catch (error) {
@@ -138,7 +159,7 @@ export default function PlannerAvailabilityRuleSetsPage() {
138
159
  const normalized = normalizeCrudServerError(error)
139
160
  flash(normalized.message ?? labels.errors.delete, 'error')
140
161
  }
141
- }, [confirm, handleRefresh, labels.actions.deleteConfirm, labels.errors.delete, labels.messages.deleted])
162
+ }, [confirm, handleRefresh, labels.actions.deleteConfirm, labels.errors.delete, labels.messages.deleted, mutationContext, runMutation])
142
163
 
143
164
  const columns = React.useMemo<ColumnDef<RuleSetRow>[]>(() => [
144
165
  {
@@ -9,10 +9,28 @@ import {
9
9
  type PlannerAvailabilityDateSpecificReplaceInput,
10
10
  } from '../data/validators'
11
11
  import { ensureOrganizationScope, ensureTenantScope, extractUndoPayload } from './shared'
12
+ import { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
12
13
  import type { PlannerAvailabilityKind, PlannerAvailabilitySubjectType } from '../data/entities'
13
14
 
14
15
  const AVAILABILITY_RULE_RESOURCE_KIND = 'planner.availability.rule'
15
16
 
17
+ /**
18
+ * The date-specific aggregate (the one-off rules for a subject/date) has no
19
+ * single parent row, so its optimistic-lock version is the latest `updated_at`
20
+ * of the rules being replaced. The editor sends that token via the extension
21
+ * header; an empty set means "nothing to clobber" → no current version → the
22
+ * guard is a no-op (strictly additive, fail-open).
23
+ */
24
+ function resolveAggregateLockVersion(rules: PlannerAvailabilityRule[]): Date | null {
25
+ let latest: Date | null = null
26
+ for (const rule of rules) {
27
+ const value = rule.updatedAt
28
+ if (!value || Number.isNaN(value.getTime())) continue
29
+ if (!latest || value.getTime() > latest.getTime()) latest = value
30
+ }
31
+ return latest
32
+ }
33
+
16
34
  type AvailabilityRuleSnapshot = {
17
35
  id: string
18
36
  tenantId: string
@@ -207,6 +225,12 @@ const replaceDateSpecificAvailabilityCommand: CommandHandler<PlannerAvailability
207
225
  if (window.repeat !== 'once') return false
208
226
  return dates.has(formatDateKey(window.startAt))
209
227
  })
228
+ enforceCommandOptimisticLock({
229
+ resourceKind: AVAILABILITY_RULE_RESOURCE_KIND,
230
+ resourceId: `${parsed.subjectType}:${parsed.subjectId}`,
231
+ current: resolveAggregateLockVersion(toDelete),
232
+ request: ctx.request ?? null,
233
+ })
210
234
  toDelete.forEach((rule) => {
211
235
  rule.deletedAt = now
212
236
  rule.updatedAt = now