@open-mercato/core 0.6.6-develop.6229.1.ebe6706732 → 0.6.6-develop.6241.1.47c01ad4ed

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 (125) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/commands/users.js +1 -0
  3. package/dist/modules/auth/commands/users.js.map +2 -2
  4. package/dist/modules/communication_channels/data/enrichers.js +9 -1
  5. package/dist/modules/communication_channels/data/enrichers.js.map +2 -2
  6. package/dist/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.client.js +3 -5
  7. package/dist/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.client.js.map +2 -2
  8. package/dist/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.js.map +2 -2
  9. package/dist/modules/customers/api/pipeline-stages/reorder/route.js +39 -1
  10. package/dist/modules/customers/api/pipeline-stages/reorder/route.js.map +2 -2
  11. package/dist/modules/customers/api/pipeline-stages/route.js +107 -12
  12. package/dist/modules/customers/api/pipeline-stages/route.js.map +2 -2
  13. package/dist/modules/customers/api/pipelines/route.js +107 -12
  14. package/dist/modules/customers/api/pipelines/route.js.map +2 -2
  15. package/dist/modules/customers/api/settings/address-format/route.js +33 -1
  16. package/dist/modules/customers/api/settings/address-format/route.js.map +2 -2
  17. package/dist/modules/customers/api/tags/assign/route.js +37 -0
  18. package/dist/modules/customers/api/tags/assign/route.js.map +2 -2
  19. package/dist/modules/customers/api/tags/unassign/route.js +37 -0
  20. package/dist/modules/customers/api/tags/unassign/route.js.map +2 -2
  21. package/dist/modules/entities/api/encryption.js +109 -38
  22. package/dist/modules/entities/api/encryption.js.map +2 -2
  23. package/dist/modules/entities/components/EncryptionManager.js +22 -9
  24. package/dist/modules/entities/components/EncryptionManager.js.map +2 -2
  25. package/dist/modules/integrations/api/[id]/credentials/route.js +23 -1
  26. package/dist/modules/integrations/api/[id]/credentials/route.js.map +2 -2
  27. package/dist/modules/integrations/api/[id]/route.js +10 -3
  28. package/dist/modules/integrations/api/[id]/route.js.map +2 -2
  29. package/dist/modules/integrations/api/[id]/state/route.js +20 -5
  30. package/dist/modules/integrations/api/[id]/state/route.js.map +2 -2
  31. package/dist/modules/integrations/api/[id]/version/route.js +17 -1
  32. package/dist/modules/integrations/api/[id]/version/route.js.map +2 -2
  33. package/dist/modules/integrations/api/route.js +2 -0
  34. package/dist/modules/integrations/api/route.js.map +2 -2
  35. package/dist/modules/integrations/backend/integrations/[id]/page.js +36 -19
  36. package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
  37. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js +13 -9
  38. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js.map +2 -2
  39. package/dist/modules/integrations/backend/integrations/page.js +4 -3
  40. package/dist/modules/integrations/backend/integrations/page.js.map +2 -2
  41. package/dist/modules/integrations/data/entities.js +2 -2
  42. package/dist/modules/integrations/data/entities.js.map +2 -2
  43. package/dist/modules/integrations/lib/credentials-service.js +32 -0
  44. package/dist/modules/integrations/lib/credentials-service.js.map +2 -2
  45. package/dist/modules/integrations/lib/state-service.js +4 -2
  46. package/dist/modules/integrations/lib/state-service.js.map +2 -2
  47. package/dist/modules/notifications/lib/notificationService.js +42 -10
  48. package/dist/modules/notifications/lib/notificationService.js.map +2 -2
  49. package/dist/modules/payment_gateways/api/status/route.js +91 -6
  50. package/dist/modules/payment_gateways/api/status/route.js.map +2 -2
  51. package/dist/modules/payment_gateways/backend/payment-gateways/page.js +25 -11
  52. package/dist/modules/payment_gateways/backend/payment-gateways/page.js.map +2 -2
  53. package/dist/modules/sales/backend/sales/channels/offers/page.js +25 -7
  54. package/dist/modules/sales/backend/sales/channels/offers/page.js.map +2 -2
  55. package/dist/modules/sales/backend/sales/channels/page.js +25 -7
  56. package/dist/modules/sales/backend/sales/channels/page.js.map +2 -2
  57. package/dist/modules/sales/components/AdjustmentKindSettings.js +52 -24
  58. package/dist/modules/sales/components/AdjustmentKindSettings.js.map +2 -2
  59. package/dist/modules/sales/components/DocumentNumberSettings.js +26 -9
  60. package/dist/modules/sales/components/DocumentNumberSettings.js.map +2 -2
  61. package/dist/modules/sales/components/OrderEditingSettings.js +26 -9
  62. package/dist/modules/sales/components/OrderEditingSettings.js.map +2 -2
  63. package/dist/modules/sales/components/StatusSettings.js +66 -31
  64. package/dist/modules/sales/components/StatusSettings.js.map +2 -2
  65. package/dist/modules/sales/components/channels/SalesChannelOffersPanel.js +25 -7
  66. package/dist/modules/sales/components/channels/SalesChannelOffersPanel.js.map +2 -2
  67. package/dist/modules/shipping_carriers/api/cancel/route.js +38 -0
  68. package/dist/modules/shipping_carriers/api/cancel/route.js.map +2 -2
  69. package/dist/modules/shipping_carriers/api/shipments/route.js +12 -5
  70. package/dist/modules/shipping_carriers/api/shipments/route.js.map +2 -2
  71. package/dist/modules/staff/lib/timesheets-ui/TimerBar.js +7 -8
  72. package/dist/modules/staff/lib/timesheets-ui/TimerBar.js.map +2 -2
  73. package/dist/modules/staff/lib/timesheets-ui/timerErrors.js +12 -0
  74. package/dist/modules/staff/lib/timesheets-ui/timerErrors.js.map +7 -0
  75. package/dist/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.js +4 -3
  76. package/dist/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.js.map +2 -2
  77. package/dist/modules/workflows/backend/definitions/page.js +19 -10
  78. package/dist/modules/workflows/backend/definitions/page.js.map +2 -2
  79. package/package.json +7 -7
  80. package/src/modules/auth/commands/users.ts +1 -0
  81. package/src/modules/communication_channels/data/enrichers.ts +25 -0
  82. package/src/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.client.tsx +7 -6
  83. package/src/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.ts +5 -2
  84. package/src/modules/customers/api/pipeline-stages/reorder/route.ts +41 -1
  85. package/src/modules/customers/api/pipeline-stages/route.ts +112 -13
  86. package/src/modules/customers/api/pipelines/route.ts +112 -13
  87. package/src/modules/customers/api/settings/address-format/route.ts +36 -1
  88. package/src/modules/customers/api/tags/assign/route.ts +39 -0
  89. package/src/modules/customers/api/tags/unassign/route.ts +39 -0
  90. package/src/modules/customers/i18n/de.json +1 -0
  91. package/src/modules/customers/i18n/en.json +1 -0
  92. package/src/modules/customers/i18n/es.json +1 -0
  93. package/src/modules/customers/i18n/pl.json +1 -0
  94. package/src/modules/entities/api/encryption.ts +122 -39
  95. package/src/modules/entities/components/EncryptionManager.tsx +28 -9
  96. package/src/modules/integrations/api/[id]/credentials/route.ts +23 -0
  97. package/src/modules/integrations/api/[id]/route.ts +8 -1
  98. package/src/modules/integrations/api/[id]/state/route.ts +20 -4
  99. package/src/modules/integrations/api/[id]/version/route.ts +18 -1
  100. package/src/modules/integrations/api/route.ts +3 -0
  101. package/src/modules/integrations/backend/integrations/[id]/page.tsx +39 -20
  102. package/src/modules/integrations/backend/integrations/bundle/[id]/page.tsx +19 -11
  103. package/src/modules/integrations/backend/integrations/page.tsx +8 -5
  104. package/src/modules/integrations/data/entities.ts +2 -2
  105. package/src/modules/integrations/lib/credentials-service.ts +35 -0
  106. package/src/modules/integrations/lib/state-service.ts +3 -0
  107. package/src/modules/notifications/lib/notificationService.ts +74 -11
  108. package/src/modules/payment_gateways/api/status/route.ts +97 -5
  109. package/src/modules/payment_gateways/backend/payment-gateways/page.tsx +27 -11
  110. package/src/modules/sales/backend/sales/channels/offers/page.tsx +31 -7
  111. package/src/modules/sales/backend/sales/channels/page.tsx +31 -7
  112. package/src/modules/sales/components/AdjustmentKindSettings.tsx +60 -24
  113. package/src/modules/sales/components/DocumentNumberSettings.tsx +32 -10
  114. package/src/modules/sales/components/OrderEditingSettings.tsx +32 -10
  115. package/src/modules/sales/components/StatusSettings.tsx +74 -33
  116. package/src/modules/sales/components/channels/SalesChannelOffersPanel.tsx +30 -6
  117. package/src/modules/shipping_carriers/api/cancel/route.ts +46 -0
  118. package/src/modules/shipping_carriers/api/shipments/route.ts +21 -6
  119. package/src/modules/staff/i18n/de.json +5 -5
  120. package/src/modules/staff/i18n/es.json +5 -5
  121. package/src/modules/staff/i18n/pl.json +5 -5
  122. package/src/modules/staff/lib/timesheets-ui/TimerBar.tsx +7 -8
  123. package/src/modules/staff/lib/timesheets-ui/timerErrors.ts +23 -0
  124. package/src/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.tsx +4 -3
  125. package/src/modules/workflows/backend/definitions/page.tsx +19 -10
@@ -24,7 +24,8 @@ import { PasswordInput } from '@open-mercato/ui/primitives/password-input'
24
24
  import { Spinner } from '@open-mercato/ui/primitives/spinner'
25
25
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '@open-mercato/ui/primitives/tabs'
26
26
  import { JsonDisplay } from '@open-mercato/ui/backend/JsonDisplay'
27
- import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
27
+ import { apiCall, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
28
+ import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
28
29
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
29
30
  import { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'
30
31
  import { useT } from '@open-mercato/shared/lib/i18n/context'
@@ -105,8 +106,10 @@ type IntegrationDetail = {
105
106
  lastHealthCheckedAt: string | null
106
107
  lastHealthLatencyMs: number | null
107
108
  enabledAt: string | null
109
+ updatedAt: string | null
108
110
  }
109
111
  hasCredentials: boolean
112
+ credentialsUpdatedAt?: string | null
110
113
  healthStatus: 'healthy' | 'degraded' | 'unhealthy' | 'unconfigured'
111
114
  analytics: IntegrationLogAnalytics
112
115
  }
@@ -423,6 +426,7 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
423
426
  const [isNotFound, setIsNotFound] = React.useState(false)
424
427
 
425
428
  const [credValues, setCredValues] = React.useState<Record<string, unknown>>({})
429
+ const [credentialsUpdatedAt, setCredentialsUpdatedAt] = React.useState<string | null>(null)
426
430
  const [credentialsFormKey, setCredentialsFormKey] = React.useState(0)
427
431
  const [isSavingCredentials, setIsSavingCredentials] = React.useState(false)
428
432
 
@@ -486,11 +490,14 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
486
490
  const loadCredentials = React.useCallback(async () => {
487
491
  const currentIntegrationId = resolveCurrentIntegrationId()
488
492
  if (!currentIntegrationId) return
489
- const call = await apiCall<{ credentials: Record<string, unknown> }>(
493
+ const call = await apiCall<{ credentials: Record<string, unknown>; updatedAt?: string | null }>(
490
494
  `/api/integrations/${encodeURIComponent(currentIntegrationId)}/credentials`,
491
495
  undefined,
492
496
  { fallback: null },
493
497
  )
498
+ if (call.ok && call.result) {
499
+ setCredentialsUpdatedAt(call.result.updatedAt ?? null)
500
+ }
494
501
  if (call.ok && call.result?.credentials) {
495
502
  const next = { ...call.result.credentials }
496
503
  if (currentIntegrationId === 'storage_s3') {
@@ -671,11 +678,14 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
671
678
  const call = await runMutationWithContext({
672
679
  actionId: 'toggle-state',
673
680
  mutationPayload: { integrationId: currentIntegrationId, isEnabled: enabled },
674
- operation: () => apiCall(`/api/integrations/${encodeURIComponent(currentIntegrationId)}/state`, {
675
- method: 'PUT',
676
- headers: { 'Content-Type': 'application/json' },
677
- body: JSON.stringify({ isEnabled: enabled }),
678
- }, { fallback: null }),
681
+ operation: () => withScopedApiRequestHeaders(
682
+ buildOptimisticLockHeader(detail?.state.updatedAt),
683
+ () => apiCall(`/api/integrations/${encodeURIComponent(currentIntegrationId)}/state`, {
684
+ method: 'PUT',
685
+ headers: { 'Content-Type': 'application/json' },
686
+ body: JSON.stringify({ isEnabled: enabled }),
687
+ }, { fallback: null }),
688
+ ),
679
689
  })
680
690
  if (call.ok) {
681
691
  setDetail((prev) => prev ? {
@@ -687,6 +697,7 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
687
697
  },
688
698
  } : prev)
689
699
  flash(t('integrations.detail.stateUpdated'), 'success')
700
+ void refreshDetail()
690
701
  } else {
691
702
  flash(t('integrations.detail.stateError'), 'error')
692
703
  }
@@ -695,7 +706,7 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
695
706
  } finally {
696
707
  setIsTogglingState(false)
697
708
  }
698
- }, [resolveCurrentIntegrationId, runMutationWithContext, t])
709
+ }, [detail?.state.updatedAt, refreshDetail, resolveCurrentIntegrationId, runMutationWithContext, t])
699
710
 
700
711
  const handleSaveCredentials = React.useCallback(async (values: Record<string, unknown>) => {
701
712
  const currentIntegrationId = resolveCurrentIntegrationId()
@@ -719,17 +730,21 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
719
730
  actionId: 'save-credentials',
720
731
  tabId: 'credentials',
721
732
  mutationPayload: { integrationId: currentIntegrationId, credentials: sanitizedValues },
722
- operation: () => apiCall(`/api/integrations/${encodeURIComponent(currentIntegrationId)}/credentials`, {
723
- method: 'PUT',
724
- headers: { 'Content-Type': 'application/json' },
725
- body: JSON.stringify({ credentials: sanitizedValues }),
726
- }, { fallback: null }),
733
+ operation: () => withScopedApiRequestHeaders(
734
+ buildOptimisticLockHeader(credentialsUpdatedAt),
735
+ () => apiCall(`/api/integrations/${encodeURIComponent(currentIntegrationId)}/credentials`, {
736
+ method: 'PUT',
737
+ headers: { 'Content-Type': 'application/json' },
738
+ body: JSON.stringify({ credentials: sanitizedValues }),
739
+ }, { fallback: null }),
740
+ ),
727
741
  })
728
742
 
729
743
  if (call.ok) {
730
744
  setCredValues(sanitizedValues)
731
745
  setCredentialsFormKey((current) => current + 1)
732
746
  flash(t('integrations.detail.credentials.saved'), 'success')
747
+ void loadCredentials()
733
748
  return
734
749
  }
735
750
 
@@ -745,7 +760,7 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
745
760
  } finally {
746
761
  setIsSavingCredentials(false)
747
762
  }
748
- }, [resolveCurrentIntegrationId, runMutationWithContext, t])
763
+ }, [credentialsUpdatedAt, loadCredentials, resolveCurrentIntegrationId, runMutationWithContext, t])
749
764
 
750
765
  const handleVersionChange = React.useCallback(async (version: string) => {
751
766
  const currentIntegrationId = resolveCurrentIntegrationId()
@@ -755,22 +770,26 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
755
770
  actionId: 'change-version',
756
771
  tabId: 'version',
757
772
  mutationPayload: { integrationId: currentIntegrationId, apiVersion: version },
758
- operation: () => apiCall(`/api/integrations/${encodeURIComponent(currentIntegrationId)}/version`, {
759
- method: 'PUT',
760
- headers: { 'Content-Type': 'application/json' },
761
- body: JSON.stringify({ apiVersion: version }),
762
- }, { fallback: null }),
773
+ operation: () => withScopedApiRequestHeaders(
774
+ buildOptimisticLockHeader(detail?.state.updatedAt),
775
+ () => apiCall(`/api/integrations/${encodeURIComponent(currentIntegrationId)}/version`, {
776
+ method: 'PUT',
777
+ headers: { 'Content-Type': 'application/json' },
778
+ body: JSON.stringify({ apiVersion: version }),
779
+ }, { fallback: null }),
780
+ ),
763
781
  })
764
782
  if (call.ok) {
765
783
  setDetail((prev) => prev ? { ...prev, state: { ...prev.state, apiVersion: version } } : prev)
766
784
  flash(t('integrations.detail.version.saved'), 'success')
785
+ void refreshDetail()
767
786
  } else {
768
787
  flash(t('integrations.detail.version.saveError'), 'error')
769
788
  }
770
789
  } catch {
771
790
  flash(t('integrations.detail.version.saveError'), 'error')
772
791
  }
773
- }, [resolveCurrentIntegrationId, runMutationWithContext, t])
792
+ }, [detail?.state.updatedAt, refreshDetail, resolveCurrentIntegrationId, runMutationWithContext, t])
774
793
 
775
794
  const handleHealthCheck = React.useCallback(async () => {
776
795
  const currentIntegrationId = resolveCurrentIntegrationId()
@@ -38,6 +38,7 @@ type BundleIntegration = {
38
38
  description?: string
39
39
  category?: string
40
40
  isEnabled: boolean
41
+ state?: { updatedAt?: string | null }
41
42
  }
42
43
 
43
44
  type BundleDetail = {
@@ -56,6 +57,7 @@ type BundleDetail = {
56
57
  bundleIntegrations: BundleIntegration[]
57
58
  state: { isEnabled: boolean }
58
59
  hasCredentials: boolean
60
+ credentialsUpdatedAt?: string | null
59
61
  }
60
62
 
61
63
  type BundleConfigPageProps = {
@@ -86,6 +88,7 @@ export default function BundleConfigPage({ params }: BundleConfigPageProps) {
86
88
  const [error, setError] = React.useState<string | null>(null)
87
89
  const [isNotFound, setIsNotFound] = React.useState(false)
88
90
  const [credValues, setCredValues] = React.useState<Record<string, unknown>>({})
91
+ const [credentialsUpdatedAt, setCredentialsUpdatedAt] = React.useState<string | null>(null)
89
92
  const [isSavingCreds, setIsSavingCreds] = React.useState(false)
90
93
  const [togglingIds, setTogglingIds] = React.useState<Set<string>>(new Set())
91
94
 
@@ -131,11 +134,14 @@ export default function BundleConfigPage({ params }: BundleConfigPageProps) {
131
134
  }
132
135
  setDetail(call.result)
133
136
 
134
- const credCall = await apiCall<{ credentials: Record<string, unknown> }>(
137
+ const credCall = await apiCall<{ credentials: Record<string, unknown>; updatedAt?: string | null }>(
135
138
  `/api/integrations/${encodeURIComponent(currentBundleId)}/credentials`,
136
139
  undefined,
137
140
  { fallback: null },
138
141
  )
142
+ if (credCall.ok && credCall.result) {
143
+ setCredentialsUpdatedAt(credCall.result.updatedAt ?? null)
144
+ }
139
145
  if (credCall.ok && credCall.result?.credentials) {
140
146
  const next = { ...credCall.result.credentials }
141
147
  if (currentBundleId === 'storage_s3') {
@@ -168,9 +174,8 @@ export default function BundleConfigPage({ params }: BundleConfigPageProps) {
168
174
  bundleId: currentBundleId,
169
175
  retryLastMutation,
170
176
  },
171
- // TODO(#2373-B): thread updatedAt — integration detail/state response does not expose a record version yet
172
177
  operation: () => withScopedApiRequestHeaders(
173
- buildOptimisticLockHeader(undefined),
178
+ buildOptimisticLockHeader(credentialsUpdatedAt),
174
179
  () => apiCall(`/api/integrations/${encodeURIComponent(currentBundleId)}/credentials`, {
175
180
  method: 'PUT',
176
181
  headers: { 'Content-Type': 'application/json' },
@@ -180,6 +185,7 @@ export default function BundleConfigPage({ params }: BundleConfigPageProps) {
180
185
  })
181
186
  if (call.ok) {
182
187
  flash(t('integrations.detail.credentials.saved'), 'success')
188
+ await load()
183
189
  } else {
184
190
  flash(t('integrations.detail.credentials.saveError'), 'error')
185
191
  }
@@ -188,9 +194,9 @@ export default function BundleConfigPage({ params }: BundleConfigPageProps) {
188
194
  } finally {
189
195
  setIsSavingCreds(false)
190
196
  }
191
- }, [resolveCurrentBundleId, runMutation, mutationContextId, retryLastMutation, credValues, t])
197
+ }, [resolveCurrentBundleId, runMutation, mutationContextId, retryLastMutation, credValues, credentialsUpdatedAt, load, t])
192
198
 
193
- const handleToggle = React.useCallback(async (integrationId: string, enabled: boolean) => {
199
+ const handleToggle = React.useCallback(async (integrationId: string, enabled: boolean, updatedAt?: string | null) => {
194
200
  setTogglingIds((prev) => new Set(prev).add(integrationId))
195
201
  try {
196
202
  const call = await runMutation({
@@ -204,10 +210,9 @@ export default function BundleConfigPage({ params }: BundleConfigPageProps) {
204
210
  integrationId,
205
211
  retryLastMutation,
206
212
  },
207
- // TODO(#2373-B): thread updatedAt — integration detail/state response does not expose a record version yet
208
213
  operation: () => withScopedApiRequestHeaders(
209
- buildOptimisticLockHeader(undefined),
210
- () => apiCall(`/api/integrations/${encodeURIComponent(integrationId)}/state`, {
214
+ buildOptimisticLockHeader(updatedAt),
215
+ () => apiCall<{ updatedAt?: string | null }>(`/api/integrations/${encodeURIComponent(integrationId)}/state`, {
211
216
  method: 'PUT',
212
217
  headers: { 'Content-Type': 'application/json' },
213
218
  body: JSON.stringify({ isEnabled: enabled }),
@@ -215,12 +220,15 @@ export default function BundleConfigPage({ params }: BundleConfigPageProps) {
215
220
  ),
216
221
  })
217
222
  if (call.ok) {
223
+ const nextUpdatedAt = call.result?.updatedAt ?? null
218
224
  setDetail((prev) => {
219
225
  if (!prev) return prev
220
226
  return {
221
227
  ...prev,
222
228
  bundleIntegrations: prev.bundleIntegrations.map((item) =>
223
- item.id === integrationId ? { ...item, isEnabled: enabled } : item,
229
+ item.id === integrationId
230
+ ? { ...item, isEnabled: enabled, state: { updatedAt: nextUpdatedAt ?? item.state?.updatedAt ?? null } }
231
+ : item,
224
232
  ),
225
233
  }
226
234
  })
@@ -237,7 +245,7 @@ export default function BundleConfigPage({ params }: BundleConfigPageProps) {
237
245
  const handleBulkToggle = React.useCallback(async (enabled: boolean) => {
238
246
  if (!detail) return
239
247
  const targets = detail.bundleIntegrations.filter((item) => item.isEnabled !== enabled)
240
- await Promise.all(targets.map((item) => handleToggle(item.id, enabled)))
248
+ await Promise.all(targets.map((item) => handleToggle(item.id, enabled, item.state?.updatedAt)))
241
249
  }, [detail, handleToggle])
242
250
 
243
251
  if (isLoading) return <Page><PageBody><LoadingMessage label={t('integrations.bundle.title')} /></PageBody></Page>
@@ -368,7 +376,7 @@ export default function BundleConfigPage({ params }: BundleConfigPageProps) {
368
376
  <Switch
369
377
  checked={item.isEnabled}
370
378
  disabled={togglingIds.has(item.id)}
371
- onCheckedChange={(checked) => void handleToggle(item.id, checked)}
379
+ onCheckedChange={(checked) => void handleToggle(item.id, checked, item.state?.updatedAt)}
372
380
  />
373
381
  </div>
374
382
  </div>
@@ -45,7 +45,7 @@ type IntegrationItem = {
45
45
  hasCredentials: boolean
46
46
  healthStatus: 'healthy' | 'degraded' | 'unhealthy' | 'unconfigured'
47
47
  analytics: IntegrationAnalytics
48
- updatedAt?: string | null
48
+ stateUpdatedAt?: string | null
49
49
  }
50
50
 
51
51
  type BundleItem = {
@@ -220,7 +220,7 @@ export default function IntegrationsMarketplacePage() {
220
220
  },
221
221
  operation: () => withScopedApiRequestHeaders(
222
222
  buildOptimisticLockHeader(updatedAt),
223
- () => apiCall(`/api/integrations/${encodeURIComponent(integrationId)}/state`, {
223
+ () => apiCall<{ updatedAt?: string | null }>(`/api/integrations/${encodeURIComponent(integrationId)}/state`, {
224
224
  method: 'PUT',
225
225
  headers: { 'Content-Type': 'application/json' },
226
226
  body: JSON.stringify({ isEnabled: enabled }),
@@ -231,12 +231,15 @@ export default function IntegrationsMarketplacePage() {
231
231
  if (!call.ok) {
232
232
  flash(t('integrations.detail.stateError'), 'error')
233
233
  } else {
234
+ const nextUpdatedAt = call.result?.updatedAt ?? null
234
235
  setData((prev) => {
235
236
  if (!prev) return prev
236
237
  return {
237
238
  ...prev,
238
239
  items: prev.items.map((item) =>
239
- item.id === integrationId ? { ...item, isEnabled: enabled } : item,
240
+ item.id === integrationId
241
+ ? { ...item, isEnabled: enabled, stateUpdatedAt: nextUpdatedAt ?? item.stateUpdatedAt }
242
+ : item,
240
243
  ),
241
244
  }
242
245
  })
@@ -465,7 +468,7 @@ export default function IntegrationsMarketplacePage() {
465
468
  <Switch
466
469
  checked={item.isEnabled}
467
470
  disabled={togglingIds.has(item.id)}
468
- onCheckedChange={(checked) => void handleToggle(item.id, checked, item.updatedAt)}
471
+ onCheckedChange={(checked) => void handleToggle(item.id, checked, item.stateUpdatedAt)}
469
472
  className="shrink-0"
470
473
  />
471
474
  </div>
@@ -503,7 +506,7 @@ export default function IntegrationsMarketplacePage() {
503
506
  <Switch
504
507
  checked={item.isEnabled}
505
508
  disabled={togglingIds.has(item.id)}
506
- onCheckedChange={(checked) => void handleToggle(item.id, checked)}
509
+ onCheckedChange={(checked) => void handleToggle(item.id, checked, item.stateUpdatedAt)}
507
510
  className="shrink-0"
508
511
  />
509
512
  </div>
@@ -85,7 +85,7 @@ export class IntegrationCredentials {
85
85
  @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
86
86
  createdAt: Date = new Date()
87
87
 
88
- @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
88
+ @Property({ name: 'updated_at', type: Date, onCreate: () => new Date(), onUpdate: () => new Date() })
89
89
  updatedAt: Date = new Date()
90
90
 
91
91
  @Property({ name: 'deleted_at', type: Date, nullable: true })
@@ -132,7 +132,7 @@ export class IntegrationState {
132
132
  @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
133
133
  createdAt: Date = new Date()
134
134
 
135
- @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
135
+ @Property({ name: 'updated_at', type: Date, onCreate: () => new Date(), onUpdate: () => new Date() })
136
136
  updatedAt: Date = new Date()
137
137
 
138
138
  @Property({ name: 'deleted_at', type: Date, nullable: true })
@@ -184,6 +184,41 @@ export function createCredentialsService(em: EntityManager) {
184
184
  return decryptCredentialsBlob(row.credentials, scope)
185
185
  },
186
186
 
187
+ async getRowUpdatedAt(integrationId: string, scope: IntegrationScope): Promise<Date | null> {
188
+ let row = await findOneWithDecryption(
189
+ em,
190
+ IntegrationCredentials,
191
+ buildCredentialsFilter(integrationId, scope),
192
+ undefined,
193
+ scope,
194
+ )
195
+ if (!row && scope.userId) {
196
+ row = await findOneWithDecryption(
197
+ em,
198
+ IntegrationCredentials,
199
+ buildCredentialsFilter(integrationId, { ...scope, userId: null }),
200
+ undefined,
201
+ scope,
202
+ )
203
+ }
204
+ return row?.updatedAt ?? null
205
+ },
206
+
207
+ /**
208
+ * Resolve the persisted `updated_at` version for the credentials a caller
209
+ * would read via {@link resolve} (direct row first, then the bundle
210
+ * fallthrough). Returns `null` when no credentials row exists yet — the
211
+ * optimistic-lock guard treats a missing current version as "no conflict".
212
+ */
213
+ async resolveUpdatedAt(integrationId: string, scope: IntegrationScope): Promise<Date | null> {
214
+ const direct = await this.getRowUpdatedAt(integrationId, scope)
215
+ if (direct) return direct
216
+
217
+ const definition = getIntegration(integrationId)
218
+ if (!definition?.bundleId) return null
219
+ return this.getRowUpdatedAt(definition.bundleId, scope)
220
+ },
221
+
187
222
  async resolve(integrationId: string, scope: IntegrationScope): Promise<Record<string, unknown> | null> {
188
223
  const direct = await this.getRaw(integrationId, scope)
189
224
  if (direct) return direct
@@ -11,6 +11,7 @@ export type ResolvedIntegrationState = {
11
11
  lastHealthCheckedAt: Date | null
12
12
  lastHealthLatencyMs: number | null
13
13
  enabledAt: Date | null
14
+ updatedAt: Date | null
14
15
  }
15
16
 
16
17
  export function createIntegrationStateService(em: EntityManager) {
@@ -25,6 +26,7 @@ export function createIntegrationStateService(em: EntityManager) {
25
26
  lastHealthCheckedAt: null,
26
27
  lastHealthLatencyMs: null,
27
28
  enabledAt: null,
29
+ updatedAt: null,
28
30
  }
29
31
  }
30
32
 
@@ -56,6 +58,7 @@ export function createIntegrationStateService(em: EntityManager) {
56
58
  lastHealthCheckedAt: state?.lastHealthCheckedAt ?? null,
57
59
  lastHealthLatencyMs: state?.lastHealthLatencyMs ?? null,
58
60
  enabledAt: state?.enabledAt ?? null,
61
+ updatedAt: state?.updatedAt ?? null,
59
62
  }
60
63
  },
61
64
 
@@ -1,6 +1,6 @@
1
1
  import type { EntityManager } from '@mikro-orm/postgresql'
2
2
  import { type Kysely, sql } from 'kysely'
3
- import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
3
+ import { CrudHttpError, conflict } from '@open-mercato/shared/lib/crud/errors'
4
4
  import { Notification, type NotificationStatus } from '../data/entities'
5
5
  import type { CreateNotificationInput, CreateBatchNotificationInput, CreateRoleNotificationInput, CreateFeatureNotificationInput, ExecuteActionInput } from '../data/validators'
6
6
  import type { NotificationPollData } from '@open-mercato/shared/modules/notifications/types'
@@ -448,6 +448,57 @@ export function createNotificationService(deps: NotificationServiceDeps): Notifi
448
448
  throw new Error('Action not found')
449
449
  }
450
450
 
451
+ // Reject an already-actioned notification before dispatching the command,
452
+ // so a retry or double-click cannot re-run the side effect.
453
+ if (notification.status === 'actioned') {
454
+ throw conflict('Notification action already executed')
455
+ }
456
+
457
+ const actionedAt = new Date()
458
+ const previousStatus = notification.status
459
+ const previousActionedAt = notification.actionedAt ?? null
460
+ const previousActionTaken = notification.actionTaken ?? null
461
+
462
+ // Atomically claim the notification so only one concurrent request can run
463
+ // the side-effecting command. The conditional UPDATE matches only while the
464
+ // notification has not been actioned yet; a losing request updates 0 rows.
465
+ const claimResult = (await getDb(em)
466
+ .updateTable('notifications' as any)
467
+ .set({
468
+ status: 'actioned',
469
+ actioned_at: actionedAt,
470
+ action_taken: input.actionId,
471
+ } as any)
472
+ .where('id' as any, '=', notification.id)
473
+ .where('recipient_user_id' as any, '=', ctx.userId as any)
474
+ .where('tenant_id' as any, '=', ctx.tenantId)
475
+ .where('status' as any, '!=', 'actioned')
476
+ .executeTakeFirst()) as { numUpdatedRows?: bigint | number } | undefined
477
+
478
+ if (Number(claimResult?.numUpdatedRows ?? 0) === 0) {
479
+ throw conflict('Notification action already executed')
480
+ }
481
+
482
+ // The claim is provisional: if the side-effecting command fails, the action
483
+ // never actually completed, so release the claim to its prior state. This
484
+ // lets the user retry the action instead of the notification being locked as
485
+ // `actioned` forever. Only release while we still own the claim
486
+ // (status = 'actioned'), so a concurrent winner's state is never clobbered.
487
+ const releaseClaim = async () => {
488
+ await getDb(em)
489
+ .updateTable('notifications' as any)
490
+ .set({
491
+ status: previousStatus,
492
+ actioned_at: previousActionedAt,
493
+ action_taken: previousActionTaken,
494
+ } as any)
495
+ .where('id' as any, '=', notification.id)
496
+ .where('recipient_user_id' as any, '=', ctx.userId as any)
497
+ .where('tenant_id' as any, '=', ctx.tenantId)
498
+ .where('status' as any, '=', 'actioned')
499
+ .executeTakeFirst()
500
+ }
501
+
451
502
  let result: unknown = null
452
503
 
453
504
  if (action.commandId && commandBus && container) {
@@ -469,21 +520,33 @@ export function createNotificationService(deps: NotificationServiceDeps): Notifi
469
520
  organizationIds: ctx.organizationId ? [ctx.organizationId] : null,
470
521
  }
471
522
 
472
- const commandResult = await commandBus.execute(action.commandId, {
473
- input: commandInput,
474
- ctx: commandCtx,
475
- metadata: {
476
- tenantId: ctx.tenantId,
477
- organizationId: ctx.organizationId,
478
- resourceKind: 'notifications',
479
- },
480
- })
523
+ let commandResult: { result: unknown }
524
+ try {
525
+ commandResult = await commandBus.execute(action.commandId, {
526
+ input: commandInput,
527
+ ctx: commandCtx,
528
+ metadata: {
529
+ tenantId: ctx.tenantId,
530
+ organizationId: ctx.organizationId,
531
+ resourceKind: 'notifications',
532
+ },
533
+ })
534
+ } catch (err) {
535
+ // Never let a rollback failure mask the original command error — the
536
+ // caller needs the real failure to decide whether to retry.
537
+ try {
538
+ await releaseClaim()
539
+ } catch (releaseErr) {
540
+ debug('failed to release notification action claim', releaseErr)
541
+ }
542
+ throw err
543
+ }
481
544
 
482
545
  result = commandResult.result
483
546
  }
484
547
 
485
548
  notification.status = 'actioned'
486
- notification.actionedAt = new Date()
549
+ notification.actionedAt = actionedAt
487
550
  notification.actionTaken = input.actionId
488
551
  notification.actionResult = result as Record<string, unknown>
489
552
 
@@ -1,13 +1,21 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
3
3
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
+ import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
5
+ import {
6
+ runCrudMutationGuardAfterSuccess,
7
+ validateCrudMutationGuard,
8
+ } from '@open-mercato/shared/lib/crud/mutation-guard'
4
9
  import { getStatusSchema } from '../../data/validators'
5
10
  import type { PaymentGatewayService } from '../../lib/gateway-service'
6
11
  import { paymentGatewaysTag } from '../openapi'
7
12
 
13
+ const gatewayTransactionResourceKind = 'payment_gateways:gateway_transaction'
14
+
8
15
  export const metadata = {
9
16
  path: '/payment_gateways/status',
10
17
  GET: { requireAuth: true, requireFeatures: ['payment_gateways.view'] },
18
+ POST: { requireAuth: true, requireFeatures: ['payment_gateways.manage'] },
11
19
  }
12
20
 
13
21
  export async function GET(req: Request) {
@@ -36,8 +44,81 @@ export async function GET(req: Request) {
36
44
  return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
37
45
  }
38
46
 
47
+ return NextResponse.json({
48
+ transactionId: transaction.id,
49
+ paymentId: transaction.paymentId,
50
+ providerKey: transaction.providerKey,
51
+ sessionId: transaction.providerSessionId,
52
+ status: transaction.unifiedStatus,
53
+ gatewayStatus: transaction.gatewayStatus,
54
+ amount: Number(transaction.amount),
55
+ amountReceived: null,
56
+ currencyCode: transaction.currencyCode,
57
+ redirectUrl: transaction.redirectUrl,
58
+ createdAt: transaction.createdAt.toISOString(),
59
+ updatedAt: transaction.updatedAt.toISOString(),
60
+ })
61
+ } catch (err: unknown) {
62
+ const message = err instanceof Error ? err.message : 'Failed to get status'
63
+ return NextResponse.json({ error: message }, { status: 500 })
64
+ }
65
+ }
66
+
67
+ export async function POST(req: Request) {
68
+ const auth = await getAuthFromRequest(req)
69
+ if (!auth?.tenantId || !auth.orgId) {
70
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
71
+ }
72
+
73
+ const payload = await readJsonSafe<unknown>(req)
74
+ const parsed = getStatusSchema.safeParse(payload)
75
+ if (!parsed.success) {
76
+ return NextResponse.json({ error: 'Invalid payload', details: parsed.error.flatten() }, { status: 422 })
77
+ }
78
+
79
+ const container = await createRequestContainer()
80
+ const service = container.resolve('paymentGatewayService') as PaymentGatewayService
81
+ const scope = { organizationId: auth.orgId as string, tenantId: auth.tenantId }
82
+ const actorUserId = auth.userId ?? auth.sub
83
+ const { transactionId } = parsed.data
84
+
85
+ try {
86
+ const transaction = await service.findTransaction(transactionId, scope)
87
+ if (!transaction) {
88
+ return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
89
+ }
90
+
91
+ const guardResult = await validateCrudMutationGuard(container, {
92
+ tenantId: auth.tenantId,
93
+ organizationId: scope.organizationId,
94
+ userId: actorUserId,
95
+ resourceKind: gatewayTransactionResourceKind,
96
+ resourceId: transactionId,
97
+ operation: 'custom',
98
+ requestMethod: req.method,
99
+ requestHeaders: req.headers,
100
+ mutationPayload: parsed.data,
101
+ })
102
+ if (guardResult && !guardResult.ok) {
103
+ return NextResponse.json(guardResult.body, { status: guardResult.status })
104
+ }
105
+
39
106
  const status = await service.getPaymentStatus(transactionId, scope)
40
107
 
108
+ if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
109
+ await runCrudMutationGuardAfterSuccess(container, {
110
+ tenantId: auth.tenantId,
111
+ organizationId: scope.organizationId,
112
+ userId: actorUserId,
113
+ resourceKind: gatewayTransactionResourceKind,
114
+ resourceId: transactionId,
115
+ operation: 'custom',
116
+ requestMethod: req.method,
117
+ requestHeaders: req.headers,
118
+ metadata: guardResult.metadata ?? null,
119
+ })
120
+ }
121
+
41
122
  return NextResponse.json({
42
123
  transactionId: transaction.id,
43
124
  paymentId: transaction.paymentId,
@@ -53,23 +134,34 @@ export async function GET(req: Request) {
53
134
  updatedAt: transaction.updatedAt.toISOString(),
54
135
  })
55
136
  } catch (err: unknown) {
56
- const message = err instanceof Error ? err.message : 'Failed to get status'
57
- return NextResponse.json({ error: message }, { status: 500 })
137
+ const message = err instanceof Error ? err.message : 'Failed to refresh status'
138
+ return NextResponse.json({ error: message }, { status: 502 })
58
139
  }
59
140
  }
60
141
 
61
142
  export const openApi = {
62
143
  tags: [paymentGatewaysTag],
63
- summary: 'Get payment transaction status',
144
+ summary: 'Payment transaction status',
64
145
  methods: {
65
146
  GET: {
66
- summary: 'Get transaction status',
147
+ summary: 'Get stored transaction status',
67
148
  tags: [paymentGatewaysTag],
68
149
  responses: [
69
- { status: 200, description: 'Transaction status' },
150
+ { status: 200, description: 'Stored transaction status' },
70
151
  { status: 400, description: 'Missing or malformed transactionId' },
71
152
  { status: 404, description: 'Transaction not found' },
72
153
  ],
73
154
  },
155
+ POST: {
156
+ summary: 'Refresh transaction status from the provider',
157
+ tags: [paymentGatewaysTag],
158
+ responses: [
159
+ { status: 200, description: 'Refreshed transaction status' },
160
+ { status: 401, description: 'Unauthorized' },
161
+ { status: 404, description: 'Transaction not found' },
162
+ { status: 422, description: 'Missing or malformed payload' },
163
+ { status: 502, description: 'Gateway provider error' },
164
+ ],
165
+ },
74
166
  },
75
167
  }