@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
@@ -10,8 +10,12 @@ import { isSystemEntitySelectable } from '@open-mercato/shared/lib/entities/syst
10
10
  import { SYSTEM_ENTITY_RECORDS_BLOCKED_CODE, isOrmBackedSystemEntityId } from '@open-mercato/shared/lib/data/engine'
11
11
  import { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
12
12
  import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
13
+ import {
14
+ beginEntitiesMutationGuard,
15
+ ENTITY_DEFINITION_RESOURCE_KIND,
16
+ } from './definitions.mutation-guard'
13
17
 
14
- const CUSTOM_ENTITY_DEFINITION_RESOURCE_KIND = 'entities.entity'
18
+ const CUSTOM_ENTITY_DEFINITION_RESOURCE_KIND = ENTITY_DEFINITION_RESOURCE_KIND
15
19
 
16
20
  export const metadata = {
17
21
  GET: { requireAuth: true },
@@ -110,7 +114,8 @@ export async function POST(req: Request) {
110
114
  }
111
115
  const input = parsed.data
112
116
 
113
- const { resolve } = await createRequestContainer()
117
+ const container = await createRequestContainer()
118
+ const { resolve } = container
114
119
  const em = resolve('em') as any
115
120
 
116
121
  // A registration for a module-declared, table-backed system entity would flip
@@ -140,6 +145,18 @@ export async function POST(req: Request) {
140
145
  throw lockError
141
146
  }
142
147
  }
148
+
149
+ const guard = await beginEntitiesMutationGuard({
150
+ container,
151
+ auth,
152
+ req,
153
+ resourceKind: ENTITY_DEFINITION_RESOURCE_KIND,
154
+ resourceId: ent ? ent.id : input.entityId,
155
+ operation: ent ? 'update' : 'create',
156
+ mutationPayload: input as unknown as Record<string, unknown>,
157
+ })
158
+ if (guard.blockedResponse) return guard.blockedResponse
159
+
143
160
  if (!ent) ent = em.create(CustomEntity, { ...where, createdAt: new Date() })
144
161
  ent.label = input.label
145
162
  ent.description = input.description ?? null
@@ -150,6 +167,7 @@ export async function POST(req: Request) {
150
167
  ent.updatedAt = new Date()
151
168
  em.persist(ent)
152
169
  await em.flush()
170
+ await guard.runAfterSuccess()
153
171
  // Invalidate sidebar/nav cache for tenant scope (also when tenantId is null)
154
172
  try {
155
173
  const cache = (await createRequestContainer()).resolve('cache') as any
@@ -168,17 +186,31 @@ export async function DELETE(req: Request) {
168
186
  const entityId = body?.entityId
169
187
  if (!entityId) return NextResponse.json({ error: 'entityId is required' }, { status: 400 })
170
188
 
171
- const { resolve } = await createRequestContainer()
189
+ const container = await createRequestContainer()
190
+ const { resolve } = container
172
191
  const em = resolve('em') as any
173
192
 
174
193
  const where: any = { entityId, organizationId: auth.orgId ?? null, tenantId: auth.tenantId ?? null }
175
194
  const ent = await em.findOne(CustomEntity, where)
176
195
  if (!ent) return NextResponse.json({ error: 'Not found' }, { status: 404 })
196
+
197
+ const guard = await beginEntitiesMutationGuard({
198
+ container,
199
+ auth,
200
+ req,
201
+ resourceKind: ENTITY_DEFINITION_RESOURCE_KIND,
202
+ resourceId: ent.id,
203
+ operation: 'delete',
204
+ mutationPayload: { entityId },
205
+ })
206
+ if (guard.blockedResponse) return guard.blockedResponse
207
+
177
208
  ent.isActive = false
178
209
  ent.updatedAt = new Date()
179
210
  ent.deletedAt = ent.deletedAt ?? new Date()
180
211
  em.persist(ent)
181
212
  await em.flush()
213
+ await guard.runAfterSuccess()
182
214
  // Invalidate sidebar/nav cache for tenant scope (also when tenantId is null)
183
215
  try {
184
216
  const cache = (await createRequestContainer()).resolve('cache') as any
@@ -539,6 +539,26 @@ export async function DELETE(req: Request) {
539
539
  if (entityKind === 'system') return systemEntityRecordsRejection(entityId)
540
540
  const isCustomEntity = entityKind === 'custom'
541
541
  await assertEntityAclForRequest({ auth, entityId, action: 'manage', isCustomEntity, rbac })
542
+
543
+ try {
544
+ const currentUpdatedAt = await readCustomEntityRecordUpdatedAt(em, {
545
+ entityType: entityId,
546
+ entityId: recordId,
547
+ organizationId: targetOrgId,
548
+ })
549
+ enforceCommandOptimisticLock({
550
+ resourceKind: CUSTOM_ENTITY_RECORD_RESOURCE_KIND,
551
+ resourceId: recordId,
552
+ current: currentUpdatedAt,
553
+ request: req,
554
+ })
555
+ } catch (lockError) {
556
+ if (isCrudHttpError(lockError)) {
557
+ return NextResponse.json(lockError.body, { status: lockError.status })
558
+ }
559
+ throw lockError
560
+ }
561
+
542
562
  await de.deleteCustomEntityRecord({ entityId, recordId, organizationId: targetOrgId, tenantId: auth.tenantId!, soft: true })
543
563
  return NextResponse.json({ ok: true })
544
564
  } catch (e) {
@@ -17,6 +17,7 @@ import { flash } from '@open-mercato/ui/backend/FlashMessages'
17
17
  import { raiseCrudError } from '@open-mercato/ui/backend/utils/serverErrors'
18
18
  import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
19
19
  import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
20
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
20
21
  import { useT } from '@open-mercato/shared/lib/i18n/context'
21
22
  import { ErrorMessage, LoadingMessage } from '@open-mercato/ui/backend/detail'
22
23
  import { useRecordsEntityGuard } from '@open-mercato/core/modules/entities/components/useRecordsEntityGuard'
@@ -67,6 +68,7 @@ export default function RecordsPage({ params }: { params: { entityId?: string }
67
68
  }
68
69
 
69
70
  function RecordsPageInner({ params }: { params: { entityId?: string } }) {
71
+ const t = useT()
70
72
  const entityId = decodeURIComponent(params?.entityId || '')
71
73
  const [sorting, setSorting] = React.useState<SortingState>([{ id: 'id', desc: false }])
72
74
  const [page, setPage] = React.useState(1)
@@ -80,6 +82,16 @@ function RecordsPageInner({ params }: { params: { entityId?: string } }) {
80
82
  const [loading, setLoading] = React.useState(false)
81
83
  const scopeVersion = useOrganizationScopeVersion()
82
84
  const { confirm, ConfirmDialogElement } = useConfirmDialog()
85
+ const deleteMutationContextId = `entities.user.records.${entityId}:single-delete`
86
+ const { runMutation: runDeleteMutation, retryLastMutation: retryDeleteMutation } = useGuardedMutation<{
87
+ formId: string
88
+ resourceKind: string
89
+ resourceId: string
90
+ retryLastMutation: () => Promise<boolean>
91
+ }>({
92
+ contextId: deleteMutationContextId,
93
+ blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
94
+ })
83
95
  const { data: cfDefs = [] } = useCustomFieldDefs(entityId, {
84
96
  enabled: Boolean(entityId),
85
97
  keyExtras: [scopeVersion],
@@ -367,22 +379,33 @@ export RECORD_ID="<record uuid>"`}</code></pre>
367
379
  items={[
368
380
  { id: 'edit', label: 'Edit', href: `/backend/entities/user/${encodeURIComponent(entityId)}/records/${encodeURIComponent(String((row as any).id))}` },
369
381
  { id: 'delete', label: 'Delete', destructive: true, onSelect: async () => {
382
+ const recordId = String((row as any).id)
370
383
  try {
371
384
  const confirmed = await confirm({
372
385
  title: 'Delete this record?',
373
386
  variant: 'destructive',
374
387
  })
375
388
  if (!confirmed) return
376
- const deleteCall = await withScopedApiRequestHeaders(
377
- buildOptimisticLockHeader((row as any).updatedAt),
378
- () => apiCall(
379
- `/api/entities/records?entityId=${encodeURIComponent(entityId)}&recordId=${encodeURIComponent(String((row as any).id))}`,
380
- { method: 'DELETE' },
381
- ),
382
- )
383
- if (!deleteCall.ok) {
384
- await raiseCrudError(deleteCall.response, 'Failed to delete record')
385
- }
389
+ await runDeleteMutation({
390
+ operation: async () => {
391
+ const deleteCall = await withScopedApiRequestHeaders(
392
+ buildOptimisticLockHeader((row as any).updatedAt),
393
+ () => apiCall(
394
+ `/api/entities/records?entityId=${encodeURIComponent(entityId)}&recordId=${encodeURIComponent(recordId)}`,
395
+ { method: 'DELETE' },
396
+ ),
397
+ )
398
+ if (!deleteCall.ok) {
399
+ await raiseCrudError(deleteCall.response, 'Failed to delete record')
400
+ }
401
+ },
402
+ context: {
403
+ formId: deleteMutationContextId,
404
+ resourceKind: 'entities.record',
405
+ resourceId: recordId,
406
+ retryLastMutation: retryDeleteMutation,
407
+ },
408
+ })
386
409
  const j = await readApiResultOrThrow<RecordsResponse>(
387
410
  `/api/entities/records?entityId=${encodeURIComponent(entityId)}&page=${page}&pageSize=${pageSize}`,
388
411
  undefined,
@@ -6,6 +6,10 @@ import { serializeOperationMetadata } from '@open-mercato/shared/lib/commands/op
6
6
  import { getOverrides } from '../../lib/queries'
7
7
  import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
8
8
  import { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
9
+ import {
10
+ runCrudMutationGuardAfterSuccess,
11
+ validateCrudMutationGuard,
12
+ } from '@open-mercato/shared/lib/crud/mutation-guard'
9
13
  import { logCrudAccess } from '@open-mercato/shared/lib/crud/factory'
10
14
  import { FeatureToggleOverride } from '../../data/entities'
11
15
  import { buildContext } from '../../lib/utils'
@@ -75,7 +79,7 @@ export async function PUT(req: Request) {
75
79
  return NextResponse.json({ error: 'Invalid payload', details: parsed.error.flatten() }, { status: 400 })
76
80
  }
77
81
 
78
- const { scope } = await resolveFeatureCheckContext({
82
+ const { scope, organizationId } = await resolveFeatureCheckContext({
79
83
  container: ctx.container,
80
84
  auth: ctx.auth,
81
85
  request: req
@@ -104,6 +108,31 @@ export async function PUT(req: Request) {
104
108
  })
105
109
  }
106
110
 
111
+ // Run the CRUD mutation guard lifecycle so this custom write route honors
112
+ // module-level guard injections (and after-success hooks) like every other
113
+ // mutating endpoint. The override either updates an existing row or creates a
114
+ // new one, so the resource id falls back to the toggle id when no row exists.
115
+ const guardUserId = ctx.auth?.userId ?? ctx.auth?.sub ?? null
116
+ if (!guardUserId) {
117
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
118
+ }
119
+ const guardResourceKind = 'feature_toggles.feature_toggle_override'
120
+ const guardResourceId = existingOverride?.id ?? parsed.data.toggleId
121
+ const guardResult = await validateCrudMutationGuard(ctx.container, {
122
+ tenantId: scope.tenantId,
123
+ organizationId: organizationId ?? null,
124
+ userId: guardUserId,
125
+ resourceKind: guardResourceKind,
126
+ resourceId: guardResourceId,
127
+ operation: 'update',
128
+ requestMethod: req.method,
129
+ requestHeaders: req.headers,
130
+ mutationPayload: parsed.data,
131
+ })
132
+ if (guardResult && !guardResult.ok) {
133
+ return NextResponse.json(guardResult.body, { status: guardResult.status })
134
+ }
135
+
107
136
  const commandBus = ctx.container.resolve('commandBus') as CommandBus
108
137
  const { result, logEntry } = await commandBus.execute<ProcessedChangeOverrideStateInput, { overrideToggleId: string | null }>('feature_toggles.overrides.changeState', {
109
138
  input: {
@@ -115,6 +144,20 @@ export async function PUT(req: Request) {
115
144
  ctx,
116
145
  })
117
146
 
147
+ if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
148
+ await runCrudMutationGuardAfterSuccess(ctx.container, {
149
+ tenantId: scope.tenantId,
150
+ organizationId: organizationId ?? null,
151
+ userId: guardUserId,
152
+ resourceKind: guardResourceKind,
153
+ resourceId: result?.overrideToggleId ?? guardResourceId,
154
+ operation: 'update',
155
+ requestMethod: req.method,
156
+ requestHeaders: req.headers,
157
+ metadata: guardResult.metadata ?? null,
158
+ })
159
+ }
160
+
118
161
  const response = NextResponse.json({
119
162
  ok: true,
120
163
  overrideToggleId: result?.overrideToggleId ?? null,
@@ -1,6 +1,10 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
3
3
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
+ import {
5
+ runCrudMutationGuardAfterSuccess,
6
+ validateCrudMutationGuard,
7
+ } from '@open-mercato/shared/lib/crud/mutation-guard'
4
8
  import { InboxEmail } from '../../../data/entities'
5
9
  import {
6
10
  resolveRequestContext,
@@ -62,6 +66,20 @@ export async function DELETE(req: Request) {
62
66
 
63
67
  const ctx = await resolveRequestContext(req)
64
68
 
69
+ const guardResult = await validateCrudMutationGuard(ctx.container, {
70
+ tenantId: ctx.tenantId,
71
+ organizationId: ctx.organizationId,
72
+ userId: ctx.userId,
73
+ resourceKind: 'inbox_ops:inbox_email',
74
+ resourceId: id,
75
+ operation: 'delete',
76
+ requestMethod: req.method,
77
+ requestHeaders: req.headers,
78
+ })
79
+ if (guardResult && !guardResult.ok) {
80
+ return NextResponse.json(guardResult.body, { status: guardResult.status })
81
+ }
82
+
65
83
  const updated = await ctx.em.nativeUpdate(
66
84
  InboxEmail,
67
85
  {
@@ -77,6 +95,20 @@ export async function DELETE(req: Request) {
77
95
  return NextResponse.json({ error: 'Email not found' }, { status: 404 })
78
96
  }
79
97
 
98
+ if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
99
+ await runCrudMutationGuardAfterSuccess(ctx.container, {
100
+ tenantId: ctx.tenantId,
101
+ organizationId: ctx.organizationId,
102
+ userId: ctx.userId,
103
+ resourceKind: 'inbox_ops:inbox_email',
104
+ resourceId: id,
105
+ operation: 'delete',
106
+ requestMethod: req.method,
107
+ requestHeaders: req.headers,
108
+ metadata: guardResult.metadata ?? null,
109
+ })
110
+ }
111
+
80
112
  return NextResponse.json({ ok: true })
81
113
  } catch (err) {
82
114
  if (err instanceof UnauthorizedError) {
@@ -1,6 +1,10 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
3
3
  import { runWithCacheTenant } from '@open-mercato/cache'
4
+ import {
5
+ runCrudMutationGuardAfterSuccess,
6
+ validateCrudMutationGuard,
7
+ } from '@open-mercato/shared/lib/crud/mutation-guard'
4
8
  import { acceptAllActions } from '../../../../lib/executionEngine'
5
9
  import { resolveCache, invalidateCountsCache } from '../../../../lib/cache'
6
10
  import {
@@ -22,6 +26,20 @@ export async function POST(req: Request) {
22
26
  const proposal = await resolveProposal(new URL(req.url), ctx)
23
27
  if (isErrorResponse(proposal)) return proposal
24
28
 
29
+ const guardResult = await validateCrudMutationGuard(ctx.container, {
30
+ tenantId: ctx.tenantId,
31
+ organizationId: ctx.organizationId,
32
+ userId: ctx.userId,
33
+ resourceKind: 'inbox_ops:inbox_proposal',
34
+ resourceId: proposal.id,
35
+ operation: 'custom',
36
+ requestMethod: req.method,
37
+ requestHeaders: req.headers,
38
+ })
39
+ if (guardResult && !guardResult.ok) {
40
+ return NextResponse.json(guardResult.body, { status: guardResult.status })
41
+ }
42
+
25
43
  const entities = resolveCrossModuleEntities(ctx.container)
26
44
  const { results, stoppedOnFailure } = await acceptAllActions(
27
45
  proposal.id,
@@ -31,6 +49,20 @@ export async function POST(req: Request) {
31
49
  const succeeded = results.filter((r) => r.success).length
32
50
  const failed = results.filter((r) => !r.success).length
33
51
 
52
+ if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
53
+ await runCrudMutationGuardAfterSuccess(ctx.container, {
54
+ tenantId: ctx.tenantId,
55
+ organizationId: ctx.organizationId,
56
+ userId: ctx.userId,
57
+ resourceKind: 'inbox_ops:inbox_proposal',
58
+ resourceId: proposal.id,
59
+ operation: 'custom',
60
+ requestMethod: req.method,
61
+ requestHeaders: req.headers,
62
+ metadata: guardResult.metadata ?? null,
63
+ })
64
+ }
65
+
34
66
  const cache = resolveCache(ctx.container)
35
67
  await runWithCacheTenant(ctx.tenantId, () => invalidateCountsCache(cache, ctx.tenantId))
36
68
 
@@ -1,6 +1,10 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
3
3
  import { runWithCacheTenant } from '@open-mercato/cache'
4
+ import {
5
+ runCrudMutationGuardAfterSuccess,
6
+ validateCrudMutationGuard,
7
+ } from '@open-mercato/shared/lib/crud/mutation-guard'
4
8
  import { actionEditSchema, validateActionPayloadForType } from '../../../../../data/validators'
5
9
  import { emitInboxOpsEvent } from '../../../../../events'
6
10
  import { resolveCache, invalidateCountsCache } from '../../../../../lib/cache'
@@ -39,9 +43,38 @@ export async function PATCH(req: Request) {
39
43
  return NextResponse.json({ error: payloadValidation.error }, { status: 400 })
40
44
  }
41
45
 
46
+ const guardResult = await validateCrudMutationGuard(ctx.container, {
47
+ tenantId: ctx.tenantId,
48
+ organizationId: ctx.organizationId,
49
+ userId: ctx.userId,
50
+ resourceKind: 'inbox_ops:inbox_proposal_action',
51
+ resourceId: action.id,
52
+ operation: 'update',
53
+ requestMethod: req.method,
54
+ requestHeaders: req.headers,
55
+ mutationPayload: parsed.data,
56
+ })
57
+ if (guardResult && !guardResult.ok) {
58
+ return NextResponse.json(guardResult.body, { status: guardResult.status })
59
+ }
60
+
42
61
  action.payload = mergedPayload
43
62
  await ctx.em.flush()
44
63
 
64
+ if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
65
+ await runCrudMutationGuardAfterSuccess(ctx.container, {
66
+ tenantId: ctx.tenantId,
67
+ organizationId: ctx.organizationId,
68
+ userId: ctx.userId,
69
+ resourceKind: 'inbox_ops:inbox_proposal_action',
70
+ resourceId: action.id,
71
+ operation: 'update',
72
+ requestMethod: req.method,
73
+ requestHeaders: req.headers,
74
+ metadata: guardResult.metadata ?? null,
75
+ })
76
+ }
77
+
45
78
  const cache = resolveCache(ctx.container)
46
79
  await runWithCacheTenant(ctx.tenantId, () => invalidateCountsCache(cache, ctx.tenantId))
47
80
 
@@ -1,6 +1,10 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
3
3
  import { runWithCacheTenant } from '@open-mercato/cache'
4
+ import {
5
+ runCrudMutationGuardAfterSuccess,
6
+ validateCrudMutationGuard,
7
+ } from '@open-mercato/shared/lib/crud/mutation-guard'
4
8
  import { categorizeProposalSchema } from '../../../../data/validators'
5
9
  import { resolveCache, invalidateCountsCache } from '../../../../lib/cache'
6
10
  import {
@@ -27,10 +31,39 @@ export async function POST(req: Request) {
27
31
  return NextResponse.json({ error: `Invalid category: ${issues}` }, { status: 400 })
28
32
  }
29
33
 
34
+ const guardResult = await validateCrudMutationGuard(ctx.container, {
35
+ tenantId: ctx.tenantId,
36
+ organizationId: ctx.organizationId,
37
+ userId: ctx.userId,
38
+ resourceKind: 'inbox_ops:inbox_proposal',
39
+ resourceId: proposal.id,
40
+ operation: 'update',
41
+ requestMethod: req.method,
42
+ requestHeaders: req.headers,
43
+ mutationPayload: parsed.data,
44
+ })
45
+ if (guardResult && !guardResult.ok) {
46
+ return NextResponse.json(guardResult.body, { status: guardResult.status })
47
+ }
48
+
30
49
  const previousCategory = proposal.category || null
31
50
  proposal.category = parsed.data.category
32
51
  await ctx.em.flush()
33
52
 
53
+ if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
54
+ await runCrudMutationGuardAfterSuccess(ctx.container, {
55
+ tenantId: ctx.tenantId,
56
+ organizationId: ctx.organizationId,
57
+ userId: ctx.userId,
58
+ resourceKind: 'inbox_ops:inbox_proposal',
59
+ resourceId: proposal.id,
60
+ operation: 'update',
61
+ requestMethod: req.method,
62
+ requestHeaders: req.headers,
63
+ metadata: guardResult.metadata ?? null,
64
+ })
65
+ }
66
+
34
67
  const cache = resolveCache(ctx.container)
35
68
  await runWithCacheTenant(ctx.tenantId, () => invalidateCountsCache(cache, ctx.tenantId))
36
69
 
@@ -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