@open-mercato/core 0.6.2-develop.3330.1.8ad2b3b84c → 0.6.2-develop.3363.1.e23f7084bd
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 +2 -2
- package/dist/generated/entities/sync_excel_upload/index.js +37 -0
- package/dist/generated/entities/sync_excel_upload/index.js.map +7 -0
- package/dist/generated/entities.ids.generated.js +4 -0
- package/dist/generated/entities.ids.generated.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +19 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/customers/search.js +3 -2
- package/dist/modules/customers/search.js.map +2 -2
- package/dist/modules/data_sync/api/runs/[id]/cancel.js +16 -0
- package/dist/modules/data_sync/api/runs/[id]/cancel.js.map +2 -2
- package/dist/modules/data_sync/di.js +2 -1
- package/dist/modules/data_sync/di.js.map +2 -2
- package/dist/modules/data_sync/lib/sync-engine.js +178 -54
- package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
- package/dist/modules/data_sync/lib/sync-run-service.js +23 -0
- package/dist/modules/data_sync/lib/sync-run-service.js.map +3 -3
- package/dist/modules/integrations/backend/integrations/[id]/page.js +241 -55
- package/dist/modules/integrations/backend/integrations/[id]/page.js.map +3 -3
- package/dist/modules/sync_excel/acl.js +10 -0
- package/dist/modules/sync_excel/acl.js.map +7 -0
- package/dist/modules/sync_excel/api/import/route.js +164 -0
- package/dist/modules/sync_excel/api/import/route.js.map +7 -0
- package/dist/modules/sync_excel/api/preview/route.js +84 -0
- package/dist/modules/sync_excel/api/preview/route.js.map +7 -0
- package/dist/modules/sync_excel/api/upload/route.js +131 -0
- package/dist/modules/sync_excel/api/upload/route.js.map +7 -0
- package/dist/modules/sync_excel/data/entities.js +79 -0
- package/dist/modules/sync_excel/data/entities.js.map +7 -0
- package/dist/modules/sync_excel/data/validators.js +58 -0
- package/dist/modules/sync_excel/data/validators.js.map +7 -0
- package/dist/modules/sync_excel/di.js +11 -0
- package/dist/modules/sync_excel/di.js.map +7 -0
- package/dist/modules/sync_excel/index.js +11 -0
- package/dist/modules/sync_excel/index.js.map +7 -0
- package/dist/modules/sync_excel/integration.js +36 -0
- package/dist/modules/sync_excel/integration.js.map +7 -0
- package/dist/modules/sync_excel/lib/adapters/customers.js +852 -0
- package/dist/modules/sync_excel/lib/adapters/customers.js.map +7 -0
- package/dist/modules/sync_excel/lib/column-detector.js +77 -0
- package/dist/modules/sync_excel/lib/column-detector.js.map +7 -0
- package/dist/modules/sync_excel/lib/mapping.js +16 -0
- package/dist/modules/sync_excel/lib/mapping.js.map +7 -0
- package/dist/modules/sync_excel/lib/parser.js +125 -0
- package/dist/modules/sync_excel/lib/parser.js.map +7 -0
- package/dist/modules/sync_excel/lib/scope.js +44 -0
- package/dist/modules/sync_excel/lib/scope.js.map +7 -0
- package/dist/modules/sync_excel/lib/upload-storage.js +63 -0
- package/dist/modules/sync_excel/lib/upload-storage.js.map +7 -0
- package/dist/modules/sync_excel/migrations/Migration20260329181120_sync_excel.js +11 -0
- package/dist/modules/sync_excel/migrations/Migration20260329181120_sync_excel.js.map +7 -0
- package/dist/modules/sync_excel/setup.js +19 -0
- package/dist/modules/sync_excel/setup.js.map +7 -0
- package/dist/modules/sync_excel/widgets/injection/upload-config/target-options.js +293 -0
- package/dist/modules/sync_excel/widgets/injection/upload-config/target-options.js.map +7 -0
- package/dist/modules/sync_excel/widgets/injection/upload-config/widget.client.js +934 -0
- package/dist/modules/sync_excel/widgets/injection/upload-config/widget.client.js.map +7 -0
- package/dist/modules/sync_excel/widgets/injection/upload-config/widget.js +15 -0
- package/dist/modules/sync_excel/widgets/injection/upload-config/widget.js.map +7 -0
- package/dist/modules/sync_excel/widgets/injection-table.js +17 -0
- package/dist/modules/sync_excel/widgets/injection-table.js.map +7 -0
- package/generated/entities/sync_excel_upload/index.ts +17 -0
- package/generated/entities.ids.generated.ts +4 -0
- package/generated/entity-fields-registry.ts +19 -0
- package/package.json +7 -7
- package/src/modules/customers/search.ts +3 -2
- package/src/modules/data_sync/api/runs/[id]/cancel.ts +18 -0
- package/src/modules/data_sync/di.ts +4 -1
- package/src/modules/data_sync/lib/adapter.ts +15 -0
- package/src/modules/data_sync/lib/sync-engine.ts +205 -52
- package/src/modules/data_sync/lib/sync-run-service.ts +24 -0
- package/src/modules/integrations/backend/integrations/[id]/page.tsx +220 -15
- package/src/modules/integrations/i18n/de.json +9 -0
- package/src/modules/integrations/i18n/en.json +9 -0
- package/src/modules/integrations/i18n/es.json +9 -0
- package/src/modules/integrations/i18n/pl.json +9 -0
- package/src/modules/sync_excel/acl.ts +6 -0
- package/src/modules/sync_excel/api/import/route.ts +182 -0
- package/src/modules/sync_excel/api/preview/route.ts +89 -0
- package/src/modules/sync_excel/api/upload/route.ts +144 -0
- package/src/modules/sync_excel/data/entities.ts +61 -0
- package/src/modules/sync_excel/data/validators.ts +58 -0
- package/src/modules/sync_excel/di.ts +9 -0
- package/src/modules/sync_excel/i18n/de.json +107 -0
- package/src/modules/sync_excel/i18n/en.json +107 -0
- package/src/modules/sync_excel/i18n/es.json +107 -0
- package/src/modules/sync_excel/i18n/pl.json +107 -0
- package/src/modules/sync_excel/index.ts +7 -0
- package/src/modules/sync_excel/integration.ts +31 -0
- package/src/modules/sync_excel/lib/adapters/customers.ts +1097 -0
- package/src/modules/sync_excel/lib/column-detector.ts +91 -0
- package/src/modules/sync_excel/lib/mapping.ts +19 -0
- package/src/modules/sync_excel/lib/parser.ts +165 -0
- package/src/modules/sync_excel/lib/scope.ts +63 -0
- package/src/modules/sync_excel/lib/upload-storage.ts +79 -0
- package/src/modules/sync_excel/migrations/.snapshot-open-mercato.json +199 -0
- package/src/modules/sync_excel/migrations/Migration20260329181120_sync_excel.ts +10 -0
- package/src/modules/sync_excel/setup.ts +20 -0
- package/src/modules/sync_excel/widgets/injection/upload-config/target-options.ts +351 -0
- package/src/modules/sync_excel/widgets/injection/upload-config/widget.client.tsx +1224 -0
- package/src/modules/sync_excel/widgets/injection/upload-config/widget.ts +30 -0
- package/src/modules/sync_excel/widgets/injection-table.ts +15 -0
|
@@ -132,6 +132,27 @@ type HealthCheckResponse = {
|
|
|
132
132
|
latencyMs: number | null
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
type DataSyncRunDetail = {
|
|
136
|
+
id: string
|
|
137
|
+
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
|
|
138
|
+
progressJobId?: string | null
|
|
139
|
+
createdCount?: number
|
|
140
|
+
updatedCount?: number
|
|
141
|
+
skippedCount?: number
|
|
142
|
+
failedCount?: number
|
|
143
|
+
progressJob?: {
|
|
144
|
+
progressPercent?: number | null
|
|
145
|
+
processedCount?: number | null
|
|
146
|
+
totalCount?: number | null
|
|
147
|
+
} | null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const LOG_LEVEL_STYLES: Record<string, string> = {
|
|
151
|
+
info: 'bg-blue-100 text-blue-800',
|
|
152
|
+
warn: 'bg-yellow-100 text-yellow-800',
|
|
153
|
+
error: 'bg-red-100 text-red-800',
|
|
154
|
+
}
|
|
155
|
+
|
|
135
156
|
const HEALTH_STATUS_STYLES: Record<string, string> = {
|
|
136
157
|
healthy: 'bg-green-100 text-green-800',
|
|
137
158
|
degraded: 'bg-yellow-100 text-yellow-800',
|
|
@@ -146,6 +167,74 @@ const HEALTH_STATUS_ICONS: Record<string, React.ElementType> = {
|
|
|
146
167
|
unconfigured: AlertTriangle,
|
|
147
168
|
}
|
|
148
169
|
|
|
170
|
+
function formatRunStatusLabel(status: DataSyncRunDetail['status'], t: ReturnType<typeof useT>): string {
|
|
171
|
+
switch (status) {
|
|
172
|
+
case 'pending':
|
|
173
|
+
return t('integrations.detail.runActivity.status.pending', 'Pending')
|
|
174
|
+
case 'running':
|
|
175
|
+
return t('integrations.detail.runActivity.status.running', 'Running')
|
|
176
|
+
case 'completed':
|
|
177
|
+
return t('integrations.detail.runActivity.status.completed', 'Completed')
|
|
178
|
+
case 'failed':
|
|
179
|
+
return t('integrations.detail.runActivity.status.failed', 'Failed')
|
|
180
|
+
case 'cancelled':
|
|
181
|
+
return t('integrations.detail.runActivity.status.cancelled', 'Cancelled')
|
|
182
|
+
default:
|
|
183
|
+
return status
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function RunActivityStrip({
|
|
188
|
+
run,
|
|
189
|
+
refreshedAt,
|
|
190
|
+
isRefreshing,
|
|
191
|
+
onRefresh,
|
|
192
|
+
t,
|
|
193
|
+
}: {
|
|
194
|
+
run: DataSyncRunDetail | null
|
|
195
|
+
refreshedAt: string | null
|
|
196
|
+
isRefreshing: boolean
|
|
197
|
+
onRefresh: () => void
|
|
198
|
+
t: ReturnType<typeof useT>
|
|
199
|
+
}) {
|
|
200
|
+
if (!run) return null
|
|
201
|
+
|
|
202
|
+
const progress = typeof run.progressJob?.progressPercent === 'number' ? run.progressJob.progressPercent : 0
|
|
203
|
+
const processed = typeof run.progressJob?.processedCount === 'number' ? run.progressJob.processedCount : 0
|
|
204
|
+
const total = typeof run.progressJob?.totalCount === 'number' ? run.progressJob.totalCount : null
|
|
205
|
+
const statusClass = run.status === 'completed'
|
|
206
|
+
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
|
|
207
|
+
: run.status === 'failed'
|
|
208
|
+
? 'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300'
|
|
209
|
+
: run.status === 'cancelled'
|
|
210
|
+
? 'border-zinc-500/30 bg-zinc-500/10 text-zinc-700 dark:text-zinc-300'
|
|
211
|
+
: 'border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-300'
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
<div className="flex flex-wrap items-center gap-3 rounded-lg border bg-muted/20 px-4 py-3 text-sm">
|
|
215
|
+
<Badge variant="outline" className={cn('gap-1.5', statusClass)}>
|
|
216
|
+
<RefreshCw className={cn('h-3.5 w-3.5', run.status === 'running' ? 'animate-spin' : '')} />
|
|
217
|
+
{formatRunStatusLabel(run.status, t)}
|
|
218
|
+
</Badge>
|
|
219
|
+
<span className="text-muted-foreground">
|
|
220
|
+
{t('integrations.detail.runActivity.processed', 'Processed')}: <span className="font-medium text-foreground">{processed}{total !== null ? ` / ${total}` : ''}</span>
|
|
221
|
+
</span>
|
|
222
|
+
<span className="text-muted-foreground">
|
|
223
|
+
{t('integrations.detail.runActivity.progress', 'Progress')}: <span className="font-medium text-foreground">{progress}%</span>
|
|
224
|
+
</span>
|
|
225
|
+
{refreshedAt ? (
|
|
226
|
+
<span className="text-muted-foreground">
|
|
227
|
+
{t('integrations.detail.runActivity.lastRefreshed', 'Last refreshed')}: {new Date(refreshedAt).toLocaleTimeString()}
|
|
228
|
+
</span>
|
|
229
|
+
) : null}
|
|
230
|
+
<Button type="button" variant="outline" size="sm" className="ml-auto" onClick={onRefresh} disabled={isRefreshing}>
|
|
231
|
+
{isRefreshing ? <Spinner className="mr-2 h-4 w-4" /> : <RefreshCw className="mr-2 h-4 w-4" />}
|
|
232
|
+
{t('integrations.detail.runActivity.refresh', 'Refresh')}
|
|
233
|
+
</Button>
|
|
234
|
+
</div>
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
149
238
|
function DetailLogSparkline({ counts, className }: { counts: number[]; className?: string }) {
|
|
150
239
|
const max = Math.max(1, ...counts)
|
|
151
240
|
const w = 120
|
|
@@ -337,6 +426,9 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
|
|
|
337
426
|
const [isCheckingHealth, setIsCheckingHealth] = React.useState(false)
|
|
338
427
|
const [isTogglingState, setIsTogglingState] = React.useState(false)
|
|
339
428
|
const [latestHealthResult, setLatestHealthResult] = React.useState<HealthCheckResponse | null>(null)
|
|
429
|
+
const [activeRunDetail, setActiveRunDetail] = React.useState<DataSyncRunDetail | null>(null)
|
|
430
|
+
const [activeRunRefreshedAt, setActiveRunRefreshedAt] = React.useState<string | null>(null)
|
|
431
|
+
const [isRefreshingRunActivity, setIsRefreshingRunActivity] = React.useState(false)
|
|
340
432
|
const [activeTab, setActiveTab] = React.useState<IntegrationDetailTab>('credentials')
|
|
341
433
|
|
|
342
434
|
const credentialsFormId = React.useId()
|
|
@@ -427,6 +519,35 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
|
|
|
427
519
|
const refreshLogs = React.useCallback(async () => {
|
|
428
520
|
await loadLogs()
|
|
429
521
|
}, [loadLogs])
|
|
522
|
+
const refreshHealthSnapshot = React.useCallback(async () => {
|
|
523
|
+
await loadDetail()
|
|
524
|
+
}, [loadDetail])
|
|
525
|
+
const runIdFromUrl = searchParams?.get('runId') ?? null
|
|
526
|
+
const refreshRunActivity = React.useCallback(async (options?: { showLoading?: boolean }) => {
|
|
527
|
+
if (!runIdFromUrl) {
|
|
528
|
+
setActiveRunDetail(null)
|
|
529
|
+
setActiveRunRefreshedAt(null)
|
|
530
|
+
return null
|
|
531
|
+
}
|
|
532
|
+
if (options?.showLoading) setIsRefreshingRunActivity(true)
|
|
533
|
+
try {
|
|
534
|
+
const call = await apiCall<DataSyncRunDetail>(
|
|
535
|
+
`/api/data_sync/runs/${encodeURIComponent(runIdFromUrl)}`,
|
|
536
|
+
undefined,
|
|
537
|
+
{ fallback: null },
|
|
538
|
+
)
|
|
539
|
+
await loadLogs()
|
|
540
|
+
await loadDetail()
|
|
541
|
+
if (call.ok && call.result) {
|
|
542
|
+
setActiveRunDetail(call.result)
|
|
543
|
+
setActiveRunRefreshedAt(new Date().toISOString())
|
|
544
|
+
return call.result
|
|
545
|
+
}
|
|
546
|
+
return null
|
|
547
|
+
} finally {
|
|
548
|
+
if (options?.showLoading) setIsRefreshingRunActivity(false)
|
|
549
|
+
}
|
|
550
|
+
}, [loadDetail, loadLogs, runIdFromUrl])
|
|
430
551
|
const injectionContext = React.useMemo(
|
|
431
552
|
() => ({
|
|
432
553
|
formId: mutationContextId,
|
|
@@ -443,6 +564,7 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
|
|
|
443
564
|
setActiveTab,
|
|
444
565
|
refreshDetail,
|
|
445
566
|
refreshLogs,
|
|
567
|
+
refreshHealthSnapshot,
|
|
446
568
|
retryLastMutation,
|
|
447
569
|
}),
|
|
448
570
|
[
|
|
@@ -454,6 +576,7 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
|
|
|
454
576
|
latestHealthResult,
|
|
455
577
|
mutationContextId,
|
|
456
578
|
refreshDetail,
|
|
579
|
+
refreshHealthSnapshot,
|
|
457
580
|
refreshLogs,
|
|
458
581
|
retryLastMutation,
|
|
459
582
|
],
|
|
@@ -732,10 +855,21 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
|
|
|
732
855
|
})
|
|
733
856
|
) as z.ZodType<Record<string, unknown>>, [editableCredentialFields, t])
|
|
734
857
|
const latestHealthLog = React.useMemo(() => logs.find(isHealthLog) ?? null, [logs])
|
|
858
|
+
const latestOperationalLog = React.useMemo(
|
|
859
|
+
() => logs.find((log) => (
|
|
860
|
+
typeof log.payload?.operationalStatus === 'string'
|
|
861
|
+
|| typeof log.payload?.summary === 'string'
|
|
862
|
+
)) ?? null,
|
|
863
|
+
[logs],
|
|
864
|
+
)
|
|
735
865
|
const healthMessage =
|
|
736
866
|
latestHealthResult?.message ??
|
|
737
|
-
(typeof latestHealthLog?.payload?.message === 'string'
|
|
738
|
-
|
|
867
|
+
(typeof latestHealthLog?.payload?.message === 'string'
|
|
868
|
+
? latestHealthLog.payload.message
|
|
869
|
+
: typeof latestOperationalLog?.payload?.summary === 'string'
|
|
870
|
+
? latestOperationalLog.payload.summary
|
|
871
|
+
: null)
|
|
872
|
+
const healthDetailsSource = latestHealthResult?.details ?? extractHealthDetails(latestHealthLog?.payload ?? latestOperationalLog?.payload)
|
|
739
873
|
const healthDetails = latestHealthLog?.code
|
|
740
874
|
? { ...healthDetailsSource, code: latestHealthLog.code }
|
|
741
875
|
: healthDetailsSource
|
|
@@ -811,8 +945,43 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
|
|
|
811
945
|
setActiveTab(nextTab)
|
|
812
946
|
if (!currentIntegrationId) return
|
|
813
947
|
const basePath = `/backend/integrations/${encodeURIComponent(currentIntegrationId)}`
|
|
814
|
-
|
|
815
|
-
|
|
948
|
+
const params = new URLSearchParams(searchParams?.toString() ?? '')
|
|
949
|
+
if (nextTab === 'credentials') params.delete('tab')
|
|
950
|
+
else params.set('tab', nextTab)
|
|
951
|
+
const query = params.toString()
|
|
952
|
+
router.replace(query ? `${basePath}?${query}` : basePath)
|
|
953
|
+
}, [resolveCurrentIntegrationId, router, searchParams, visibleTabIds])
|
|
954
|
+
|
|
955
|
+
React.useEffect(() => {
|
|
956
|
+
if (!runIdFromUrl) {
|
|
957
|
+
setActiveRunDetail(null)
|
|
958
|
+
setActiveRunRefreshedAt(null)
|
|
959
|
+
return
|
|
960
|
+
}
|
|
961
|
+
if (activeTab !== 'logs' && activeTab !== 'health') return
|
|
962
|
+
|
|
963
|
+
let cancelled = false
|
|
964
|
+
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
|
965
|
+
|
|
966
|
+
const poll = async () => {
|
|
967
|
+
const detail = await refreshRunActivity()
|
|
968
|
+
if (cancelled) return
|
|
969
|
+
|
|
970
|
+
const status = detail?.status ?? null
|
|
971
|
+
if (status === 'pending' || status === 'running') {
|
|
972
|
+
timeoutId = setTimeout(() => {
|
|
973
|
+
void poll()
|
|
974
|
+
}, 4_000)
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
void poll()
|
|
979
|
+
|
|
980
|
+
return () => {
|
|
981
|
+
cancelled = true
|
|
982
|
+
if (timeoutId) clearTimeout(timeoutId)
|
|
983
|
+
}
|
|
984
|
+
}, [activeTab, refreshRunActivity, runIdFromUrl])
|
|
816
985
|
|
|
817
986
|
if (isLoading) return <Page><PageBody><LoadingMessage label={t('integrations.detail.title')} /></PageBody></Page>
|
|
818
987
|
if (error || !detail || !resolvedIntegration || !resolvedState) {
|
|
@@ -1110,20 +1279,39 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
|
|
|
1110
1279
|
|
|
1111
1280
|
{showHealthTab ? (
|
|
1112
1281
|
<TabsContent value="health" className="mt-0 space-y-4">
|
|
1282
|
+
<RunActivityStrip
|
|
1283
|
+
run={activeRunDetail}
|
|
1284
|
+
refreshedAt={activeRunRefreshedAt}
|
|
1285
|
+
isRefreshing={isRefreshingRunActivity}
|
|
1286
|
+
onRefresh={() => void refreshRunActivity({ showLoading: true })}
|
|
1287
|
+
t={t}
|
|
1288
|
+
/>
|
|
1113
1289
|
<Card className="gap-4 py-4">
|
|
1114
1290
|
<CardHeader className="px-5">
|
|
1115
1291
|
<div className="flex items-center justify-between">
|
|
1116
1292
|
<CardTitle>{t('integrations.detail.health.title')}</CardTitle>
|
|
1117
|
-
<
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1293
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
1294
|
+
<Button
|
|
1295
|
+
type="button"
|
|
1296
|
+
variant="outline"
|
|
1297
|
+
size="sm"
|
|
1298
|
+
onClick={() => void refreshRunActivity({ showLoading: true })}
|
|
1299
|
+
disabled={isRefreshingRunActivity || !runIdFromUrl}
|
|
1300
|
+
>
|
|
1301
|
+
{isRefreshingRunActivity ? <Spinner className="mr-2 h-4 w-4" /> : <RefreshCw className="mr-2 h-4 w-4" />}
|
|
1302
|
+
{t('integrations.detail.runActivity.refresh', 'Refresh')}
|
|
1303
|
+
</Button>
|
|
1304
|
+
<Button
|
|
1305
|
+
type="button"
|
|
1306
|
+
variant="outline"
|
|
1307
|
+
size="sm"
|
|
1308
|
+
onClick={() => void handleHealthCheck()}
|
|
1309
|
+
disabled={isCheckingHealth}
|
|
1310
|
+
>
|
|
1311
|
+
{isCheckingHealth ? <Spinner className="mr-2 h-4 w-4" /> : <Zap className="mr-2 h-4 w-4" />}
|
|
1312
|
+
{isCheckingHealth ? t('integrations.detail.health.checking') : t('integrations.detail.health.check')}
|
|
1313
|
+
</Button>
|
|
1314
|
+
</div>
|
|
1127
1315
|
</div>
|
|
1128
1316
|
</CardHeader>
|
|
1129
1317
|
<CardContent className="space-y-3 px-5">
|
|
@@ -1183,7 +1371,14 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
|
|
|
1183
1371
|
|
|
1184
1372
|
{showLogsTab ? (
|
|
1185
1373
|
<TabsContent value="logs" className="mt-0 space-y-4">
|
|
1186
|
-
<
|
|
1374
|
+
<RunActivityStrip
|
|
1375
|
+
run={activeRunDetail}
|
|
1376
|
+
refreshedAt={activeRunRefreshedAt}
|
|
1377
|
+
isRefreshing={isRefreshingRunActivity}
|
|
1378
|
+
onRefresh={() => void refreshRunActivity({ showLoading: true })}
|
|
1379
|
+
t={t}
|
|
1380
|
+
/>
|
|
1381
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
1187
1382
|
<div className="inline-flex">
|
|
1188
1383
|
<Select
|
|
1189
1384
|
value={logLevel || undefined}
|
|
@@ -1199,6 +1394,16 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
|
|
|
1199
1394
|
</SelectContent>
|
|
1200
1395
|
</Select>
|
|
1201
1396
|
</div>
|
|
1397
|
+
<Button
|
|
1398
|
+
type="button"
|
|
1399
|
+
variant="outline"
|
|
1400
|
+
size="sm"
|
|
1401
|
+
onClick={() => void refreshRunActivity({ showLoading: true })}
|
|
1402
|
+
disabled={isRefreshingRunActivity || !runIdFromUrl}
|
|
1403
|
+
>
|
|
1404
|
+
{isRefreshingRunActivity ? <Spinner className="mr-2 h-4 w-4" /> : <RefreshCw className="mr-2 h-4 w-4" />}
|
|
1405
|
+
{t('integrations.detail.runActivity.refresh', 'Refresh')}
|
|
1406
|
+
</Button>
|
|
1202
1407
|
</div>
|
|
1203
1408
|
{isLoadingLogs ? (
|
|
1204
1409
|
<div className="flex justify-center py-8"><Spinner /></div>
|
|
@@ -126,6 +126,15 @@
|
|
|
126
126
|
"integrations.detail.logs.level.info": "Info",
|
|
127
127
|
"integrations.detail.logs.level.warn": "Warnung",
|
|
128
128
|
"integrations.detail.logs.title": "Betriebsprotokolle",
|
|
129
|
+
"integrations.detail.runActivity.lastRefreshed": "Zuletzt aktualisiert",
|
|
130
|
+
"integrations.detail.runActivity.processed": "Verarbeitet",
|
|
131
|
+
"integrations.detail.runActivity.progress": "Fortschritt",
|
|
132
|
+
"integrations.detail.runActivity.refresh": "Aktualisieren",
|
|
133
|
+
"integrations.detail.runActivity.status.cancelled": "Abgebrochen",
|
|
134
|
+
"integrations.detail.runActivity.status.completed": "Abgeschlossen",
|
|
135
|
+
"integrations.detail.runActivity.status.failed": "Fehlgeschlagen",
|
|
136
|
+
"integrations.detail.runActivity.status.pending": "Ausstehend",
|
|
137
|
+
"integrations.detail.runActivity.status.running": "Läuft",
|
|
129
138
|
"integrations.detail.state.disabled": "Disabled",
|
|
130
139
|
"integrations.detail.state.enabled": "Enabled",
|
|
131
140
|
"integrations.detail.state.label": "State",
|
|
@@ -126,6 +126,15 @@
|
|
|
126
126
|
"integrations.detail.logs.level.info": "Info",
|
|
127
127
|
"integrations.detail.logs.level.warn": "Warning",
|
|
128
128
|
"integrations.detail.logs.title": "Operation Logs",
|
|
129
|
+
"integrations.detail.runActivity.lastRefreshed": "Last refreshed",
|
|
130
|
+
"integrations.detail.runActivity.processed": "Processed",
|
|
131
|
+
"integrations.detail.runActivity.progress": "Progress",
|
|
132
|
+
"integrations.detail.runActivity.refresh": "Refresh",
|
|
133
|
+
"integrations.detail.runActivity.status.cancelled": "Cancelled",
|
|
134
|
+
"integrations.detail.runActivity.status.completed": "Completed",
|
|
135
|
+
"integrations.detail.runActivity.status.failed": "Failed",
|
|
136
|
+
"integrations.detail.runActivity.status.pending": "Pending",
|
|
137
|
+
"integrations.detail.runActivity.status.running": "Running",
|
|
129
138
|
"integrations.detail.state.disabled": "Disabled",
|
|
130
139
|
"integrations.detail.state.enabled": "Enabled",
|
|
131
140
|
"integrations.detail.state.label": "State",
|
|
@@ -126,6 +126,15 @@
|
|
|
126
126
|
"integrations.detail.logs.level.info": "Info",
|
|
127
127
|
"integrations.detail.logs.level.warn": "Advertencia",
|
|
128
128
|
"integrations.detail.logs.title": "Registros de operación",
|
|
129
|
+
"integrations.detail.runActivity.lastRefreshed": "Última actualización",
|
|
130
|
+
"integrations.detail.runActivity.processed": "Procesados",
|
|
131
|
+
"integrations.detail.runActivity.progress": "Progreso",
|
|
132
|
+
"integrations.detail.runActivity.refresh": "Actualizar",
|
|
133
|
+
"integrations.detail.runActivity.status.cancelled": "Cancelado",
|
|
134
|
+
"integrations.detail.runActivity.status.completed": "Completado",
|
|
135
|
+
"integrations.detail.runActivity.status.failed": "Fallido",
|
|
136
|
+
"integrations.detail.runActivity.status.pending": "Pendiente",
|
|
137
|
+
"integrations.detail.runActivity.status.running": "En curso",
|
|
129
138
|
"integrations.detail.state.disabled": "Disabled",
|
|
130
139
|
"integrations.detail.state.enabled": "Enabled",
|
|
131
140
|
"integrations.detail.state.label": "State",
|
|
@@ -126,6 +126,15 @@
|
|
|
126
126
|
"integrations.detail.logs.level.info": "Info",
|
|
127
127
|
"integrations.detail.logs.level.warn": "Ostrzeżenie",
|
|
128
128
|
"integrations.detail.logs.title": "Logi operacji",
|
|
129
|
+
"integrations.detail.runActivity.lastRefreshed": "Ostatnio odświeżono",
|
|
130
|
+
"integrations.detail.runActivity.processed": "Przetworzone",
|
|
131
|
+
"integrations.detail.runActivity.progress": "Postęp",
|
|
132
|
+
"integrations.detail.runActivity.refresh": "Odśwież",
|
|
133
|
+
"integrations.detail.runActivity.status.cancelled": "Anulowany",
|
|
134
|
+
"integrations.detail.runActivity.status.completed": "Zakończony",
|
|
135
|
+
"integrations.detail.runActivity.status.failed": "Nieudany",
|
|
136
|
+
"integrations.detail.runActivity.status.pending": "Oczekuje",
|
|
137
|
+
"integrations.detail.runActivity.status.running": "Trwa",
|
|
129
138
|
"integrations.detail.state.disabled": "Disabled",
|
|
130
139
|
"integrations.detail.state.enabled": "Enabled",
|
|
131
140
|
"integrations.detail.state.label": "State",
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { NextResponse } from 'next/server'
|
|
3
|
+
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
4
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
5
|
+
import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
|
|
6
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
7
|
+
import { z } from 'zod'
|
|
8
|
+
import type { ProgressService } from '@open-mercato/core/modules/progress/lib/progressService'
|
|
9
|
+
import type { SyncRunService } from '@open-mercato/core/modules/data_sync/lib/sync-run-service'
|
|
10
|
+
import { startDataSyncRun } from '@open-mercato/core/modules/data_sync/lib/start-run'
|
|
11
|
+
import { SyncMapping } from '@open-mercato/core/modules/data_sync/data/entities'
|
|
12
|
+
import type { CredentialsService } from '@open-mercato/core/modules/integrations/lib/credentials-service'
|
|
13
|
+
import type { IntegrationStateService } from '@open-mercato/core/modules/integrations/lib/state-service'
|
|
14
|
+
import { SyncExcelUpload } from '../../data/entities'
|
|
15
|
+
import { Attachment } from '../../../attachments/data/entities'
|
|
16
|
+
import { createCursor } from '../../lib/adapters/customers'
|
|
17
|
+
import {
|
|
18
|
+
syncExcelImportRequestSchema,
|
|
19
|
+
syncExcelImportResponseSchema,
|
|
20
|
+
} from '../../data/validators'
|
|
21
|
+
import { resolveSyncExcelConcreteScope } from '../../lib/scope'
|
|
22
|
+
|
|
23
|
+
export const metadata = {
|
|
24
|
+
POST: { requireAuth: true, requireFeatures: ['sync_excel.run'] },
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const errorSchema = z.object({
|
|
28
|
+
error: z.string(),
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
export const openApi = {
|
|
32
|
+
tags: ['SyncExcel'],
|
|
33
|
+
summary: 'Start a CSV import run for a stored sync_excel upload',
|
|
34
|
+
methods: {
|
|
35
|
+
POST: {
|
|
36
|
+
summary: 'Start CSV import',
|
|
37
|
+
responses: [
|
|
38
|
+
{ status: 201, description: 'Import run started', schema: syncExcelImportResponseSchema },
|
|
39
|
+
{ status: 401, description: 'Unauthorized', schema: errorSchema },
|
|
40
|
+
{ status: 404, description: 'Upload not found', schema: errorSchema },
|
|
41
|
+
{ status: 409, description: 'Import overlap detected', schema: errorSchema },
|
|
42
|
+
{ status: 422, description: 'Invalid import payload', schema: errorSchema },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function POST(request: Request) {
|
|
49
|
+
const auth = await getAuthFromRequest(request)
|
|
50
|
+
if (!auth?.tenantId) {
|
|
51
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const payload = await readJsonSafe(request)
|
|
55
|
+
const parsedPayload = syncExcelImportRequestSchema.safeParse(payload)
|
|
56
|
+
if (!parsedPayload.success) {
|
|
57
|
+
return NextResponse.json({ error: 'Invalid import payload.' }, { status: 422 })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const container = await createRequestContainer()
|
|
61
|
+
const em = container.resolve('em') as EntityManager
|
|
62
|
+
const syncRunService = container.resolve('dataSyncRunService') as SyncRunService
|
|
63
|
+
const progressService = container.resolve('progressService') as ProgressService
|
|
64
|
+
const credentialsService = container.resolve('integrationCredentialsService') as CredentialsService
|
|
65
|
+
const integrationStateService = container.resolve('integrationStateService') as IntegrationStateService
|
|
66
|
+
const scopeResult = await resolveSyncExcelConcreteScope({ auth, container, request })
|
|
67
|
+
if (!scopeResult.ok) {
|
|
68
|
+
return NextResponse.json({ error: scopeResult.error }, { status: scopeResult.status })
|
|
69
|
+
}
|
|
70
|
+
const { scope } = scopeResult
|
|
71
|
+
|
|
72
|
+
const upload = await findOneWithDecryption(
|
|
73
|
+
em,
|
|
74
|
+
SyncExcelUpload,
|
|
75
|
+
{
|
|
76
|
+
id: parsedPayload.data.uploadId,
|
|
77
|
+
organizationId: scope.organizationId,
|
|
78
|
+
tenantId: scope.tenantId,
|
|
79
|
+
},
|
|
80
|
+
undefined,
|
|
81
|
+
scope,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if (!upload) {
|
|
85
|
+
return NextResponse.json({ error: 'Upload preview not found.' }, { status: 404 })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const attachment = await findOneWithDecryption(
|
|
89
|
+
em,
|
|
90
|
+
Attachment,
|
|
91
|
+
{
|
|
92
|
+
id: upload.attachmentId,
|
|
93
|
+
organizationId: scope.organizationId,
|
|
94
|
+
tenantId: scope.tenantId,
|
|
95
|
+
},
|
|
96
|
+
undefined,
|
|
97
|
+
scope,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if (!attachment) {
|
|
101
|
+
return NextResponse.json({ error: 'Upload attachment not found.' }, { status: 404 })
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (upload.entityType !== parsedPayload.data.entityType) {
|
|
105
|
+
return NextResponse.json({ error: 'Upload entity type does not match requested import target.' }, { status: 422 })
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (parsedPayload.data.mapping.entityType !== parsedPayload.data.entityType) {
|
|
109
|
+
return NextResponse.json({ error: 'Mapping entity type does not match requested import target.' }, { status: 422 })
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const overlap = await syncRunService.findRunningOverlap('sync_excel', parsedPayload.data.entityType, 'import', scope)
|
|
113
|
+
if (overlap) {
|
|
114
|
+
return NextResponse.json({ error: 'A sync_excel import is already in progress for this entity type.' }, { status: 409 })
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const existingMapping = await findOneWithDecryption(
|
|
118
|
+
em,
|
|
119
|
+
SyncMapping,
|
|
120
|
+
{
|
|
121
|
+
integrationId: 'sync_excel',
|
|
122
|
+
entityType: parsedPayload.data.entityType,
|
|
123
|
+
organizationId: scope.organizationId,
|
|
124
|
+
tenantId: scope.tenantId,
|
|
125
|
+
},
|
|
126
|
+
undefined,
|
|
127
|
+
scope,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if (existingMapping) {
|
|
131
|
+
existingMapping.mapping = parsedPayload.data.mapping
|
|
132
|
+
} else {
|
|
133
|
+
em.persist(em.create(SyncMapping, {
|
|
134
|
+
integrationId: 'sync_excel',
|
|
135
|
+
entityType: parsedPayload.data.entityType,
|
|
136
|
+
mapping: parsedPayload.data.mapping,
|
|
137
|
+
organizationId: scope.organizationId,
|
|
138
|
+
tenantId: scope.tenantId,
|
|
139
|
+
}))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
await credentialsService.save('sync_excel', {}, scope)
|
|
143
|
+
await integrationStateService.upsert('sync_excel', { isEnabled: true }, scope)
|
|
144
|
+
await em.flush()
|
|
145
|
+
|
|
146
|
+
const { run, progressJob } = await startDataSyncRun({
|
|
147
|
+
syncRunService,
|
|
148
|
+
progressService,
|
|
149
|
+
scope: {
|
|
150
|
+
...scope,
|
|
151
|
+
userId: auth.sub,
|
|
152
|
+
},
|
|
153
|
+
input: {
|
|
154
|
+
integrationId: 'sync_excel',
|
|
155
|
+
entityType: parsedPayload.data.entityType,
|
|
156
|
+
direction: 'import',
|
|
157
|
+
cursor: createCursor(upload.id, 0),
|
|
158
|
+
triggeredBy: auth.sub,
|
|
159
|
+
batchSize: parsedPayload.data.batchSize ?? 100,
|
|
160
|
+
progressJob: {
|
|
161
|
+
jobType: 'sync_excel:import',
|
|
162
|
+
name: `CSV import — ${parsedPayload.data.entityType}`,
|
|
163
|
+
description: upload.filename,
|
|
164
|
+
meta: {
|
|
165
|
+
integrationId: 'sync_excel',
|
|
166
|
+
uploadId: upload.id,
|
|
167
|
+
hiddenFromTopBar: false,
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
upload.syncRunId = run.id
|
|
174
|
+
upload.status = 'importing'
|
|
175
|
+
await em.flush()
|
|
176
|
+
|
|
177
|
+
return NextResponse.json(syncExcelImportResponseSchema.parse({
|
|
178
|
+
runId: run.id,
|
|
179
|
+
progressJobId: progressJob?.id ?? null,
|
|
180
|
+
status: run.status,
|
|
181
|
+
}), { status: 201 })
|
|
182
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { NextResponse } from 'next/server'
|
|
3
|
+
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
4
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
5
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
6
|
+
import { z } from 'zod'
|
|
7
|
+
import { syncExcelPreviewQuerySchema, syncExcelUploadResponseSchema } from '../../data/validators'
|
|
8
|
+
import { SyncExcelUpload } from '../../data/entities'
|
|
9
|
+
import { buildSuggestedMapping } from '../../lib/mapping'
|
|
10
|
+
import { resolveSyncExcelConcreteScope } from '../../lib/scope'
|
|
11
|
+
|
|
12
|
+
export const metadata = {
|
|
13
|
+
GET: { requireAuth: true, requireFeatures: ['sync_excel.view'] },
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const errorSchema = z.object({
|
|
17
|
+
error: z.string(),
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
export const openApi = {
|
|
21
|
+
tags: ['SyncExcel'],
|
|
22
|
+
summary: 'Fetch stored preview for a sync_excel upload',
|
|
23
|
+
methods: {
|
|
24
|
+
GET: {
|
|
25
|
+
summary: 'Fetch upload preview',
|
|
26
|
+
responses: [
|
|
27
|
+
{ status: 200, description: 'Stored upload preview', schema: syncExcelUploadResponseSchema },
|
|
28
|
+
{ status: 401, description: 'Unauthorized', schema: errorSchema },
|
|
29
|
+
{ status: 422, description: 'Invalid query', schema: errorSchema },
|
|
30
|
+
{ status: 404, description: 'Upload preview not found', schema: errorSchema },
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function GET(request: Request) {
|
|
37
|
+
const auth = await getAuthFromRequest(request)
|
|
38
|
+
if (!auth?.tenantId) {
|
|
39
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const url = new URL(request.url)
|
|
43
|
+
const parsedQuery = syncExcelPreviewQuerySchema.safeParse({
|
|
44
|
+
uploadId: url.searchParams.get('uploadId'),
|
|
45
|
+
entityType: url.searchParams.get('entityType') ?? undefined,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
if (!parsedQuery.success) {
|
|
49
|
+
return NextResponse.json({ error: 'Invalid query' }, { status: 422 })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const container = await createRequestContainer()
|
|
53
|
+
const scopeResult = await resolveSyncExcelConcreteScope({ auth, container, request })
|
|
54
|
+
if (!scopeResult.ok) {
|
|
55
|
+
return NextResponse.json({ error: scopeResult.error }, { status: scopeResult.status })
|
|
56
|
+
}
|
|
57
|
+
const { scope } = scopeResult
|
|
58
|
+
const em = container.resolve('em') as EntityManager
|
|
59
|
+
const upload = await findOneWithDecryption(
|
|
60
|
+
em,
|
|
61
|
+
SyncExcelUpload,
|
|
62
|
+
{
|
|
63
|
+
id: parsedQuery.data.uploadId,
|
|
64
|
+
organizationId: scope.organizationId,
|
|
65
|
+
tenantId: scope.tenantId,
|
|
66
|
+
},
|
|
67
|
+
undefined,
|
|
68
|
+
scope,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if (!upload) {
|
|
72
|
+
return NextResponse.json({ error: 'Upload preview not found.' }, { status: 404 })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const entityType = parsedQuery.data.entityType ?? upload.entityType
|
|
76
|
+
const suggestedMapping = buildSuggestedMapping(entityType, upload.headers)
|
|
77
|
+
|
|
78
|
+
return NextResponse.json(syncExcelUploadResponseSchema.parse({
|
|
79
|
+
uploadId: upload.id,
|
|
80
|
+
filename: upload.filename,
|
|
81
|
+
mimeType: upload.mimeType,
|
|
82
|
+
fileSize: upload.fileSize,
|
|
83
|
+
entityType,
|
|
84
|
+
headers: upload.headers,
|
|
85
|
+
sampleRows: upload.sampleRows,
|
|
86
|
+
totalRows: upload.totalRows,
|
|
87
|
+
suggestedMapping,
|
|
88
|
+
}))
|
|
89
|
+
}
|