@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/auth/commands/users.js +1 -0
- package/dist/modules/auth/commands/users.js.map +2 -2
- package/dist/modules/communication_channels/data/enrichers.js +9 -1
- package/dist/modules/communication_channels/data/enrichers.js.map +2 -2
- package/dist/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.client.js +3 -5
- package/dist/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.client.js.map +2 -2
- package/dist/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.js.map +2 -2
- package/dist/modules/customers/api/pipeline-stages/reorder/route.js +39 -1
- package/dist/modules/customers/api/pipeline-stages/reorder/route.js.map +2 -2
- package/dist/modules/customers/api/pipeline-stages/route.js +107 -12
- package/dist/modules/customers/api/pipeline-stages/route.js.map +2 -2
- package/dist/modules/customers/api/pipelines/route.js +107 -12
- package/dist/modules/customers/api/pipelines/route.js.map +2 -2
- package/dist/modules/customers/api/settings/address-format/route.js +33 -1
- package/dist/modules/customers/api/settings/address-format/route.js.map +2 -2
- package/dist/modules/customers/api/tags/assign/route.js +37 -0
- package/dist/modules/customers/api/tags/assign/route.js.map +2 -2
- package/dist/modules/customers/api/tags/unassign/route.js +37 -0
- package/dist/modules/customers/api/tags/unassign/route.js.map +2 -2
- package/dist/modules/customers/components/calendar/CalendarToolbar.js +4 -6
- package/dist/modules/customers/components/calendar/CalendarToolbar.js.map +2 -2
- package/dist/modules/customers/components/calendar/EventBlock.js +2 -6
- package/dist/modules/customers/components/calendar/EventBlock.js.map +2 -2
- package/dist/modules/customers/components/calendar/UpcomingCards.js +9 -7
- package/dist/modules/customers/components/calendar/UpcomingCards.js.map +2 -2
- package/dist/modules/customers/lib/calendar/format.js +27 -0
- package/dist/modules/customers/lib/calendar/format.js.map +7 -0
- package/dist/modules/entities/api/encryption.js +109 -38
- package/dist/modules/entities/api/encryption.js.map +2 -2
- package/dist/modules/entities/components/EncryptionManager.js +22 -9
- package/dist/modules/entities/components/EncryptionManager.js.map +2 -2
- package/dist/modules/integrations/api/[id]/credentials/route.js +23 -1
- package/dist/modules/integrations/api/[id]/credentials/route.js.map +2 -2
- package/dist/modules/integrations/api/[id]/route.js +10 -3
- package/dist/modules/integrations/api/[id]/route.js.map +2 -2
- package/dist/modules/integrations/api/[id]/state/route.js +20 -5
- package/dist/modules/integrations/api/[id]/state/route.js.map +2 -2
- package/dist/modules/integrations/api/[id]/version/route.js +17 -1
- package/dist/modules/integrations/api/[id]/version/route.js.map +2 -2
- package/dist/modules/integrations/api/route.js +2 -0
- package/dist/modules/integrations/api/route.js.map +2 -2
- package/dist/modules/integrations/backend/integrations/[id]/page.js +36 -19
- package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
- package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js +13 -9
- package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js.map +2 -2
- package/dist/modules/integrations/backend/integrations/page.js +4 -3
- package/dist/modules/integrations/backend/integrations/page.js.map +2 -2
- package/dist/modules/integrations/data/entities.js +2 -2
- package/dist/modules/integrations/data/entities.js.map +2 -2
- package/dist/modules/integrations/lib/credentials-service.js +32 -0
- package/dist/modules/integrations/lib/credentials-service.js.map +2 -2
- package/dist/modules/integrations/lib/state-service.js +4 -2
- package/dist/modules/integrations/lib/state-service.js.map +2 -2
- package/dist/modules/notifications/lib/notificationService.js +42 -10
- package/dist/modules/notifications/lib/notificationService.js.map +2 -2
- package/dist/modules/payment_gateways/api/status/route.js +91 -6
- package/dist/modules/payment_gateways/api/status/route.js.map +2 -2
- package/dist/modules/payment_gateways/backend/payment-gateways/page.js +25 -11
- package/dist/modules/payment_gateways/backend/payment-gateways/page.js.map +2 -2
- package/dist/modules/sales/backend/sales/channels/offers/page.js +25 -7
- package/dist/modules/sales/backend/sales/channels/offers/page.js.map +2 -2
- package/dist/modules/sales/backend/sales/channels/page.js +25 -7
- package/dist/modules/sales/backend/sales/channels/page.js.map +2 -2
- package/dist/modules/sales/components/AdjustmentKindSettings.js +52 -24
- package/dist/modules/sales/components/AdjustmentKindSettings.js.map +2 -2
- package/dist/modules/sales/components/DocumentNumberSettings.js +26 -9
- package/dist/modules/sales/components/DocumentNumberSettings.js.map +2 -2
- package/dist/modules/sales/components/OrderEditingSettings.js +26 -9
- package/dist/modules/sales/components/OrderEditingSettings.js.map +2 -2
- package/dist/modules/sales/components/StatusSettings.js +66 -31
- package/dist/modules/sales/components/StatusSettings.js.map +2 -2
- package/dist/modules/sales/components/channels/SalesChannelOffersPanel.js +25 -7
- package/dist/modules/sales/components/channels/SalesChannelOffersPanel.js.map +2 -2
- package/dist/modules/shipping_carriers/api/cancel/route.js +38 -0
- package/dist/modules/shipping_carriers/api/cancel/route.js.map +2 -2
- package/dist/modules/shipping_carriers/api/shipments/route.js +12 -5
- package/dist/modules/shipping_carriers/api/shipments/route.js.map +2 -2
- package/dist/modules/staff/lib/timesheets-ui/TimerBar.js +46 -13
- package/dist/modules/staff/lib/timesheets-ui/TimerBar.js.map +2 -2
- package/dist/modules/staff/lib/timesheets-ui/timerErrors.js +12 -0
- package/dist/modules/staff/lib/timesheets-ui/timerErrors.js.map +7 -0
- package/dist/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.js +43 -8
- package/dist/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.js.map +2 -2
- package/dist/modules/workflows/backend/definitions/page.js +19 -10
- package/dist/modules/workflows/backend/definitions/page.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/auth/commands/users.ts +1 -0
- package/src/modules/communication_channels/data/enrichers.ts +25 -0
- package/src/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.client.tsx +7 -6
- package/src/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.ts +5 -2
- package/src/modules/customers/api/pipeline-stages/reorder/route.ts +41 -1
- package/src/modules/customers/api/pipeline-stages/route.ts +112 -13
- package/src/modules/customers/api/pipelines/route.ts +112 -13
- package/src/modules/customers/api/settings/address-format/route.ts +36 -1
- package/src/modules/customers/api/tags/assign/route.ts +39 -0
- package/src/modules/customers/api/tags/unassign/route.ts +39 -0
- package/src/modules/customers/components/calendar/CalendarToolbar.tsx +4 -7
- package/src/modules/customers/components/calendar/EventBlock.tsx +2 -6
- package/src/modules/customers/components/calendar/UpcomingCards.tsx +9 -7
- package/src/modules/customers/i18n/de.json +1 -0
- package/src/modules/customers/i18n/en.json +1 -0
- package/src/modules/customers/i18n/es.json +1 -0
- package/src/modules/customers/i18n/pl.json +1 -0
- package/src/modules/customers/lib/calendar/format.ts +25 -0
- package/src/modules/entities/api/encryption.ts +122 -39
- package/src/modules/entities/components/EncryptionManager.tsx +28 -9
- package/src/modules/integrations/api/[id]/credentials/route.ts +23 -0
- package/src/modules/integrations/api/[id]/route.ts +8 -1
- package/src/modules/integrations/api/[id]/state/route.ts +20 -4
- package/src/modules/integrations/api/[id]/version/route.ts +18 -1
- package/src/modules/integrations/api/route.ts +3 -0
- package/src/modules/integrations/backend/integrations/[id]/page.tsx +39 -20
- package/src/modules/integrations/backend/integrations/bundle/[id]/page.tsx +19 -11
- package/src/modules/integrations/backend/integrations/page.tsx +8 -5
- package/src/modules/integrations/data/entities.ts +2 -2
- package/src/modules/integrations/lib/credentials-service.ts +35 -0
- package/src/modules/integrations/lib/state-service.ts +3 -0
- package/src/modules/notifications/lib/notificationService.ts +74 -11
- package/src/modules/payment_gateways/api/status/route.ts +97 -5
- package/src/modules/payment_gateways/backend/payment-gateways/page.tsx +27 -11
- package/src/modules/sales/backend/sales/channels/offers/page.tsx +31 -7
- package/src/modules/sales/backend/sales/channels/page.tsx +31 -7
- package/src/modules/sales/components/AdjustmentKindSettings.tsx +60 -24
- package/src/modules/sales/components/DocumentNumberSettings.tsx +32 -10
- package/src/modules/sales/components/OrderEditingSettings.tsx +32 -10
- package/src/modules/sales/components/StatusSettings.tsx +74 -33
- package/src/modules/sales/components/channels/SalesChannelOffersPanel.tsx +30 -6
- package/src/modules/shipping_carriers/api/cancel/route.ts +46 -0
- package/src/modules/shipping_carriers/api/shipments/route.ts +21 -6
- package/src/modules/staff/i18n/de.json +5 -5
- package/src/modules/staff/i18n/es.json +5 -5
- package/src/modules/staff/i18n/pl.json +5 -5
- package/src/modules/staff/lib/timesheets-ui/TimerBar.tsx +57 -13
- package/src/modules/staff/lib/timesheets-ui/timerErrors.ts +23 -0
- package/src/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.tsx +57 -9
- 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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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 =
|
|
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
|
|
57
|
-
return NextResponse.json({ error: message }, { status:
|
|
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: '
|
|
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: '
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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">
|