@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.
Files changed (102) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/generated/entities/sync_excel_upload/index.js +37 -0
  3. package/dist/generated/entities/sync_excel_upload/index.js.map +7 -0
  4. package/dist/generated/entities.ids.generated.js +4 -0
  5. package/dist/generated/entities.ids.generated.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +19 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/modules/customers/search.js +3 -2
  9. package/dist/modules/customers/search.js.map +2 -2
  10. package/dist/modules/data_sync/api/runs/[id]/cancel.js +16 -0
  11. package/dist/modules/data_sync/api/runs/[id]/cancel.js.map +2 -2
  12. package/dist/modules/data_sync/di.js +2 -1
  13. package/dist/modules/data_sync/di.js.map +2 -2
  14. package/dist/modules/data_sync/lib/sync-engine.js +178 -54
  15. package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
  16. package/dist/modules/data_sync/lib/sync-run-service.js +23 -0
  17. package/dist/modules/data_sync/lib/sync-run-service.js.map +3 -3
  18. package/dist/modules/integrations/backend/integrations/[id]/page.js +241 -55
  19. package/dist/modules/integrations/backend/integrations/[id]/page.js.map +3 -3
  20. package/dist/modules/sync_excel/acl.js +10 -0
  21. package/dist/modules/sync_excel/acl.js.map +7 -0
  22. package/dist/modules/sync_excel/api/import/route.js +164 -0
  23. package/dist/modules/sync_excel/api/import/route.js.map +7 -0
  24. package/dist/modules/sync_excel/api/preview/route.js +84 -0
  25. package/dist/modules/sync_excel/api/preview/route.js.map +7 -0
  26. package/dist/modules/sync_excel/api/upload/route.js +131 -0
  27. package/dist/modules/sync_excel/api/upload/route.js.map +7 -0
  28. package/dist/modules/sync_excel/data/entities.js +79 -0
  29. package/dist/modules/sync_excel/data/entities.js.map +7 -0
  30. package/dist/modules/sync_excel/data/validators.js +58 -0
  31. package/dist/modules/sync_excel/data/validators.js.map +7 -0
  32. package/dist/modules/sync_excel/di.js +11 -0
  33. package/dist/modules/sync_excel/di.js.map +7 -0
  34. package/dist/modules/sync_excel/index.js +11 -0
  35. package/dist/modules/sync_excel/index.js.map +7 -0
  36. package/dist/modules/sync_excel/integration.js +36 -0
  37. package/dist/modules/sync_excel/integration.js.map +7 -0
  38. package/dist/modules/sync_excel/lib/adapters/customers.js +852 -0
  39. package/dist/modules/sync_excel/lib/adapters/customers.js.map +7 -0
  40. package/dist/modules/sync_excel/lib/column-detector.js +77 -0
  41. package/dist/modules/sync_excel/lib/column-detector.js.map +7 -0
  42. package/dist/modules/sync_excel/lib/mapping.js +16 -0
  43. package/dist/modules/sync_excel/lib/mapping.js.map +7 -0
  44. package/dist/modules/sync_excel/lib/parser.js +125 -0
  45. package/dist/modules/sync_excel/lib/parser.js.map +7 -0
  46. package/dist/modules/sync_excel/lib/scope.js +44 -0
  47. package/dist/modules/sync_excel/lib/scope.js.map +7 -0
  48. package/dist/modules/sync_excel/lib/upload-storage.js +63 -0
  49. package/dist/modules/sync_excel/lib/upload-storage.js.map +7 -0
  50. package/dist/modules/sync_excel/migrations/Migration20260329181120_sync_excel.js +11 -0
  51. package/dist/modules/sync_excel/migrations/Migration20260329181120_sync_excel.js.map +7 -0
  52. package/dist/modules/sync_excel/setup.js +19 -0
  53. package/dist/modules/sync_excel/setup.js.map +7 -0
  54. package/dist/modules/sync_excel/widgets/injection/upload-config/target-options.js +293 -0
  55. package/dist/modules/sync_excel/widgets/injection/upload-config/target-options.js.map +7 -0
  56. package/dist/modules/sync_excel/widgets/injection/upload-config/widget.client.js +934 -0
  57. package/dist/modules/sync_excel/widgets/injection/upload-config/widget.client.js.map +7 -0
  58. package/dist/modules/sync_excel/widgets/injection/upload-config/widget.js +15 -0
  59. package/dist/modules/sync_excel/widgets/injection/upload-config/widget.js.map +7 -0
  60. package/dist/modules/sync_excel/widgets/injection-table.js +17 -0
  61. package/dist/modules/sync_excel/widgets/injection-table.js.map +7 -0
  62. package/generated/entities/sync_excel_upload/index.ts +17 -0
  63. package/generated/entities.ids.generated.ts +4 -0
  64. package/generated/entity-fields-registry.ts +19 -0
  65. package/package.json +7 -7
  66. package/src/modules/customers/search.ts +3 -2
  67. package/src/modules/data_sync/api/runs/[id]/cancel.ts +18 -0
  68. package/src/modules/data_sync/di.ts +4 -1
  69. package/src/modules/data_sync/lib/adapter.ts +15 -0
  70. package/src/modules/data_sync/lib/sync-engine.ts +205 -52
  71. package/src/modules/data_sync/lib/sync-run-service.ts +24 -0
  72. package/src/modules/integrations/backend/integrations/[id]/page.tsx +220 -15
  73. package/src/modules/integrations/i18n/de.json +9 -0
  74. package/src/modules/integrations/i18n/en.json +9 -0
  75. package/src/modules/integrations/i18n/es.json +9 -0
  76. package/src/modules/integrations/i18n/pl.json +9 -0
  77. package/src/modules/sync_excel/acl.ts +6 -0
  78. package/src/modules/sync_excel/api/import/route.ts +182 -0
  79. package/src/modules/sync_excel/api/preview/route.ts +89 -0
  80. package/src/modules/sync_excel/api/upload/route.ts +144 -0
  81. package/src/modules/sync_excel/data/entities.ts +61 -0
  82. package/src/modules/sync_excel/data/validators.ts +58 -0
  83. package/src/modules/sync_excel/di.ts +9 -0
  84. package/src/modules/sync_excel/i18n/de.json +107 -0
  85. package/src/modules/sync_excel/i18n/en.json +107 -0
  86. package/src/modules/sync_excel/i18n/es.json +107 -0
  87. package/src/modules/sync_excel/i18n/pl.json +107 -0
  88. package/src/modules/sync_excel/index.ts +7 -0
  89. package/src/modules/sync_excel/integration.ts +31 -0
  90. package/src/modules/sync_excel/lib/adapters/customers.ts +1097 -0
  91. package/src/modules/sync_excel/lib/column-detector.ts +91 -0
  92. package/src/modules/sync_excel/lib/mapping.ts +19 -0
  93. package/src/modules/sync_excel/lib/parser.ts +165 -0
  94. package/src/modules/sync_excel/lib/scope.ts +63 -0
  95. package/src/modules/sync_excel/lib/upload-storage.ts +79 -0
  96. package/src/modules/sync_excel/migrations/.snapshot-open-mercato.json +199 -0
  97. package/src/modules/sync_excel/migrations/Migration20260329181120_sync_excel.ts +10 -0
  98. package/src/modules/sync_excel/setup.ts +20 -0
  99. package/src/modules/sync_excel/widgets/injection/upload-config/target-options.ts +351 -0
  100. package/src/modules/sync_excel/widgets/injection/upload-config/widget.client.tsx +1224 -0
  101. package/src/modules/sync_excel/widgets/injection/upload-config/widget.ts +30 -0
  102. 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' ? latestHealthLog.payload.message : null)
738
- const healthDetailsSource = latestHealthResult?.details ?? extractHealthDetails(latestHealthLog?.payload)
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
- router.replace(nextTab === 'credentials' ? basePath : `${basePath}?tab=${encodeURIComponent(nextTab)}`)
815
- }, [resolveCurrentIntegrationId, router, visibleTabIds])
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
- <Button
1118
- type="button"
1119
- variant="outline"
1120
- size="sm"
1121
- onClick={() => void handleHealthCheck()}
1122
- disabled={isCheckingHealth}
1123
- >
1124
- {isCheckingHealth ? <Spinner className="mr-2 h-4 w-4" /> : <Zap className="mr-2 h-4 w-4" />}
1125
- {isCheckingHealth ? t('integrations.detail.health.checking') : t('integrations.detail.health.check')}
1126
- </Button>
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
- <div className="flex items-center gap-3">
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,6 @@
1
+ export const features = [
2
+ { id: 'sync_excel.view', title: 'View sync_excel uploads and previews', module: 'sync_excel' },
3
+ { id: 'sync_excel.run', title: 'Upload CSV files and prepare imports', module: 'sync_excel' },
4
+ ]
5
+
6
+ export default features
@@ -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
+ }