@open-mercato/core 0.6.8-develop.6891.1.4dca3f1ad3 → 0.6.8-develop.6893.1.7af3b3a72d

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 (31) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/communication_channels/api/get/me/channels/route.js +16 -2
  3. package/dist/modules/communication_channels/api/get/me/channels/route.js.map +2 -2
  4. package/dist/modules/communication_channels/api/post/channels/[id]/poll-now/route.js +14 -1
  5. package/dist/modules/communication_channels/api/post/channels/[id]/poll-now/route.js.map +2 -2
  6. package/dist/modules/communication_channels/backend/profile/communication-channels/page.js +24 -11
  7. package/dist/modules/communication_channels/backend/profile/communication-channels/page.js.map +2 -2
  8. package/dist/modules/communication_channels/lib/polling-eligibility.js +8 -0
  9. package/dist/modules/communication_channels/lib/polling-eligibility.js.map +7 -0
  10. package/dist/modules/communication_channels/workers/poll-channel.js +2 -2
  11. package/dist/modules/communication_channels/workers/poll-channel.js.map +2 -2
  12. package/dist/modules/configs/cli.js +12 -10
  13. package/dist/modules/configs/cli.js.map +2 -2
  14. package/dist/modules/configs/lib/touchGeneratedBarrels.js +2 -2
  15. package/dist/modules/configs/lib/touchGeneratedBarrels.js.map +2 -2
  16. package/dist/modules/customers/message-objects.js +1 -1
  17. package/dist/modules/customers/message-objects.js.map +1 -1
  18. package/package.json +8 -8
  19. package/src/modules/communication_channels/api/get/me/channels/route.ts +24 -2
  20. package/src/modules/communication_channels/api/post/channels/[id]/poll-now/route.ts +22 -1
  21. package/src/modules/communication_channels/backend/profile/communication-channels/page.tsx +58 -13
  22. package/src/modules/communication_channels/i18n/de.json +6 -0
  23. package/src/modules/communication_channels/i18n/en.json +6 -0
  24. package/src/modules/communication_channels/i18n/es.json +6 -0
  25. package/src/modules/communication_channels/i18n/ko.json +6 -0
  26. package/src/modules/communication_channels/i18n/pl.json +6 -0
  27. package/src/modules/communication_channels/lib/polling-eligibility.ts +14 -0
  28. package/src/modules/communication_channels/workers/poll-channel.ts +3 -3
  29. package/src/modules/configs/cli.ts +12 -10
  30. package/src/modules/configs/lib/touchGeneratedBarrels.ts +5 -2
  31. package/src/modules/customers/message-objects.ts +1 -1
@@ -42,6 +42,13 @@ type ChannelRow = {
42
42
  /** Spec C — push delivery state (null when provider doesn't support push). */
43
43
  pushStatus: 'active' | 'inactive' | 'failed' | null
44
44
  lastPushError: { code: string | null; message: string | null; at: string | null } | null
45
+ /**
46
+ * `true` when the adapter declares real-time push, so the hub's poll worker
47
+ * skips this channel entirely — inbound arrives over the provider connection.
48
+ */
49
+ supportsRealtimePush: boolean
50
+ /** `true` when the adapter implements `registerPush` (Gmail-style subscriptions). */
51
+ supportsPushRegistration: boolean
45
52
  createdAt: string | null
46
53
  }
47
54
 
@@ -314,17 +321,38 @@ export default function ProfileCommunicationChannelsPage() {
314
321
  },
315
322
  {
316
323
  id: 'pushStatus',
317
- header: t('communication_channels.push.status.active', 'Push'),
324
+ // Its own key: sharing `push.status.active` made the header render
325
+ // "Push active" over a column whose rows say "Polling only" (#4980).
326
+ header: t('communication_channels.profile.columns.push', 'Push'),
318
327
  cell: ({ row }) => {
319
- const supportsPush = row.original.providerKey === 'gmail'
320
- if (!supportsPush) {
328
+ const ps = row.original.pushStatus
329
+ const errorTitle = row.original.lastPushError?.message ?? undefined
330
+ // Derived from the adapter's declared capabilities, never from the
331
+ // provider name: `supportsPushRegistration` means push subscriptions
332
+ // can be (re-)registered from here, `supportsRealtimePush` means the
333
+ // hub's poll worker skips the channel because the provider connection
334
+ // delivers inbound itself (#4980).
335
+ if (!row.original.supportsPushRegistration) {
336
+ if (!row.original.supportsRealtimePush) {
337
+ return (
338
+ <span className="text-xs text-muted-foreground">
339
+ {t('communication_channels.push.status.inactive', 'Polling only')}
340
+ </span>
341
+ )
342
+ }
343
+ if (ps === 'failed') {
344
+ return (
345
+ <Tag variant="error" dot title={errorTitle}>
346
+ {t('communication_channels.push.status.pushDrivenFailed', 'Push connection failed')}
347
+ </Tag>
348
+ )
349
+ }
321
350
  return (
322
- <span className="text-xs text-muted-foreground">
323
- {t('communication_channels.push.status.inactive', 'Polling only')}
324
- </span>
351
+ <Tag variant="success" dot>
352
+ {t('communication_channels.push.status.pushDriven', 'Push-driven')}
353
+ </Tag>
325
354
  )
326
355
  }
327
- const ps = row.original.pushStatus
328
356
  if (ps === 'active') {
329
357
  return (
330
358
  <Tag variant="success" dot>
@@ -333,7 +361,6 @@ export default function ProfileCommunicationChannelsPage() {
333
361
  )
334
362
  }
335
363
  if (ps === 'failed') {
336
- const errorMsg = row.original.lastPushError?.message ?? null
337
364
  return (
338
365
  <div className="flex items-center gap-2">
339
366
  <Tag variant="error" dot>
@@ -345,18 +372,23 @@ export default function ProfileCommunicationChannelsPage() {
345
372
  size="sm"
346
373
  onClick={() => void onRegisterPush(row.original.id)}
347
374
  aria-label={t('communication_channels.push.button.reregister', 'Re-register push')}
348
- title={errorMsg ?? undefined}
375
+ title={errorTitle}
349
376
  >
350
377
  {t('communication_channels.push.button.reregister', 'Re-register push')}
351
378
  </Button>
352
379
  </div>
353
380
  )
354
381
  }
355
- // null or 'inactive' — provider supports push but not registered yet.
382
+ // null or 'inactive' — the provider can register push but has not yet.
383
+ // Only a hub-polled channel falls back to polling meanwhile; for a
384
+ // push-driven one nothing is delivering inbound at all, so claiming
385
+ // "Polling only" would repeat the defect this issue is about (#4980).
356
386
  return (
357
387
  <div className="flex items-center gap-2">
358
388
  <span className="text-xs text-muted-foreground">
359
- {t('communication_channels.push.status.inactive', 'Polling only')}
389
+ {row.original.supportsRealtimePush
390
+ ? t('communication_channels.push.status.notRegistered', 'Push not registered')
391
+ : t('communication_channels.push.status.inactive', 'Polling only')}
360
392
  </span>
361
393
  <Button
362
394
  type="button"
@@ -409,25 +441,38 @@ export default function ProfileCommunicationChannelsPage() {
409
441
  // Allowed from 'connected' AND 'error' — the latter lets the user
410
442
  // recover a stuck channel without disconnecting + reconnecting.
411
443
  // 'requires_reauth' and 'disconnected' are owned by other flows.
444
+ // A push-driven channel is never polled by the worker, so offering the
445
+ // action at all would promise a sync that cannot happen (#4980).
446
+ const pushDriven = row.original.supportsRealtimePush
412
447
  const pollable =
413
448
  row.original.isActive &&
449
+ !pushDriven &&
414
450
  (row.original.status === 'connected' || row.original.status === 'error')
415
451
  const label =
416
452
  row.original.status === 'error'
417
453
  ? t('communication_channels.profile.actions.retryPoll', 'Retry')
418
454
  : t('communication_channels.profile.actions.pollNow', 'Poll now')
419
- return (
455
+ const disabledReason = pushDriven
456
+ ? t(
457
+ 'communication_channels.profile.actions.pollNowPushDriven',
458
+ 'This channel is push-driven — inbound messages arrive over the provider connection, so polling does not apply.',
459
+ )
460
+ : undefined
461
+ const button = (
420
462
  <Button
421
463
  type="button"
422
464
  variant={row.original.status === 'error' ? 'default' : 'outline'}
423
465
  size="sm"
424
466
  onClick={() => void onPollNow(row.original.id)}
425
467
  disabled={!pollable}
426
- aria-label={label}
468
+ aria-label={disabledReason ? `${label} — ${disabledReason}` : label}
427
469
  >
428
470
  {label}
429
471
  </Button>
430
472
  )
473
+ // A disabled button does not receive hover events in every browser, so
474
+ // the explanation lives on a wrapper the pointer can still reach.
475
+ return disabledReason ? <span title={disabledReason}>{button}</span> : button
431
476
  },
432
477
  },
433
478
  {
@@ -23,6 +23,7 @@
23
23
  "communication_channels.emptyState": "Noch keine gemeinsamen Kanäle. Diese Seite listet gemeinsame, mandantenweite Kanäle auf. Ihr persönliches E-Mail-Postfach ist privat – verbinden und verwalten Sie es unter Profil → Kommunikationskanäle.",
24
24
  "communication_channels.errors.loadDetail": "Kanal konnte nicht geladen werden",
25
25
  "communication_channels.errors.loadList": "Kanäle konnten nicht geladen werden",
26
+ "communication_channels.errors.pollNowPushDriven": "Der Kanal ist Push-gesteuert — Abrufen ist nicht anwendbar. Eingehende Nachrichten kommen über die Push-Verbindung des Anbieters.",
26
27
  "communication_channels.errors.reactionFailed": "Reaktion fehlgeschlagen",
27
28
  "communication_channels.errors.undoBlockedPrimaryConflict": "Ein anderer Kanal ist jetzt für diesen Benutzer als primär festgelegt. Setze ihn auf nicht-primär, bevor du den getrennten Kanal wieder als primär festlegst.",
28
29
  "communication_channels.infoPanel.aria": "Kanalinfo",
@@ -53,6 +54,7 @@
53
54
  "communication_channels.profile.actions.importHistory": "Verlauf importieren",
54
55
  "communication_channels.profile.actions.pollNow": "Jetzt abrufen",
55
56
  "communication_channels.profile.actions.pollNowFailed": "Abruf konnte nicht ausgelöst werden",
57
+ "communication_channels.profile.actions.pollNowPushDriven": "Dieser Kanal ist Push-gesteuert — eingehende Nachrichten kommen über die Anbieterverbindung, Abrufen ist daher nicht anwendbar.",
56
58
  "communication_channels.profile.actions.pollNowSuccess": "Abruf ausgelöst — neue Nachrichten erscheinen in wenigen Sekunden auf den verknüpften Personen-Zeitleisten.",
57
59
  "communication_channels.profile.actions.retryPoll": "Wiederholen",
58
60
  "communication_channels.profile.actions.setPrimary": "Als primär festlegen",
@@ -64,6 +66,7 @@
64
66
  "communication_channels.profile.columns.lastPolled": "Zuletzt synchronisiert",
65
67
  "communication_channels.profile.columns.pollNow": "Synchronisieren",
66
68
  "communication_channels.profile.columns.primary": "Primär",
69
+ "communication_channels.profile.columns.push": "Push",
67
70
  "communication_channels.profile.connect.blocked": "Verbindung durch Validierung blockiert",
68
71
  "communication_channels.profile.connect.cancel": "Abbrechen",
69
72
  "communication_channels.profile.connect.connected": "Kanal verbunden.",
@@ -126,6 +129,9 @@
126
129
  "communication_channels.push.status.active": "Push aktiv",
127
130
  "communication_channels.push.status.failed": "Push fehlgeschlagen — nutze Polling",
128
131
  "communication_channels.push.status.inactive": "Nur Polling",
132
+ "communication_channels.push.status.notRegistered": "Push nicht registriert",
133
+ "communication_channels.push.status.pushDriven": "Push-gesteuert",
134
+ "communication_channels.push.status.pushDrivenFailed": "Push-Verbindung fehlgeschlagen",
129
135
  "communication_channels.reaction.bar.aria": "Reaktionen",
130
136
  "communication_channels.reaction.cannotRemoveExternal": "Reaktionen externer Teilnehmer können nur in der Anbieter-App entfernt werden.",
131
137
  "communication_channels.reaction.toggleAria": "Reaktion {emoji} umschalten",
@@ -23,6 +23,7 @@
23
23
  "communication_channels.emptyState": "No shared channels yet. This page lists shared, tenant-wide channels. Your personal email mailbox is private — connect and manage it under Profile → Communication Channels.",
24
24
  "communication_channels.errors.loadDetail": "Failed to load channel",
25
25
  "communication_channels.errors.loadList": "Failed to load channels",
26
+ "communication_channels.errors.pollNowPushDriven": "Channel is push-driven — polling does not apply. Inbound messages arrive through the provider push connection.",
26
27
  "communication_channels.errors.reactionFailed": "Reaction failed",
27
28
  "communication_channels.errors.undoBlockedPrimaryConflict": "Another channel is now primary for this user. Set it as non-primary before restoring the disconnected channel as primary.",
28
29
  "communication_channels.infoPanel.aria": "Channel info",
@@ -53,6 +54,7 @@
53
54
  "communication_channels.profile.actions.importHistory": "Import history",
54
55
  "communication_channels.profile.actions.pollNow": "Poll now",
55
56
  "communication_channels.profile.actions.pollNowFailed": "Failed to trigger poll",
57
+ "communication_channels.profile.actions.pollNowPushDriven": "This channel is push-driven — inbound messages arrive over the provider connection, so polling does not apply.",
56
58
  "communication_channels.profile.actions.pollNowSuccess": "Poll triggered — new messages will appear on linked Person timelines in a few seconds.",
57
59
  "communication_channels.profile.actions.retryPoll": "Retry",
58
60
  "communication_channels.profile.actions.setPrimary": "Set as primary",
@@ -64,6 +66,7 @@
64
66
  "communication_channels.profile.columns.lastPolled": "Last synced",
65
67
  "communication_channels.profile.columns.pollNow": "Sync",
66
68
  "communication_channels.profile.columns.primary": "Primary",
69
+ "communication_channels.profile.columns.push": "Push",
67
70
  "communication_channels.profile.connect.blocked": "Connection blocked by validation",
68
71
  "communication_channels.profile.connect.cancel": "Cancel",
69
72
  "communication_channels.profile.connect.connected": "Channel connected.",
@@ -126,6 +129,9 @@
126
129
  "communication_channels.push.status.active": "Push active",
127
130
  "communication_channels.push.status.failed": "Push failed — using polling",
128
131
  "communication_channels.push.status.inactive": "Polling only",
132
+ "communication_channels.push.status.notRegistered": "Push not registered",
133
+ "communication_channels.push.status.pushDriven": "Push-driven",
134
+ "communication_channels.push.status.pushDrivenFailed": "Push connection failed",
129
135
  "communication_channels.reaction.bar.aria": "Reactions",
130
136
  "communication_channels.reaction.cannotRemoveExternal": "Reactions from external participants can only be removed in the provider app.",
131
137
  "communication_channels.reaction.toggleAria": "Toggle {emoji} reaction",
@@ -23,6 +23,7 @@
23
23
  "communication_channels.emptyState": "Aún no hay canales compartidos. Esta página muestra los canales compartidos de toda la organización. Tu buzón de correo personal es privado: conéctalo y gestiónalo en Perfil → Canales de comunicación.",
24
24
  "communication_channels.errors.loadDetail": "No se pudo cargar el canal",
25
25
  "communication_channels.errors.loadList": "No se pudieron cargar los canales",
26
+ "communication_channels.errors.pollNowPushDriven": "El canal funciona con push — el sondeo no aplica. Los mensajes entrantes llegan por la conexión push del proveedor.",
26
27
  "communication_channels.errors.reactionFailed": "La reacción falló",
27
28
  "communication_channels.errors.undoBlockedPrimaryConflict": "Otro canal es ahora el principal para este usuario. Configúralo como no principal antes de restaurar el canal desconectado como principal.",
28
29
  "communication_channels.infoPanel.aria": "Información del canal",
@@ -53,6 +54,7 @@
53
54
  "communication_channels.profile.actions.importHistory": "Importar historial",
54
55
  "communication_channels.profile.actions.pollNow": "Sondear ahora",
55
56
  "communication_channels.profile.actions.pollNowFailed": "No se pudo iniciar el sondeo",
57
+ "communication_channels.profile.actions.pollNowPushDriven": "Este canal funciona con push — los mensajes entrantes llegan por la conexión del proveedor, por lo que el sondeo no aplica.",
56
58
  "communication_channels.profile.actions.pollNowSuccess": "Sondeo iniciado — los nuevos mensajes aparecerán en las líneas de tiempo de las personas vinculadas en unos segundos.",
57
59
  "communication_channels.profile.actions.retryPoll": "Reintentar",
58
60
  "communication_channels.profile.actions.setPrimary": "Establecer como principal",
@@ -64,6 +66,7 @@
64
66
  "communication_channels.profile.columns.lastPolled": "Última sincronización",
65
67
  "communication_channels.profile.columns.pollNow": "Sincronizar",
66
68
  "communication_channels.profile.columns.primary": "Principal",
69
+ "communication_channels.profile.columns.push": "Push",
67
70
  "communication_channels.profile.connect.blocked": "Conexión bloqueada por la validación",
68
71
  "communication_channels.profile.connect.cancel": "Cancelar",
69
72
  "communication_channels.profile.connect.connected": "Canal conectado.",
@@ -126,6 +129,9 @@
126
129
  "communication_channels.push.status.active": "Push activo",
127
130
  "communication_channels.push.status.failed": "Push fallido — usando sondeo",
128
131
  "communication_channels.push.status.inactive": "Solo sondeo",
132
+ "communication_channels.push.status.notRegistered": "Push no registrado",
133
+ "communication_channels.push.status.pushDriven": "Basado en push",
134
+ "communication_channels.push.status.pushDrivenFailed": "Error en la conexión push",
129
135
  "communication_channels.reaction.bar.aria": "Reacciones",
130
136
  "communication_channels.reaction.cannotRemoveExternal": "Las reacciones de participantes externos solo se pueden eliminar en la aplicación del proveedor.",
131
137
  "communication_channels.reaction.toggleAria": "Alternar reacción {emoji}",
@@ -23,6 +23,7 @@
23
23
  "communication_channels.emptyState": "아직 공유 채널이 없습니다. 이 페이지에는 공유되는 테넌트 전체 채널이 나열됩니다. 개인 이메일 메일함은 비공개이며, 프로필 → 통신 채널에서 연결하고 관리하세요.",
24
24
  "communication_channels.errors.loadDetail": "채널을 불러오지 못했습니다",
25
25
  "communication_channels.errors.loadList": "채널을 불러오지 못했습니다",
26
+ "communication_channels.errors.pollNowPushDriven": "이 채널은 푸시 기반입니다 — 폴링은 적용되지 않습니다. 수신 메시지는 공급자의 푸시 연결로 전달됩니다.",
26
27
  "communication_channels.errors.reactionFailed": "반응 처리에 실패했습니다",
27
28
  "communication_channels.errors.undoBlockedPrimaryConflict": "다른 채널이 현재 이 사용자의 기본 채널로 설정되어 있습니다. 연결이 끊어진 채널을 기본으로 복원하기 전에 해당 채널을 기본이 아닌 채널로 설정하세요.",
28
29
  "communication_channels.infoPanel.aria": "채널 정보",
@@ -53,6 +54,7 @@
53
54
  "communication_channels.profile.actions.importHistory": "기록 가져오기",
54
55
  "communication_channels.profile.actions.pollNow": "지금 폴링",
55
56
  "communication_channels.profile.actions.pollNowFailed": "폴링 실행에 실패했습니다",
57
+ "communication_channels.profile.actions.pollNowPushDriven": "이 채널은 푸시 기반입니다 — 수신 메시지가 공급자 연결로 전달되므로 폴링은 적용되지 않습니다.",
56
58
  "communication_channels.profile.actions.pollNowSuccess": "폴링이 실행되었습니다 — 몇 초 후 연결된 사람 타임라인에 새 메시지가 표시됩니다.",
57
59
  "communication_channels.profile.actions.retryPoll": "재시도",
58
60
  "communication_channels.profile.actions.setPrimary": "기본으로 설정",
@@ -64,6 +66,7 @@
64
66
  "communication_channels.profile.columns.lastPolled": "마지막 동기화",
65
67
  "communication_channels.profile.columns.pollNow": "동기화",
66
68
  "communication_channels.profile.columns.primary": "기본",
69
+ "communication_channels.profile.columns.push": "푸시",
67
70
  "communication_channels.profile.connect.blocked": "유효성 검사로 인해 연결이 차단되었습니다",
68
71
  "communication_channels.profile.connect.cancel": "취소",
69
72
  "communication_channels.profile.connect.connected": "채널이 연결되었습니다.",
@@ -126,6 +129,9 @@
126
129
  "communication_channels.push.status.active": "푸시 활성",
127
130
  "communication_channels.push.status.failed": "푸시 실패 — 폴링 사용 중",
128
131
  "communication_channels.push.status.inactive": "폴링만 사용",
132
+ "communication_channels.push.status.notRegistered": "푸시 미등록",
133
+ "communication_channels.push.status.pushDriven": "푸시 기반",
134
+ "communication_channels.push.status.pushDrivenFailed": "푸시 연결 실패",
129
135
  "communication_channels.reaction.bar.aria": "반응",
130
136
  "communication_channels.reaction.cannotRemoveExternal": "외부 참여자의 반응은 제공업체 앱에서만 제거할 수 있습니다.",
131
137
  "communication_channels.reaction.toggleAria": "{emoji} 반응 전환",
@@ -23,6 +23,7 @@
23
23
  "communication_channels.emptyState": "Brak kanałów współdzielonych. Ta strona pokazuje współdzielone kanały obejmujące całą organizację. Twoja osobista skrzynka e-mail jest prywatna — połącz ją i zarządzaj nią w Profil → Kanały komunikacji.",
24
24
  "communication_channels.errors.loadDetail": "Nie udało się załadować kanału",
25
25
  "communication_channels.errors.loadList": "Nie udało się załadować kanałów",
26
+ "communication_channels.errors.pollNowPushDriven": "Kanał działa w trybie push — odpytywanie go nie dotyczy. Wiadomości przychodzące docierają połączeniem push dostawcy.",
26
27
  "communication_channels.errors.reactionFailed": "Reakcja nie powiodła się",
27
28
  "communication_channels.errors.undoBlockedPrimaryConflict": "Inny kanał jest teraz głównym dla tego użytkownika. Ustaw go jako niegłówny, zanim przywrócisz rozłączony kanał jako główny.",
28
29
  "communication_channels.infoPanel.aria": "Informacje o kanale",
@@ -53,6 +54,7 @@
53
54
  "communication_channels.profile.actions.importHistory": "Importuj historię",
54
55
  "communication_channels.profile.actions.pollNow": "Pobierz teraz",
55
56
  "communication_channels.profile.actions.pollNowFailed": "Nie udało się uruchomić pobierania",
57
+ "communication_channels.profile.actions.pollNowPushDriven": "Ten kanał działa w trybie push — wiadomości przychodzące docierają połączeniem dostawcy, więc odpytywanie go nie dotyczy.",
56
58
  "communication_channels.profile.actions.pollNowSuccess": "Pobieranie uruchomione — nowe wiadomości pojawią się na osiach czasu osób w ciągu kilku sekund.",
57
59
  "communication_channels.profile.actions.retryPoll": "Ponów",
58
60
  "communication_channels.profile.actions.setPrimary": "Ustaw jako główny",
@@ -64,6 +66,7 @@
64
66
  "communication_channels.profile.columns.lastPolled": "Ostatnia synchronizacja",
65
67
  "communication_channels.profile.columns.pollNow": "Synchronizacja",
66
68
  "communication_channels.profile.columns.primary": "Główny",
69
+ "communication_channels.profile.columns.push": "Push",
67
70
  "communication_channels.profile.connect.blocked": "Połączenie zablokowane przez walidację",
68
71
  "communication_channels.profile.connect.cancel": "Anuluj",
69
72
  "communication_channels.profile.connect.connected": "Kanał połączony.",
@@ -126,6 +129,9 @@
126
129
  "communication_channels.push.status.active": "Push aktywny",
127
130
  "communication_channels.push.status.failed": "Push nieudany — używam odpytywania",
128
131
  "communication_channels.push.status.inactive": "Tylko odpytywanie",
132
+ "communication_channels.push.status.notRegistered": "Push niezarejestrowany",
133
+ "communication_channels.push.status.pushDriven": "Sterowany push",
134
+ "communication_channels.push.status.pushDrivenFailed": "Połączenie push nie działa",
129
135
  "communication_channels.reaction.bar.aria": "Reakcje",
130
136
  "communication_channels.reaction.cannotRemoveExternal": "Reakcje uczestników zewnętrznych można usunąć tylko w aplikacji dostawcy.",
131
137
  "communication_channels.reaction.toggleAria": "Przełącz reakcję {emoji}",
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Single source of truth for "does the hub poll this channel?".
3
+ *
4
+ * `ChannelCapabilities.realtimePush` is optional and defaults to `true` for
5
+ * back-compat (chat providers predating the flag omit it), so only an explicit
6
+ * `false` opts a channel into hub-managed polling. The poll worker, the manual
7
+ * `poll-now` route and the profile grid all derive their behaviour from this
8
+ * predicate, so the UI can never label a channel the opposite of what the worker
9
+ * actually does (#4980).
10
+ */
11
+ export function isHubPolledChannel(capabilities: unknown): boolean {
12
+ if (!capabilities || typeof capabilities !== 'object' || Array.isArray(capabilities)) return false
13
+ return (capabilities as { realtimePush?: unknown }).realtimePush === false
14
+ }
@@ -9,6 +9,7 @@ import {
9
9
  } from '../commands/ingest-inbound-message'
10
10
  import { COMMUNICATION_CHANNELS_QUEUES, getCommunicationChannelsQueue } from '../lib/queue'
11
11
  import { preservePushState } from '../lib/push-state'
12
+ import { isHubPolledChannel } from '../lib/polling-eligibility'
12
13
  import { writeIngestDeadLetter } from '../lib/dead-letter'
13
14
  import { classifyOutboundError, computeBackoffMs, isReauthError } from '../lib/error-classification'
14
15
  import { refreshCredentialsIfNeeded } from '../lib/credential-refresh'
@@ -123,9 +124,8 @@ export default async function handle(
123
124
  logger.warn('no adapter for provider', { providerKey: channel.providerKey, channelId })
124
125
  return
125
126
  }
126
- // Adapter opted out of polling — webhook providers.
127
- const capabilities = (channel.capabilities as { realtimePush?: boolean } | null) ?? null
128
- if (capabilities?.realtimePush !== false) {
127
+ // Adapter opted out of polling — webhook and gateway providers.
128
+ if (!isHubPolledChannel(channel.capabilities)) {
129
129
  // realtimePush is `true` (default for back-compat) — don't poll push providers.
130
130
  return
131
131
  }
@@ -158,12 +158,12 @@ function printCacheHelp() {
158
158
  console.log(' yarn mercato configs cache purge --key <key1,key2> [--tenant <id> | --global | --all-tenants] [--dry-run] [--json]')
159
159
  console.log(' yarn mercato configs cache purge --id <token1,token2> [--tenant <id> | --global | --all-tenants] [--dry-run] [--json]')
160
160
  console.log(' yarn mercato configs cache purge --pattern <glob> [--tenant <id> | --global | --all-tenants] [--dry-run] [--json]')
161
- console.log(' yarn mercato configs cache structural [--tenant <id> | --global | --all-tenants] [--dry-run] [--json]')
161
+ console.log(' yarn mercato configs cache structural [--tenant <id> | --global | --all-tenants] [--touch-generated] [--dry-run] [--json]')
162
162
  console.log('')
163
163
  console.log('ℹ️ Notes:')
164
164
  console.log(' `stats` mirrors the cache admin page segment overview for CRUD/widget caches.')
165
165
  console.log(' `purge --id` removes every key whose name contains the provided token (for example a user id or entity id).')
166
- console.log(' `structural` targets navigation/sidebar caches and is the recommended post-step after module/sidebar structure changes.')
166
+ console.log(' `structural` targets navigation/sidebar caches. Add `--touch-generated` only for explicit stale-compiler recovery.')
167
167
  console.log(' When no scope flag is supplied, this command uses the global cache scope only.')
168
168
  }
169
169
 
@@ -278,14 +278,16 @@ async function runStructuralCachePurge(args: ParsedArgs) {
278
278
  if (json) {
279
279
  console.log(JSON.stringify(structuralResults, null, 2))
280
280
  }
281
- const quiet = flagEnabled(args, 'quiet')
282
- try {
283
- touchGeneratedBarrels({ quiet: quiet || json })
284
- } catch (err) {
285
- if (!quiet && !json) {
286
- console.warn(
287
- `[structural] failed to touch generated barrels: ${(err as Error).message ?? err}`,
288
- )
281
+ if (flagEnabled(args, 'touch-generated', 'touchGenerated')) {
282
+ const quiet = flagEnabled(args, 'quiet')
283
+ try {
284
+ touchGeneratedBarrels({ quiet: quiet || json })
285
+ } catch (err) {
286
+ if (!quiet && !json) {
287
+ console.warn(
288
+ `[structural] failed to touch generated barrels: ${(err as Error).message ?? err}`,
289
+ )
290
+ }
289
291
  }
290
292
  }
291
293
  }
@@ -43,13 +43,16 @@ export function touchGeneratedBarrels(
43
43
  }
44
44
 
45
45
  const touched: string[] = []
46
+ const touchedAt = new Date()
46
47
  const entries = fs.readdirSync(generatedDir, { withFileTypes: true })
47
48
  for (const entry of entries) {
48
49
  if (!entry.isFile()) continue
49
50
  if (!TOUCHABLE_PATTERN.test(entry.name)) continue
50
51
  const filePath = path.join(generatedDir, entry.name)
51
- const contents = fs.readFileSync(filePath)
52
- fs.writeFileSync(filePath, contents)
52
+ // Advance mtime without rewriting the bytes: rewriting truncates then refills
53
+ // multi-megabyte registries, and a concurrent Turbopack compile reading one
54
+ // mid-write sees a partial file. Matches `packages/cli/src/lib/post-generate-invalidation.ts`.
55
+ fs.utimesSync(filePath, touchedAt, touchedAt)
53
56
  touched.push(filePath)
54
57
  }
55
58
 
@@ -1,5 +1,5 @@
1
1
  import type { MessageObjectTypeDefinition } from '@open-mercato/shared/modules/messages/types'
2
- import { MessageObjectDetail, MessageObjectPreview } from '@open-mercato/ui'
2
+ import { MessageObjectDetail, MessageObjectPreview } from '@open-mercato/ui/backend/messages'
3
3
 
4
4
  const objectMessageTypes = ['default', 'messages.defaultWithObjects']
5
5