@open-mercato/core 0.6.2-develop.3467.1.2a1818709d → 0.6.2

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 (158) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/helpers/integration/queue-runner.js +15 -3
  3. package/dist/helpers/integration/queue-runner.js.map +2 -2
  4. package/dist/helpers/integration/queue.js +1 -1
  5. package/dist/helpers/integration/queue.js.map +2 -2
  6. package/dist/helpers/integration/salesUi.js +24 -1
  7. package/dist/helpers/integration/salesUi.js.map +2 -2
  8. package/dist/modules/auth/api/roles/acl/route.js +37 -1
  9. package/dist/modules/auth/api/roles/acl/route.js.map +2 -2
  10. package/dist/modules/auth/api/roles/route.js +36 -2
  11. package/dist/modules/auth/api/roles/route.js.map +2 -2
  12. package/dist/modules/auth/api/users/acl/route.js +36 -1
  13. package/dist/modules/auth/api/users/acl/route.js.map +2 -2
  14. package/dist/modules/auth/api/users/route.js +101 -13
  15. package/dist/modules/auth/api/users/route.js.map +2 -2
  16. package/dist/modules/auth/backend/roles/[id]/edit/page.js +32 -14
  17. package/dist/modules/auth/backend/roles/[id]/edit/page.js.map +2 -2
  18. package/dist/modules/auth/backend/users/[id]/edit/page.js +1 -1
  19. package/dist/modules/auth/backend/users/[id]/edit/page.js.map +2 -2
  20. package/dist/modules/auth/backend/users/create/page.js +1 -1
  21. package/dist/modules/auth/backend/users/create/page.js.map +2 -2
  22. package/dist/modules/auth/backend/users/roleOptions.js +1 -1
  23. package/dist/modules/auth/backend/users/roleOptions.js.map +2 -2
  24. package/dist/modules/auth/commands/roles.js +8 -0
  25. package/dist/modules/auth/commands/roles.js.map +2 -2
  26. package/dist/modules/auth/commands/users.js +3 -0
  27. package/dist/modules/auth/commands/users.js.map +2 -2
  28. package/dist/modules/auth/components/AclEditor.js +53 -26
  29. package/dist/modules/auth/components/AclEditor.js.map +2 -2
  30. package/dist/modules/auth/lib/grantChecks.js +97 -2
  31. package/dist/modules/auth/lib/grantChecks.js.map +2 -2
  32. package/dist/modules/business_rules/lib/expression-evaluator.js +9 -7
  33. package/dist/modules/business_rules/lib/expression-evaluator.js.map +2 -2
  34. package/dist/modules/customers/ai-agents.js +10 -1
  35. package/dist/modules/customers/ai-agents.js.map +2 -2
  36. package/dist/modules/customers/ai-tools/settings-pack.js +55 -1
  37. package/dist/modules/customers/ai-tools/settings-pack.js.map +2 -2
  38. package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js +13 -5
  39. package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js.map +2 -2
  40. package/dist/modules/customers/lib/personCompanyLinkTable.js +4 -30
  41. package/dist/modules/customers/lib/personCompanyLinkTable.js.map +2 -2
  42. package/dist/modules/customers/migrations/Migration20260521175027.js +12 -0
  43. package/dist/modules/customers/migrations/Migration20260521175027.js.map +7 -0
  44. package/dist/modules/dashboards/components/WidgetVisibilityEditor.js +41 -12
  45. package/dist/modules/dashboards/components/WidgetVisibilityEditor.js.map +2 -2
  46. package/dist/modules/data_sync/api/options.js +2 -0
  47. package/dist/modules/data_sync/api/options.js.map +2 -2
  48. package/dist/modules/data_sync/api/run.js +77 -58
  49. package/dist/modules/data_sync/api/run.js.map +2 -2
  50. package/dist/modules/data_sync/backend/data-sync/page.js +3 -2
  51. package/dist/modules/data_sync/backend/data-sync/page.js.map +2 -2
  52. package/dist/modules/data_sync/components/IntegrationScheduleTab.js +8 -3
  53. package/dist/modules/data_sync/components/IntegrationScheduleTab.js.map +2 -2
  54. package/dist/modules/integrations/backend/integrations/[id]/page.js +15 -13
  55. package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
  56. package/dist/modules/integrations/data/validators.js +9 -6
  57. package/dist/modules/integrations/data/validators.js.map +2 -2
  58. package/dist/modules/messages/api/route.js +2 -2
  59. package/dist/modules/messages/api/route.js.map +2 -2
  60. package/dist/modules/messages/commands/messages.js +42 -2
  61. package/dist/modules/messages/commands/messages.js.map +2 -2
  62. package/dist/modules/messages/components/MessagesInboxPageClient.js +66 -87
  63. package/dist/modules/messages/components/MessagesInboxPageClient.js.map +2 -2
  64. package/dist/modules/messages/components/inboxFilters.js +133 -0
  65. package/dist/modules/messages/components/inboxFilters.js.map +7 -0
  66. package/dist/modules/messages/data/validators.js +8 -6
  67. package/dist/modules/messages/data/validators.js.map +2 -2
  68. package/dist/modules/progress/api/jobs/route.js +26 -16
  69. package/dist/modules/progress/api/jobs/route.js.map +2 -2
  70. package/dist/modules/query_index/di.js +8 -6
  71. package/dist/modules/query_index/di.js.map +2 -2
  72. package/dist/modules/query_index/lib/indexer.js +12 -10
  73. package/dist/modules/query_index/lib/indexer.js.map +2 -2
  74. package/dist/modules/query_index/subscribers/upsert_one.js +7 -1
  75. package/dist/modules/query_index/subscribers/upsert_one.js.map +2 -2
  76. package/dist/modules/sales/api/order-adjustments/route.js.map +2 -2
  77. package/dist/modules/sales/api/quote-adjustments/route.js.map +2 -2
  78. package/dist/modules/sales/commands/documents.js +104 -0
  79. package/dist/modules/sales/commands/documents.js.map +2 -2
  80. package/dist/modules/sales/data/validators.js +20 -1
  81. package/dist/modules/sales/data/validators.js.map +2 -2
  82. package/dist/modules/sales/lib/makeSalesLineRoute.js.map +2 -2
  83. package/dist/modules/sync_excel/api/import/route.js +126 -116
  84. package/dist/modules/sync_excel/api/import/route.js.map +2 -2
  85. package/dist/modules/sync_excel/lib/adapters/customers.js +1 -0
  86. package/dist/modules/sync_excel/lib/adapters/customers.js.map +2 -2
  87. package/dist/modules/translations/components/TranslationManager.js +9 -4
  88. package/dist/modules/translations/components/TranslationManager.js.map +2 -2
  89. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  90. package/dist/modules/workflows/lib/event-trigger-service.js +23 -11
  91. package/dist/modules/workflows/lib/event-trigger-service.js.map +2 -2
  92. package/jest.config.cjs +4 -1
  93. package/package.json +15 -16
  94. package/src/helpers/integration/queue-runner.ts +21 -2
  95. package/src/helpers/integration/queue.ts +1 -1
  96. package/src/helpers/integration/salesUi.ts +26 -1
  97. package/src/modules/auth/api/roles/acl/route.ts +39 -1
  98. package/src/modules/auth/api/roles/route.ts +38 -2
  99. package/src/modules/auth/api/users/acl/route.ts +41 -1
  100. package/src/modules/auth/api/users/route.ts +105 -12
  101. package/src/modules/auth/backend/roles/[id]/edit/page.tsx +35 -13
  102. package/src/modules/auth/backend/users/[id]/edit/page.tsx +1 -1
  103. package/src/modules/auth/backend/users/create/page.tsx +1 -1
  104. package/src/modules/auth/backend/users/roleOptions.ts +2 -1
  105. package/src/modules/auth/commands/roles.ts +8 -0
  106. package/src/modules/auth/commands/users.ts +3 -0
  107. package/src/modules/auth/components/AclEditor.tsx +50 -24
  108. package/src/modules/auth/lib/grantChecks.ts +116 -2
  109. package/src/modules/business_rules/lib/expression-evaluator.ts +9 -13
  110. package/src/modules/customers/ai-agents.ts +10 -1
  111. package/src/modules/customers/ai-tools/settings-pack.ts +62 -1
  112. package/src/modules/customers/components/detail/schedule/useScheduleFormState.ts +18 -7
  113. package/src/modules/customers/lib/personCompanyLinkTable.ts +33 -41
  114. package/src/modules/customers/migrations/Migration20260521175027.ts +39 -0
  115. package/src/modules/dashboards/components/WidgetVisibilityEditor.tsx +38 -12
  116. package/src/modules/data_sync/api/options.ts +2 -0
  117. package/src/modules/data_sync/api/run.ts +80 -61
  118. package/src/modules/data_sync/backend/data-sync/page.tsx +10 -0
  119. package/src/modules/data_sync/components/IntegrationScheduleTab.tsx +17 -3
  120. package/src/modules/data_sync/i18n/de.json +3 -0
  121. package/src/modules/data_sync/i18n/en.json +3 -0
  122. package/src/modules/data_sync/i18n/es.json +3 -0
  123. package/src/modules/data_sync/i18n/pl.json +3 -0
  124. package/src/modules/data_sync/lib/adapter.ts +9 -0
  125. package/src/modules/integrations/backend/integrations/[id]/page.tsx +15 -13
  126. package/src/modules/integrations/data/validators.ts +5 -4
  127. package/src/modules/messages/api/route.ts +2 -2
  128. package/src/modules/messages/commands/messages.ts +54 -2
  129. package/src/modules/messages/components/MessagesInboxPageClient.tsx +75 -90
  130. package/src/modules/messages/components/inboxFilters.ts +162 -0
  131. package/src/modules/messages/data/validators.ts +7 -5
  132. package/src/modules/messages/i18n/de.json +5 -4
  133. package/src/modules/messages/i18n/en.json +5 -4
  134. package/src/modules/messages/i18n/es.json +5 -4
  135. package/src/modules/messages/i18n/pl.json +5 -4
  136. package/src/modules/progress/api/jobs/route.ts +30 -20
  137. package/src/modules/query_index/di.ts +9 -7
  138. package/src/modules/query_index/lib/indexer.ts +13 -11
  139. package/src/modules/query_index/subscribers/upsert_one.ts +9 -1
  140. package/src/modules/sales/api/order-adjustments/route.ts +2 -1
  141. package/src/modules/sales/api/quote-adjustments/route.ts +2 -1
  142. package/src/modules/sales/commands/documents.ts +116 -0
  143. package/src/modules/sales/data/validators.ts +38 -0
  144. package/src/modules/sales/i18n/de.json +2 -0
  145. package/src/modules/sales/i18n/en.json +2 -0
  146. package/src/modules/sales/i18n/es.json +2 -0
  147. package/src/modules/sales/i18n/pl.json +2 -0
  148. package/src/modules/sales/lib/makeSalesLineRoute.ts +2 -1
  149. package/src/modules/staff/i18n/de.json +1 -0
  150. package/src/modules/staff/i18n/en.json +1 -0
  151. package/src/modules/staff/i18n/es.json +1 -0
  152. package/src/modules/staff/i18n/pl.json +1 -0
  153. package/src/modules/sync_excel/api/import/route.ts +141 -131
  154. package/src/modules/sync_excel/lib/adapters/customers.ts +1 -0
  155. package/src/modules/translations/components/TranslationManager.tsx +9 -4
  156. package/src/modules/workflows/lib/activity-executor.ts +4 -3
  157. package/src/modules/workflows/lib/event-trigger-service.ts +24 -11
  158. package/tsconfig.json +1 -0
@@ -32,6 +32,8 @@ type SyncOption = {
32
32
  integrationId: string
33
33
  title: string
34
34
  direction: 'import' | 'export' | 'bidirectional'
35
+ runMode?: 'generic' | 'provider'
36
+ canStartRun?: boolean
35
37
  supportedEntities: string[]
36
38
  hasCredentials: boolean
37
39
  isEnabled: boolean
@@ -204,6 +206,10 @@ export function IntegrationScheduleTab(props: IntegrationScheduleTabProps) {
204
206
  flash(t('data_sync.integrationTab.credentialsMissing', 'Configure credentials before starting a sync.'), 'error')
205
207
  return
206
208
  }
209
+ if (option?.canStartRun === false) {
210
+ flash(t('data_sync.integrationTab.providerManaged', 'Start this integration from its provider-specific setup flow.'), 'error')
211
+ return
212
+ }
207
213
 
208
214
  setRunningKey(scheduleKey)
209
215
  try {
@@ -231,7 +237,7 @@ export function IntegrationScheduleTab(props: IntegrationScheduleTabProps) {
231
237
  } finally {
232
238
  setRunningKey(null)
233
239
  }
234
- }, [props.hasCredentials, props.integrationId, props.isEnabled, schedules, t])
240
+ }, [option?.canStartRun, props.hasCredentials, props.integrationId, props.isEnabled, schedules, t])
235
241
 
236
242
  const handleSaveSchedule = React.useCallback(async (entityType: string, direction: 'import' | 'export', scheduleKey: string) => {
237
243
  const scheduleState = schedules[scheduleKey] ?? buildDefaultScheduleState(entityType)
@@ -369,6 +375,14 @@ export function IntegrationScheduleTab(props: IntegrationScheduleTabProps) {
369
375
  </Alert>
370
376
  ) : null}
371
377
 
378
+ {option.canStartRun === false ? (
379
+ <Alert variant="info">
380
+ <AlertDescription>
381
+ {t('data_sync.integrationTab.providerManagedNotice', 'This provider needs its own setup flow before a run can start. Use the provider tab on this page instead of generic schedules.')}
382
+ </AlertDescription>
383
+ </Alert>
384
+ ) : null}
385
+
372
386
  {rows.length === 0 ? (
373
387
  <Alert variant="info">
374
388
  <AlertDescription>
@@ -469,7 +483,7 @@ export function IntegrationScheduleTab(props: IntegrationScheduleTabProps) {
469
483
  type="button"
470
484
  size="sm"
471
485
  onClick={() => void handleStartSync(row.entityType, row.direction, row.key)}
472
- disabled={controlsDisabled || !props.isEnabled || !props.hasCredentials}
486
+ disabled={controlsDisabled || !props.isEnabled || !props.hasCredentials || option.canStartRun === false}
473
487
  >
474
488
  {isRunning ? <Spinner className="mr-2 h-4 w-4" /> : <Play className="mr-2 h-4 w-4" />}
475
489
  {isRunning
@@ -481,7 +495,7 @@ export function IntegrationScheduleTab(props: IntegrationScheduleTabProps) {
481
495
  size="sm"
482
496
  variant="outline"
483
497
  onClick={() => void handleSaveSchedule(row.entityType, row.direction, row.key)}
484
- disabled={controlsDisabled}
498
+ disabled={controlsDisabled || option.canStartRun === false}
485
499
  >
486
500
  {isSaving ? <Spinner className="mr-2 h-4 w-4" /> : <Save className="mr-2 h-4 w-4" />}
487
501
  {isSaving
@@ -54,6 +54,7 @@
54
54
  "data_sync.dashboard.start.fullSync": "Run as full sync",
55
55
  "data_sync.dashboard.start.fullSyncHelp": "Ignore the saved cursor and process the entire source again for this run.",
56
56
  "data_sync.dashboard.start.invalidBatchSize": "Batch size must be between 1 and 1000.",
57
+ "data_sync.dashboard.start.providerManaged": "Diese Integration startet Synchronisierungsläufe über ihren eigenen Einrichtungsablauf. Öffne die Integrationseinstellungen, um fortzufahren.",
57
58
  "data_sync.dashboard.start.runNowDescription": "Use this for the next immediate sync. Batch size and full-sync mode apply only to this manual run.",
58
59
  "data_sync.dashboard.start.runNowFootnote": "Manual runs show progress immediately and land on the run detail page after launch.",
59
60
  "data_sync.dashboard.start.runNowTitle": "Run once now",
@@ -83,6 +84,8 @@
83
84
  "data_sync.integrationTab.integrationDisabledNotice": "The integration is disabled. You can save schedules now, but runs will stay blocked until the integration is enabled.",
84
85
  "data_sync.integrationTab.loadError": "Failed to load sync schedules.",
85
86
  "data_sync.integrationTab.notAvailable": "This integration is not registered as a data sync provider.",
87
+ "data_sync.integrationTab.providerManaged": "Starte diese Integration über ihren anbieterspezifischen Einrichtungsablauf.",
88
+ "data_sync.integrationTab.providerManagedNotice": "Dieser Anbieter benötigt einen eigenen Einrichtungsablauf, bevor ein Lauf gestartet werden kann. Verwende den Anbieter-Tab auf dieser Seite statt der generischen Zeitpläne.",
86
89
  "data_sync.integrationTab.refresh": "Refresh",
87
90
  "data_sync.integrationTab.runError": "Failed to start sync.",
88
91
  "data_sync.integrationTab.runNowHelp": "Run now uses the full-sync checkbox from the same row and starts progress tracking immediately in Data Sync.",
@@ -54,6 +54,7 @@
54
54
  "data_sync.dashboard.start.fullSync": "Run as full sync",
55
55
  "data_sync.dashboard.start.fullSyncHelp": "Ignore the saved cursor and process the entire source again for this run.",
56
56
  "data_sync.dashboard.start.invalidBatchSize": "Batch size must be between 1 and 1000.",
57
+ "data_sync.dashboard.start.providerManaged": "This integration starts sync runs from its own setup flow. Open the integration settings page to continue.",
57
58
  "data_sync.dashboard.start.runNowDescription": "Use this for the next immediate sync. Batch size and full-sync mode apply only to this manual run.",
58
59
  "data_sync.dashboard.start.runNowFootnote": "Manual runs show progress immediately and land on the run detail page after launch.",
59
60
  "data_sync.dashboard.start.runNowTitle": "Run once now",
@@ -83,6 +84,8 @@
83
84
  "data_sync.integrationTab.integrationDisabledNotice": "The integration is disabled. You can save schedules now, but runs will stay blocked until the integration is enabled.",
84
85
  "data_sync.integrationTab.loadError": "Failed to load sync schedules.",
85
86
  "data_sync.integrationTab.notAvailable": "This integration is not registered as a data sync provider.",
87
+ "data_sync.integrationTab.providerManaged": "Start this integration from its provider-specific setup flow.",
88
+ "data_sync.integrationTab.providerManagedNotice": "This provider needs its own setup flow before a run can start. Use the provider tab on this page instead of generic schedules.",
86
89
  "data_sync.integrationTab.refresh": "Refresh",
87
90
  "data_sync.integrationTab.runError": "Failed to start sync.",
88
91
  "data_sync.integrationTab.runNowHelp": "Run now uses the full-sync checkbox from the same row and starts progress tracking immediately in Data Sync.",
@@ -54,6 +54,7 @@
54
54
  "data_sync.dashboard.start.fullSync": "Run as full sync",
55
55
  "data_sync.dashboard.start.fullSyncHelp": "Ignore the saved cursor and process the entire source again for this run.",
56
56
  "data_sync.dashboard.start.invalidBatchSize": "Batch size must be between 1 and 1000.",
57
+ "data_sync.dashboard.start.providerManaged": "Esta integración inicia las sincronizaciones desde su propio flujo de configuración. Abre la página de ajustes de la integración para continuar.",
57
58
  "data_sync.dashboard.start.runNowDescription": "Use this for the next immediate sync. Batch size and full-sync mode apply only to this manual run.",
58
59
  "data_sync.dashboard.start.runNowFootnote": "Manual runs show progress immediately and land on the run detail page after launch.",
59
60
  "data_sync.dashboard.start.runNowTitle": "Run once now",
@@ -83,6 +84,8 @@
83
84
  "data_sync.integrationTab.integrationDisabledNotice": "The integration is disabled. You can save schedules now, but runs will stay blocked until the integration is enabled.",
84
85
  "data_sync.integrationTab.loadError": "Failed to load sync schedules.",
85
86
  "data_sync.integrationTab.notAvailable": "This integration is not registered as a data sync provider.",
87
+ "data_sync.integrationTab.providerManaged": "Inicia esta integración desde su flujo de configuración específico del proveedor.",
88
+ "data_sync.integrationTab.providerManagedNotice": "Este proveedor necesita su propio flujo de configuración antes de poder iniciar una ejecución. Usa la pestaña del proveedor en esta página en lugar de los calendarios genéricos.",
86
89
  "data_sync.integrationTab.refresh": "Refresh",
87
90
  "data_sync.integrationTab.runError": "Failed to start sync.",
88
91
  "data_sync.integrationTab.runNowHelp": "Run now uses the full-sync checkbox from the same row and starts progress tracking immediately in Data Sync.",
@@ -54,6 +54,7 @@
54
54
  "data_sync.dashboard.start.fullSync": "Run as full sync",
55
55
  "data_sync.dashboard.start.fullSyncHelp": "Ignore the saved cursor and process the entire source again for this run.",
56
56
  "data_sync.dashboard.start.invalidBatchSize": "Batch size must be between 1 and 1000.",
57
+ "data_sync.dashboard.start.providerManaged": "Ta integracja uruchamia synchronizacje z własnego przepływu konfiguracji. Otwórz ustawienia integracji, aby kontynuować.",
57
58
  "data_sync.dashboard.start.runNowDescription": "Use this for the next immediate sync. Batch size and full-sync mode apply only to this manual run.",
58
59
  "data_sync.dashboard.start.runNowFootnote": "Manual runs show progress immediately and land on the run detail page after launch.",
59
60
  "data_sync.dashboard.start.runNowTitle": "Run once now",
@@ -83,6 +84,8 @@
83
84
  "data_sync.integrationTab.integrationDisabledNotice": "The integration is disabled. You can save schedules now, but runs will stay blocked until the integration is enabled.",
84
85
  "data_sync.integrationTab.loadError": "Failed to load sync schedules.",
85
86
  "data_sync.integrationTab.notAvailable": "This integration is not registered as a data sync provider.",
87
+ "data_sync.integrationTab.providerManaged": "Uruchom tę integrację z jej przepływu konfiguracji dostawcy.",
88
+ "data_sync.integrationTab.providerManagedNotice": "Ten dostawca wymaga własnego przepływu konfiguracji, zanim będzie można uruchomić synchronizację. Użyj karty dostawcy na tej stronie zamiast ogólnych harmonogramów.",
86
89
  "data_sync.integrationTab.refresh": "Refresh",
87
90
  "data_sync.integrationTab.runError": "Failed to start sync.",
88
91
  "data_sync.integrationTab.runNowHelp": "Run now uses the full-sync checkbox from the same row and starts progress tracking immediately in Data Sync.",
@@ -93,6 +93,15 @@ export interface DataSyncAdapter {
93
93
  readonly providerKey: string
94
94
  readonly direction: 'import' | 'export' | 'bidirectional'
95
95
  readonly supportedEntities: string[]
96
+ /**
97
+ * How a run may be started.
98
+ *
99
+ * - `generic` (default): `/api/data_sync/run` has enough information to
100
+ * create and enqueue the run.
101
+ * - `provider`: the provider owns a prerequisite flow before a run can be
102
+ * enqueued, such as uploading a CSV and linking that upload to the run.
103
+ */
104
+ readonly runMode?: 'generic' | 'provider'
96
105
  readonly operationalTelemetry?: boolean
97
106
 
98
107
  streamImport?(input: StreamImportInput): AsyncIterable<ImportBatch>
@@ -442,15 +442,16 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
442
442
  )
443
443
  }, [integrationId])
444
444
 
445
- const loadDetail = React.useCallback(async () => {
445
+ const loadDetail = React.useCallback(async (options?: { showLoading?: boolean }) => {
446
+ const showLoading = options?.showLoading ?? true
446
447
  const currentIntegrationId = resolveCurrentIntegrationId()
447
448
  if (!currentIntegrationId) {
448
- setIsLoading(false)
449
- setError(t('integrations.detail.loadError', 'Failed to load integration'))
449
+ if (showLoading) setIsLoading(false)
450
+ if (showLoading) setError(t('integrations.detail.loadError', 'Failed to load integration'))
450
451
  return
451
452
  }
452
- setError(null)
453
- setIsLoading(true)
453
+ if (showLoading) setError(null)
454
+ if (showLoading) setIsLoading(true)
454
455
  try {
455
456
  const call = await apiCall<IntegrationDetail>(
456
457
  `/api/integrations/${encodeURIComponent(currentIntegrationId)}`,
@@ -458,15 +459,16 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
458
459
  { fallback: null },
459
460
  )
460
461
  if (!call.ok || !call.result) {
461
- setError(t('integrations.detail.loadError', 'Failed to load integration'))
462
- setIsLoading(false)
462
+ if (showLoading) setError(t('integrations.detail.loadError', 'Failed to load integration'))
463
+ if (showLoading) setIsLoading(false)
463
464
  return
464
465
  }
465
466
  setDetail(call.result)
466
- setIsLoading(false)
467
+ setError(null)
468
+ if (showLoading) setIsLoading(false)
467
469
  } catch {
468
- setError(t('integrations.detail.loadError', 'Failed to load integration'))
469
- setIsLoading(false)
470
+ if (showLoading) setError(t('integrations.detail.loadError', 'Failed to load integration'))
471
+ if (showLoading) setIsLoading(false)
470
472
  }
471
473
  }, [resolveCurrentIntegrationId, t])
472
474
 
@@ -522,14 +524,14 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
522
524
  spotId: detailWidgetSpotId,
523
525
  })
524
526
  const refreshDetail = React.useCallback(async () => {
525
- await loadDetail()
527
+ await loadDetail({ showLoading: false })
526
528
  await loadCredentials()
527
529
  }, [loadCredentials, loadDetail])
528
530
  const refreshLogs = React.useCallback(async () => {
529
531
  await loadLogs()
530
532
  }, [loadLogs])
531
533
  const refreshHealthSnapshot = React.useCallback(async () => {
532
- await loadDetail()
534
+ await loadDetail({ showLoading: false })
533
535
  }, [loadDetail])
534
536
  const runIdFromUrl = searchParams?.get('runId') ?? null
535
537
  const refreshRunActivity = React.useCallback(async (options?: { showLoading?: boolean }) => {
@@ -546,7 +548,7 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
546
548
  { fallback: null },
547
549
  )
548
550
  await loadLogs()
549
- await loadDetail()
551
+ await loadDetail({ showLoading: false })
550
552
  if (call.ok && call.result) {
551
553
  setActiveRunDetail(call.result)
552
554
  setActiveRunRefreshedAt(new Date().toISOString())
@@ -40,14 +40,15 @@ export const listIntegrationLogsQuerySchema = z.object({
40
40
 
41
41
  export type ListIntegrationLogsQuery = z.infer<typeof listIntegrationLogsQuerySchema>
42
42
 
43
- const optionalBooleanQuery = z.union([z.boolean(), z.string(), z.null(), z.undefined()])
44
- .transform((value) => {
43
+ const optionalBooleanQuery = z.preprocess(
44
+ (value) => {
45
45
  if (value === undefined || value === '' || value === null) return undefined
46
46
  if (value === true || value === 'true' || value === '1') return true
47
47
  if (value === false || value === 'false' || value === '0') return false
48
48
  return value
49
- })
50
- .pipe(z.boolean().optional())
49
+ },
50
+ z.boolean().optional(),
51
+ )
51
52
 
52
53
  export const integrationMarketplaceHealthStatusSchema = z.enum(['healthy', 'degraded', 'unhealthy', 'unconfigured'])
53
54
 
@@ -168,13 +168,13 @@ export async function GET(req: Request) {
168
168
  eb.selectFrom('attachments')
169
169
  .select(sql<number>`1`.as('one'))
170
170
  .where('attachments.entity_id', '=', MESSAGE_ATTACHMENT_ENTITY_ID)
171
- .whereRef('attachments.record_id', '=', 'm.id')
171
+ .where(sql<boolean>`attachments.record_id = m.id::text`)
172
172
  )
173
173
  const notExistsFn = (eb: any) => eb.not(eb.exists(
174
174
  eb.selectFrom('attachments')
175
175
  .select(sql<number>`1`.as('one'))
176
176
  .where('attachments.entity_id', '=', MESSAGE_ATTACHMENT_ENTITY_ID)
177
- .whereRef('attachments.record_id', '=', 'm.id')
177
+ .where(sql<boolean>`attachments.record_id = m.id::text`)
178
178
  ))
179
179
  q = input.hasAttachments ? q.where(existsFn) : q.where(notExistsFn)
180
180
  }
@@ -2,7 +2,7 @@ import type { EntityManager } from '@mikro-orm/postgresql'
2
2
  import { z } from 'zod'
3
3
  import { registerCommand, type CommandHandler } from '@open-mercato/shared/lib/commands'
4
4
  import { extractUndoPayload, type UndoPayload } from '@open-mercato/shared/lib/commands/undo'
5
- import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
+ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
6
6
  import { Message, MessageObject, MessageRecipient, type MessageActionData } from '../data/entities'
7
7
  import { emitMessagesEvent } from '../events'
8
8
  import {
@@ -368,6 +368,20 @@ const updateDraftCommand: CommandHandler<unknown, { ok: true; id: string }> = {
368
368
  if (message.senderUserId !== input.userId) throw new Error('Access denied')
369
369
  if (!message.isDraft) throw new Error('Only draft messages can be edited')
370
370
 
371
+ const isSending = input.isDraft === false
372
+ const preloadedRecipients = isSending && !input.recipients
373
+ ? await findWithDecryption(
374
+ em,
375
+ MessageRecipient,
376
+ { messageId: message.id, deletedAt: null },
377
+ undefined,
378
+ {
379
+ tenantId: input.tenantId,
380
+ organizationId: input.organizationId,
381
+ },
382
+ )
383
+ : null
384
+
371
385
  const nextMessageType = input.type ?? message.type
372
386
  if (input.objects) {
373
387
  const objectValidationError = validateMessageObjectsForType(nextMessageType, input.objects)
@@ -454,12 +468,50 @@ const updateDraftCommand: CommandHandler<unknown, { ok: true; id: string }> = {
454
468
  )
455
469
  }
456
470
 
471
+ if (isSending) {
472
+ const finalVisibility = input.visibility ?? message.visibility
473
+ const finalSubject = input.subject ?? message.subject
474
+ const finalBody = input.body ?? message.body
475
+ const finalRecipientCount = input.recipients
476
+ ? input.recipients.length
477
+ : (preloadedRecipients?.length ?? 0)
478
+
479
+ if (finalVisibility !== 'public' && finalRecipientCount === 0) {
480
+ throw new Error('at least one recipient is required')
481
+ }
482
+ if (!finalSubject?.trim()) throw new Error('subject is required')
483
+ if (!finalBody?.trim()) throw new Error('body is required')
484
+
485
+ message.isDraft = false
486
+ message.status = 'sent'
487
+ message.sentAt = new Date()
488
+ if (!message.threadId) message.threadId = message.id
489
+ }
490
+
457
491
  await em.flush()
458
492
  await emitMessageIndexUpsert(ctx.container, {
459
493
  messageId: message.id,
460
494
  tenantId: input.tenantId,
461
495
  organizationId: input.organizationId,
462
496
  })
497
+
498
+ if (isSending) {
499
+ const recipientUserIds = input.recipients
500
+ ? input.recipients.map((r) => r.userId)
501
+ : (preloadedRecipients ?? []).map((r) => r.recipientUserId)
502
+ const resolvedVisibility = input.visibility ?? message.visibility
503
+ const resolvedSendViaEmail = input.sendViaEmail ?? message.sendViaEmail
504
+ await emitMessageSentEvent(ctx.container, {
505
+ messageId: message.id,
506
+ senderUserId: input.userId,
507
+ recipientUserIds,
508
+ sendViaEmail: resolvedVisibility === 'public' ? true : resolvedSendViaEmail,
509
+ externalEmail: message.externalEmail ?? null,
510
+ tenantId: input.tenantId,
511
+ organizationId: input.organizationId,
512
+ })
513
+ }
514
+
463
515
  return { ok: true, id: message.id }
464
516
  },
465
517
  async captureAfter(rawInput, _result, ctx) {
@@ -473,7 +525,7 @@ const updateDraftCommand: CommandHandler<unknown, { ok: true; id: string }> = {
473
525
  buildLog: async ({ input, snapshots }) => {
474
526
  const parsed = updateDraftCommandSchema.parse(input)
475
527
  return {
476
- actionLabel: 'Update draft message',
528
+ actionLabel: parsed.isDraft === false ? 'Send draft message' : 'Update draft message',
477
529
  resourceKind: 'messages.message',
478
530
  resourceId: parsed.messageId,
479
531
  tenantId: parsed.tenantId,
@@ -19,6 +19,11 @@ import { DefaultMessageListItem } from './defaults/DefaultMessageListItem'
19
19
  import { getMessageListParticipantLabel } from './messageListLabels'
20
20
  import { toErrorMessage } from './message-detail/utils'
21
21
  import { useMessagesInboxBulkActions, type MessageFolder } from './useMessagesInboxBulkActions'
22
+ import {
23
+ buildMessagesInboxFilters,
24
+ buildMessagesListParams,
25
+ type SenderOption,
26
+ } from './inboxFilters'
22
27
 
23
28
  type MessageListItem = {
24
29
  id: string
@@ -106,30 +111,13 @@ export function MessagesInboxPageClient() {
106
111
  scopeVersion,
107
112
  ],
108
113
  queryFn: async () => {
109
- const params = new URLSearchParams()
110
- params.set('folder', folder)
111
- params.set('page', String(page))
112
- params.set('pageSize', String(pageSize))
113
-
114
- if (search.trim()) {
115
- params.set('search', search.trim())
116
- }
117
-
118
- const status = typeof filterValues.status === 'string' ? filterValues.status.trim() : ''
119
- const type = typeof filterValues.type === 'string' ? filterValues.type.trim() : ''
120
- const hasObjects = typeof filterValues.hasObjects === 'string' ? filterValues.hasObjects.trim() : ''
121
- const hasAttachments = typeof filterValues.hasAttachments === 'string' ? filterValues.hasAttachments.trim() : ''
122
- const hasActions = typeof filterValues.hasActions === 'string' ? filterValues.hasActions.trim() : ''
123
- const senderId = typeof filterValues.senderId === 'string' ? filterValues.senderId.trim() : ''
124
- const since = typeof filterValues.since === 'string' ? filterValues.since.trim() : ''
125
-
126
- if (status) params.set('status', status)
127
- if (type) params.set('type', type)
128
- if (hasObjects) params.set('hasObjects', hasObjects)
129
- if (hasAttachments) params.set('hasAttachments', hasAttachments)
130
- if (hasActions) params.set('hasActions', hasActions)
131
- if (senderId) params.set('senderId', senderId)
132
- if (since) params.set('since', since)
114
+ const params = buildMessagesListParams({
115
+ folder,
116
+ page,
117
+ pageSize,
118
+ search,
119
+ filterValues,
120
+ })
133
121
 
134
122
  const call = await apiCall<MessageListResponse>(`/api/messages?${params.toString()}`)
135
123
  if (!call.ok) {
@@ -191,6 +179,19 @@ export function MessagesInboxPageClient() {
191
179
  return map
192
180
  }, [messageTypesQuery.data, t])
193
181
 
182
+ const [senderOptions, setSenderOptions] = React.useState<SenderOption[]>([])
183
+ const senderOptionsScopeRef = React.useRef(scopeVersion)
184
+
185
+ const mergeSenderOptions = React.useCallback((incoming: SenderOption[]) => {
186
+ if (incoming.length === 0) return
187
+ setSenderOptions((prev) => {
188
+ const map = new Map<string, SenderOption>()
189
+ for (const opt of prev) map.set(opt.value, opt)
190
+ for (const opt of incoming) map.set(opt.value, opt)
191
+ return Array.from(map.values())
192
+ })
193
+ }, [])
194
+
194
195
  const loadSenderOptions = React.useCallback(async (query?: string) => {
195
196
  const params = new URLSearchParams()
196
197
  params.set('page', '1')
@@ -199,11 +200,19 @@ export function MessagesInboxPageClient() {
199
200
  params.set('search', query.trim())
200
201
  }
201
202
 
202
- const call = await apiCall<{ items?: UserListItem[] }>(`/api/auth/users?${params.toString()}`)
203
+ const call = await apiCall<{ items?: UserListItem[] }>(
204
+ `/api/auth/users?${params.toString()}`,
205
+ {
206
+ headers: {
207
+ 'x-om-forbidden-redirect': '0',
208
+ },
209
+ },
210
+ ).catch(() => null)
211
+ if (!call) return []
203
212
  if (!call.ok) return []
204
213
 
205
214
  const items = Array.isArray(call.result?.items) ? call.result?.items ?? [] : []
206
- return items.flatMap((item) => {
215
+ const next: SenderOption[] = items.flatMap((item) => {
207
216
  if (!item || typeof item.id !== 'string' || item.id.trim().length === 0) return []
208
217
  const name = typeof item.name === 'string' && item.name.trim().length > 0 ? item.name.trim() : null
209
218
  const email = typeof item.email === 'string' && item.email.trim().length > 0 ? item.email.trim() : null
@@ -214,7 +223,39 @@ export function MessagesInboxPageClient() {
214
223
  description: email && email !== label ? email : null,
215
224
  }]
216
225
  })
217
- }, [])
226
+ if (senderOptionsScopeRef.current === scopeVersion) {
227
+ mergeSenderOptions(next)
228
+ }
229
+ return next
230
+ }, [mergeSenderOptions, scopeVersion])
231
+
232
+ React.useEffect(() => {
233
+ const items = listQuery.data?.items ?? []
234
+ const next = items.flatMap((item): SenderOption[] => {
235
+ if (typeof item.senderUserId !== 'string' || item.senderUserId.trim().length === 0) return []
236
+ const name = typeof item.senderName === 'string' && item.senderName.trim().length > 0
237
+ ? item.senderName.trim()
238
+ : null
239
+ const email = typeof item.senderEmail === 'string' && item.senderEmail.trim().length > 0
240
+ ? item.senderEmail.trim()
241
+ : null
242
+ const label = name ?? email ?? item.senderUserId
243
+ return [{
244
+ value: item.senderUserId,
245
+ label,
246
+ description: email && email !== label ? email : null,
247
+ }]
248
+ })
249
+ mergeSenderOptions(next)
250
+ }, [listQuery.data?.items, mergeSenderOptions])
251
+
252
+ React.useEffect(() => {
253
+ senderOptionsScopeRef.current = scopeVersion
254
+ setSenderOptions([])
255
+ loadSenderOptions().catch((error: unknown) => {
256
+ console.warn('[messages] Failed to load sender filter options', error)
257
+ })
258
+ }, [loadSenderOptions, scopeVersion])
218
259
 
219
260
  const listItemComponentKeyByType = React.useMemo(() => {
220
261
  const map: Record<string, string | null> = {}
@@ -230,69 +271,13 @@ export function MessagesInboxPageClient() {
230
271
  label: t(item.labelKey, item.type),
231
272
  }))
232
273
 
233
- return [
234
- {
235
- id: 'status',
236
- label: t('messages.filters.status', 'Status'),
237
- type: 'select',
238
- options: [
239
- { value: '', label: t('messages.filters.all', 'All') },
240
- { value: 'unread', label: t('messages.status.unread', 'Unread') },
241
- { value: 'read', label: t('messages.status.read', 'Read') },
242
- { value: 'archived', label: t('messages.status.archived', 'Archived') },
243
- ],
244
- },
245
- {
246
- id: 'type',
247
- label: t('messages.filters.type', 'Type'),
248
- type: 'select',
249
- options: [{ value: '', label: t('messages.filters.all', 'All') }, ...typeOptions],
250
- },
251
- {
252
- id: 'hasObjects',
253
- label: t('messages.filters.hasObjects', 'Objects'),
254
- type: 'select',
255
- options: [
256
- { value: '', label: t('messages.filters.all', 'All') },
257
- { value: 'true', label: t('common.yes', 'Yes') },
258
- { value: 'false', label: t('common.no', 'No') },
259
- ],
260
- },
261
- {
262
- id: 'hasAttachments',
263
- label: t('messages.filters.hasAttachments', 'Attachments'),
264
- type: 'select',
265
- options: [
266
- { value: '', label: t('messages.filters.all', 'All') },
267
- { value: 'true', label: t('common.yes', 'Yes') },
268
- { value: 'false', label: t('common.no', 'No') },
269
- ],
270
- },
271
- {
272
- id: 'hasActions',
273
- label: t('messages.filters.hasActions', 'Actions'),
274
- type: 'select',
275
- options: [
276
- { value: '', label: t('messages.filters.all', 'All') },
277
- { value: 'true', label: t('common.yes', 'Yes') },
278
- { value: 'false', label: t('common.no', 'No') },
279
- ],
280
- },
281
- {
282
- id: 'senderId',
283
- label: t('messages.filters.sender', 'Sender'),
284
- type: 'select',
285
- options: [{ value: '', label: t('messages.filters.all', 'All') }],
286
- loadOptions: loadSenderOptions,
287
- },
288
- {
289
- id: 'since',
290
- label: t('messages.filters.since', 'Sent after'),
291
- type: 'text',
292
- placeholder: t('messages.filters.sincePlaceholder', 'YYYY-MM-DDTHH:mm:ssZ'),
293
- },
294
- ]
295
- }, [loadSenderOptions, messageTypesQuery.data, t])
274
+ return buildMessagesInboxFilters({
275
+ t,
276
+ typeOptions,
277
+ senderOptions,
278
+ loadSenderOptions,
279
+ })
280
+ }, [loadSenderOptions, messageTypesQuery.data, senderOptions, t])
296
281
 
297
282
  const columns = React.useMemo<ColumnDef<MessageListItem>[]>(() => [
298
283
  {