@open-mercato/core 0.6.6-develop.6229.1.ebe6706732 → 0.6.6-develop.6245.1.2be3b151f8

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 (137) 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/customers/components/calendar/CalendarToolbar.js +4 -6
  22. package/dist/modules/customers/components/calendar/CalendarToolbar.js.map +2 -2
  23. package/dist/modules/customers/components/calendar/EventBlock.js +2 -6
  24. package/dist/modules/customers/components/calendar/EventBlock.js.map +2 -2
  25. package/dist/modules/customers/components/calendar/UpcomingCards.js +9 -7
  26. package/dist/modules/customers/components/calendar/UpcomingCards.js.map +2 -2
  27. package/dist/modules/customers/lib/calendar/format.js +27 -0
  28. package/dist/modules/customers/lib/calendar/format.js.map +7 -0
  29. package/dist/modules/entities/api/encryption.js +109 -38
  30. package/dist/modules/entities/api/encryption.js.map +2 -2
  31. package/dist/modules/entities/components/EncryptionManager.js +22 -9
  32. package/dist/modules/entities/components/EncryptionManager.js.map +2 -2
  33. package/dist/modules/integrations/api/[id]/credentials/route.js +23 -1
  34. package/dist/modules/integrations/api/[id]/credentials/route.js.map +2 -2
  35. package/dist/modules/integrations/api/[id]/route.js +10 -3
  36. package/dist/modules/integrations/api/[id]/route.js.map +2 -2
  37. package/dist/modules/integrations/api/[id]/state/route.js +20 -5
  38. package/dist/modules/integrations/api/[id]/state/route.js.map +2 -2
  39. package/dist/modules/integrations/api/[id]/version/route.js +17 -1
  40. package/dist/modules/integrations/api/[id]/version/route.js.map +2 -2
  41. package/dist/modules/integrations/api/route.js +2 -0
  42. package/dist/modules/integrations/api/route.js.map +2 -2
  43. package/dist/modules/integrations/backend/integrations/[id]/page.js +36 -19
  44. package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
  45. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js +13 -9
  46. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js.map +2 -2
  47. package/dist/modules/integrations/backend/integrations/page.js +4 -3
  48. package/dist/modules/integrations/backend/integrations/page.js.map +2 -2
  49. package/dist/modules/integrations/data/entities.js +2 -2
  50. package/dist/modules/integrations/data/entities.js.map +2 -2
  51. package/dist/modules/integrations/lib/credentials-service.js +32 -0
  52. package/dist/modules/integrations/lib/credentials-service.js.map +2 -2
  53. package/dist/modules/integrations/lib/state-service.js +4 -2
  54. package/dist/modules/integrations/lib/state-service.js.map +2 -2
  55. package/dist/modules/notifications/lib/notificationService.js +42 -10
  56. package/dist/modules/notifications/lib/notificationService.js.map +2 -2
  57. package/dist/modules/payment_gateways/api/status/route.js +91 -6
  58. package/dist/modules/payment_gateways/api/status/route.js.map +2 -2
  59. package/dist/modules/payment_gateways/backend/payment-gateways/page.js +25 -11
  60. package/dist/modules/payment_gateways/backend/payment-gateways/page.js.map +2 -2
  61. package/dist/modules/sales/backend/sales/channels/offers/page.js +25 -7
  62. package/dist/modules/sales/backend/sales/channels/offers/page.js.map +2 -2
  63. package/dist/modules/sales/backend/sales/channels/page.js +25 -7
  64. package/dist/modules/sales/backend/sales/channels/page.js.map +2 -2
  65. package/dist/modules/sales/components/AdjustmentKindSettings.js +52 -24
  66. package/dist/modules/sales/components/AdjustmentKindSettings.js.map +2 -2
  67. package/dist/modules/sales/components/DocumentNumberSettings.js +26 -9
  68. package/dist/modules/sales/components/DocumentNumberSettings.js.map +2 -2
  69. package/dist/modules/sales/components/OrderEditingSettings.js +26 -9
  70. package/dist/modules/sales/components/OrderEditingSettings.js.map +2 -2
  71. package/dist/modules/sales/components/StatusSettings.js +66 -31
  72. package/dist/modules/sales/components/StatusSettings.js.map +2 -2
  73. package/dist/modules/sales/components/channels/SalesChannelOffersPanel.js +25 -7
  74. package/dist/modules/sales/components/channels/SalesChannelOffersPanel.js.map +2 -2
  75. package/dist/modules/shipping_carriers/api/cancel/route.js +38 -0
  76. package/dist/modules/shipping_carriers/api/cancel/route.js.map +2 -2
  77. package/dist/modules/shipping_carriers/api/shipments/route.js +12 -5
  78. package/dist/modules/shipping_carriers/api/shipments/route.js.map +2 -2
  79. package/dist/modules/staff/lib/timesheets-ui/TimerBar.js +46 -13
  80. package/dist/modules/staff/lib/timesheets-ui/TimerBar.js.map +2 -2
  81. package/dist/modules/staff/lib/timesheets-ui/timerErrors.js +12 -0
  82. package/dist/modules/staff/lib/timesheets-ui/timerErrors.js.map +7 -0
  83. package/dist/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.js +43 -8
  84. package/dist/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.js.map +2 -2
  85. package/dist/modules/workflows/backend/definitions/page.js +19 -10
  86. package/dist/modules/workflows/backend/definitions/page.js.map +2 -2
  87. package/package.json +7 -7
  88. package/src/modules/auth/commands/users.ts +1 -0
  89. package/src/modules/communication_channels/data/enrichers.ts +25 -0
  90. package/src/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.client.tsx +7 -6
  91. package/src/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.ts +5 -2
  92. package/src/modules/customers/api/pipeline-stages/reorder/route.ts +41 -1
  93. package/src/modules/customers/api/pipeline-stages/route.ts +112 -13
  94. package/src/modules/customers/api/pipelines/route.ts +112 -13
  95. package/src/modules/customers/api/settings/address-format/route.ts +36 -1
  96. package/src/modules/customers/api/tags/assign/route.ts +39 -0
  97. package/src/modules/customers/api/tags/unassign/route.ts +39 -0
  98. package/src/modules/customers/components/calendar/CalendarToolbar.tsx +4 -7
  99. package/src/modules/customers/components/calendar/EventBlock.tsx +2 -6
  100. package/src/modules/customers/components/calendar/UpcomingCards.tsx +9 -7
  101. package/src/modules/customers/i18n/de.json +1 -0
  102. package/src/modules/customers/i18n/en.json +1 -0
  103. package/src/modules/customers/i18n/es.json +1 -0
  104. package/src/modules/customers/i18n/pl.json +1 -0
  105. package/src/modules/customers/lib/calendar/format.ts +25 -0
  106. package/src/modules/entities/api/encryption.ts +122 -39
  107. package/src/modules/entities/components/EncryptionManager.tsx +28 -9
  108. package/src/modules/integrations/api/[id]/credentials/route.ts +23 -0
  109. package/src/modules/integrations/api/[id]/route.ts +8 -1
  110. package/src/modules/integrations/api/[id]/state/route.ts +20 -4
  111. package/src/modules/integrations/api/[id]/version/route.ts +18 -1
  112. package/src/modules/integrations/api/route.ts +3 -0
  113. package/src/modules/integrations/backend/integrations/[id]/page.tsx +39 -20
  114. package/src/modules/integrations/backend/integrations/bundle/[id]/page.tsx +19 -11
  115. package/src/modules/integrations/backend/integrations/page.tsx +8 -5
  116. package/src/modules/integrations/data/entities.ts +2 -2
  117. package/src/modules/integrations/lib/credentials-service.ts +35 -0
  118. package/src/modules/integrations/lib/state-service.ts +3 -0
  119. package/src/modules/notifications/lib/notificationService.ts +74 -11
  120. package/src/modules/payment_gateways/api/status/route.ts +97 -5
  121. package/src/modules/payment_gateways/backend/payment-gateways/page.tsx +27 -11
  122. package/src/modules/sales/backend/sales/channels/offers/page.tsx +31 -7
  123. package/src/modules/sales/backend/sales/channels/page.tsx +31 -7
  124. package/src/modules/sales/components/AdjustmentKindSettings.tsx +60 -24
  125. package/src/modules/sales/components/DocumentNumberSettings.tsx +32 -10
  126. package/src/modules/sales/components/OrderEditingSettings.tsx +32 -10
  127. package/src/modules/sales/components/StatusSettings.tsx +74 -33
  128. package/src/modules/sales/components/channels/SalesChannelOffersPanel.tsx +30 -6
  129. package/src/modules/shipping_carriers/api/cancel/route.ts +46 -0
  130. package/src/modules/shipping_carriers/api/shipments/route.ts +21 -6
  131. package/src/modules/staff/i18n/de.json +5 -5
  132. package/src/modules/staff/i18n/es.json +5 -5
  133. package/src/modules/staff/i18n/pl.json +5 -5
  134. package/src/modules/staff/lib/timesheets-ui/TimerBar.tsx +57 -13
  135. package/src/modules/staff/lib/timesheets-ui/timerErrors.ts +23 -0
  136. package/src/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.tsx +57 -9
  137. package/src/modules/workflows/backend/definitions/page.tsx +19 -10
@@ -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
  }
@@ -290,19 +290,35 @@ export default function PaymentTransactionsPage() {
290
290
  const handleRefreshStatus = React.useCallback(async () => {
291
291
  if (!selectedId) return
292
292
  setIsRefreshingStatus(true)
293
- const call = await apiCall(`/api/payment_gateways/status?transactionId=${encodeURIComponent(selectedId)}`, undefined, { fallback: null })
294
- if (!call.ok) {
295
- flash(t('payment_gateways.transactions.error.refreshStatus', 'Failed to refresh transaction status'), 'error')
293
+ try {
294
+ await runMutation({
295
+ operation: async () => {
296
+ await apiCallOrThrow('/api/payment_gateways/status', {
297
+ method: 'POST',
298
+ headers: { 'content-type': 'application/json' },
299
+ body: JSON.stringify({ transactionId: selectedId }),
300
+ })
301
+ },
302
+ context: {
303
+ entityType: 'payment_gateways:gateway_transaction',
304
+ entityId: selectedId,
305
+ },
306
+ mutationPayload: { transactionId: selectedId },
307
+ })
308
+ await Promise.all([
309
+ loadRows(),
310
+ loadDetail(selectedId),
311
+ ])
312
+ flash(t('payment_gateways.transactions.success.refreshStatus', 'Transaction status refreshed'), 'success')
313
+ } catch (error) {
314
+ const message = error instanceof Error && error.message
315
+ ? error.message
316
+ : t('payment_gateways.transactions.error.refreshStatus', 'Failed to refresh transaction status')
317
+ flash(message, 'error')
318
+ } finally {
296
319
  setIsRefreshingStatus(false)
297
- return
298
320
  }
299
- await Promise.all([
300
- loadRows(),
301
- loadDetail(selectedId),
302
- ])
303
- flash(t('payment_gateways.transactions.success.refreshStatus', 'Transaction status refreshed'), 'success')
304
- setIsRefreshingStatus(false)
305
- }, [loadDetail, loadRows, selectedId, t])
321
+ }, [loadDetail, loadRows, runMutation, selectedId, t])
306
322
 
307
323
  const handleCapturePayment = React.useCallback(async () => {
308
324
  if (!selectedId) return
@@ -14,6 +14,7 @@ import { readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato
14
14
  import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
15
15
  import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
16
16
  import { deleteCrud } from '@open-mercato/ui/backend/utils/crud'
17
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
17
18
  import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
18
19
  import { useT } from '@open-mercato/shared/lib/i18n/context'
19
20
  import { mapOfferRow, renderOfferPriceSummary, type OfferRow } from '@open-mercato/core/modules/sales/components/channels/offerTableUtils'
@@ -26,9 +27,27 @@ type OffersResponse = {
26
27
 
27
28
  const PAGE_SIZE = 25
28
29
 
30
+ const SAVE_CONTEXT_ID = 'sales-channel-offers-list'
31
+
29
32
  export default function SalesChannelOffersListPage() {
30
33
  const t = useT()
31
34
  const router = useRouter()
35
+ const { runMutation, retryLastMutation } = useGuardedMutation<{
36
+ formId: string
37
+ resourceKind: string
38
+ retryLastMutation: () => Promise<boolean>
39
+ }>({
40
+ contextId: SAVE_CONTEXT_ID,
41
+ blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
42
+ })
43
+ const mutationContext = React.useMemo(
44
+ () => ({
45
+ formId: SAVE_CONTEXT_ID,
46
+ resourceKind: 'sales.channels.offers',
47
+ retryLastMutation,
48
+ }),
49
+ [retryLastMutation],
50
+ )
32
51
  const scopeVersion = useOrganizationScopeVersion()
33
52
  const channelOptionsRef = React.useRef<Map<string, FilterOption>>(new Map())
34
53
  const [rows, setRows] = React.useState<OfferRow[]>([])
@@ -282,19 +301,24 @@ export default function SalesChannelOffersListPage() {
282
301
 
283
302
  const handleDelete = React.useCallback(async (row: OfferRow) => {
284
303
  try {
285
- await withScopedApiRequestHeaders(
286
- buildOptimisticLockHeader(row.updatedAt),
287
- () => deleteCrud('catalog/offers', row.id, {
288
- errorMessage: t('sales.channels.offers.errors.delete', 'Failed to delete offer.'),
289
- }),
290
- )
304
+ await runMutation({
305
+ operation: () =>
306
+ withScopedApiRequestHeaders(
307
+ buildOptimisticLockHeader(row.updatedAt),
308
+ () => deleteCrud('catalog/offers', row.id, {
309
+ errorMessage: t('sales.channels.offers.errors.delete', 'Failed to delete offer.'),
310
+ }),
311
+ ),
312
+ context: mutationContext,
313
+ mutationPayload: { action: 'delete', id: row.id },
314
+ })
291
315
  flash(t('sales.channels.offers.messages.deleted', 'Offer deleted.'), 'success')
292
316
  handleRefresh()
293
317
  } catch (err) {
294
318
  if (surfaceRecordConflict(err, t)) { handleRefresh(); return }
295
319
  console.error('sales.channels.offers.delete', err)
296
320
  }
297
- }, [handleRefresh, t])
321
+ }, [handleRefresh, mutationContext, runMutation, t])
298
322
 
299
323
  const tableTitle = (
300
324
  <div className="flex flex-col gap-1">