@open-mercato/core 0.8.1-develop.7221.1.ece7ca874e → 0.8.1-develop.7225.1.73190a4a95

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 (87) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.js +166 -0
  3. package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.js.map +7 -0
  4. package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.meta.js +16 -0
  5. package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.meta.js.map +7 -0
  6. package/dist/modules/catalog/backend/catalog/prices/create/page.js +68 -0
  7. package/dist/modules/catalog/backend/catalog/prices/create/page.js.map +7 -0
  8. package/dist/modules/catalog/backend/catalog/prices/create/page.meta.js +25 -0
  9. package/dist/modules/catalog/backend/catalog/prices/create/page.meta.js.map +7 -0
  10. package/dist/modules/catalog/backend/catalog/prices/page.js +11 -0
  11. package/dist/modules/catalog/backend/catalog/prices/page.js.map +7 -0
  12. package/dist/modules/catalog/backend/catalog/prices/page.meta.js +16 -0
  13. package/dist/modules/catalog/backend/catalog/prices/page.meta.js.map +7 -0
  14. package/dist/modules/catalog/components/prices/PriceScopeSelectors.js +201 -0
  15. package/dist/modules/catalog/components/prices/PriceScopeSelectors.js.map +7 -0
  16. package/dist/modules/catalog/components/prices/PricesDataTable.js +273 -0
  17. package/dist/modules/catalog/components/prices/PricesDataTable.js.map +7 -0
  18. package/dist/modules/catalog/components/prices/normalizePriceRecord.js +43 -0
  19. package/dist/modules/catalog/components/prices/normalizePriceRecord.js.map +7 -0
  20. package/dist/modules/catalog/components/prices/priceFormFields.js +195 -0
  21. package/dist/modules/catalog/components/prices/priceFormFields.js.map +7 -0
  22. package/dist/modules/catalog/components/prices/useBatchLabels.js +42 -0
  23. package/dist/modules/catalog/components/prices/useBatchLabels.js.map +7 -0
  24. package/dist/modules/catalog/data/validators.js +21 -2
  25. package/dist/modules/catalog/data/validators.js.map +2 -2
  26. package/dist/modules/catalog/extension-points.js +2 -1
  27. package/dist/modules/catalog/extension-points.js.map +2 -2
  28. package/dist/modules/catalog/lib/pricing.js +37 -8
  29. package/dist/modules/catalog/lib/pricing.js.map +2 -2
  30. package/dist/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.js +88 -0
  31. package/dist/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.js.map +7 -0
  32. package/dist/modules/communication_channels/backend/profile/communication-channels/page.js +6 -12
  33. package/dist/modules/communication_channels/backend/profile/communication-channels/page.js.map +2 -2
  34. package/dist/modules/communication_channels/commands/connect-credential-channel.js +7 -2
  35. package/dist/modules/communication_channels/commands/connect-credential-channel.js.map +2 -2
  36. package/dist/modules/communication_channels/commands/ingest-inbound-message.js +11 -2
  37. package/dist/modules/communication_channels/commands/ingest-inbound-message.js.map +2 -2
  38. package/dist/modules/communication_channels/extension-points.js +1 -1
  39. package/dist/modules/communication_channels/extension-points.js.map +1 -1
  40. package/dist/modules/communication_channels/lib/connect-channel.js +33 -0
  41. package/dist/modules/communication_channels/lib/connect-channel.js.map +2 -2
  42. package/dist/modules/messages/api/route.js +5 -1
  43. package/dist/modules/messages/api/route.js.map +2 -2
  44. package/dist/modules/messages/commands/messages.js +3 -2
  45. package/dist/modules/messages/commands/messages.js.map +2 -2
  46. package/dist/modules/messages/data/validators.js +12 -2
  47. package/dist/modules/messages/data/validators.js.map +2 -2
  48. package/dist/modules/messages/subscribers/message-notification.js +4 -1
  49. package/dist/modules/messages/subscribers/message-notification.js.map +2 -2
  50. package/package.json +7 -7
  51. package/src/modules/catalog/AGENTS.md +6 -2
  52. package/src/modules/catalog/backend/catalog/prices/[id]/edit/page.meta.ts +12 -0
  53. package/src/modules/catalog/backend/catalog/prices/[id]/edit/page.tsx +198 -0
  54. package/src/modules/catalog/backend/catalog/prices/create/page.meta.ts +23 -0
  55. package/src/modules/catalog/backend/catalog/prices/create/page.tsx +70 -0
  56. package/src/modules/catalog/backend/catalog/prices/page.meta.ts +12 -0
  57. package/src/modules/catalog/backend/catalog/prices/page.tsx +14 -0
  58. package/src/modules/catalog/components/prices/PriceScopeSelectors.tsx +243 -0
  59. package/src/modules/catalog/components/prices/PricesDataTable.tsx +320 -0
  60. package/src/modules/catalog/components/prices/normalizePriceRecord.ts +71 -0
  61. package/src/modules/catalog/components/prices/priceFormFields.tsx +228 -0
  62. package/src/modules/catalog/components/prices/useBatchLabels.ts +57 -0
  63. package/src/modules/catalog/data/validators.ts +43 -2
  64. package/src/modules/catalog/extension-points.ts +1 -0
  65. package/src/modules/catalog/i18n/de.json +72 -0
  66. package/src/modules/catalog/i18n/en.json +72 -0
  67. package/src/modules/catalog/i18n/es.json +72 -0
  68. package/src/modules/catalog/i18n/ko.json +72 -0
  69. package/src/modules/catalog/i18n/pl.json +72 -0
  70. package/src/modules/catalog/lib/pricing.ts +103 -9
  71. package/src/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.tsx +120 -0
  72. package/src/modules/communication_channels/backend/profile/communication-channels/page.tsx +9 -10
  73. package/src/modules/communication_channels/commands/connect-credential-channel.ts +9 -0
  74. package/src/modules/communication_channels/commands/ingest-inbound-message.ts +17 -2
  75. package/src/modules/communication_channels/extension-points.ts +1 -1
  76. package/src/modules/communication_channels/i18n/de.json +2 -1
  77. package/src/modules/communication_channels/i18n/en.json +2 -1
  78. package/src/modules/communication_channels/i18n/es.json +2 -1
  79. package/src/modules/communication_channels/i18n/ko.json +2 -1
  80. package/src/modules/communication_channels/i18n/pl.json +2 -1
  81. package/src/modules/communication_channels/lib/adapter.ts +8 -0
  82. package/src/modules/communication_channels/lib/connect-channel.ts +49 -0
  83. package/src/modules/messages/api/route.ts +8 -2
  84. package/src/modules/messages/commands/messages.ts +17 -2
  85. package/src/modules/messages/data/validators.ts +26 -11
  86. package/src/modules/messages/subscribers/message-notification.ts +17 -5
  87. package/src/modules/sales/AGENTS.md +2 -3
@@ -0,0 +1,120 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { ChevronDown } from 'lucide-react'
5
+ import { extensionPoints } from '@open-mercato/core/modules/communication_channels/extension-points'
6
+ import { Button } from '@open-mercato/ui/primitives/button'
7
+ import { InjectionSpot, useInjectionWidgets } from '@open-mercato/ui/backend/injection/InjectionSpot'
8
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
9
+ import { cn } from '@open-mercato/shared/lib/utils'
10
+
11
+ const CONNECT_MENU_TRIGGER_ID = 'communication-channels-connect-trigger'
12
+ const CONNECT_MENU_PANEL_ID = 'communication-channels-connect-menu'
13
+
14
+ type ConnectChannelMenuProps = {
15
+ onConnected: () => void
16
+ }
17
+
18
+ /**
19
+ * Single "Connect channel" entry point for the profile page.
20
+ *
21
+ * Every `channel-*` package injects its own connect control into the
22
+ * `profile:communication-channels:connect` spot. Rendering that spot inline put
23
+ * each provider button side by side in the page header, unspaced and growing
24
+ * wider with every installed provider (#5595). The widgets now stack inside a
25
+ * dropdown, so a new provider lengthens a list instead of widening a row.
26
+ *
27
+ * The panel stays mounted and is hidden with CSS rather than being conditionally
28
+ * rendered: `channel-imap` and `channel-discord` own their connect `Dialog`
29
+ * inside the injected widget, so unmounting the panel on close would tear down
30
+ * an open dialog.
31
+ */
32
+ export function ConnectChannelMenu({ onConnected }: ConnectChannelMenuProps): React.JSX.Element | null {
33
+ const t = useT()
34
+ const spotId = extensionPoints.hosts.profileConnect.spotId
35
+ const [open, setOpen] = React.useState(false)
36
+ const containerRef = React.useRef<HTMLDivElement>(null)
37
+ const triggerRef = React.useRef<HTMLButtonElement>(null)
38
+
39
+ const context = React.useMemo(() => ({ reload: onConnected }), [onConnected])
40
+ const { widgets } = useInjectionWidgets(spotId, { context, triggerOnLoad: true })
41
+
42
+ React.useEffect(() => {
43
+ if (!open) return
44
+ // A provider's credential dialog renders through a portal at `document.body`,
45
+ // so it sits outside `containerRef` and a click inside it reads as an outside
46
+ // click. Dismissing on those would collapse the panel behind the open dialog —
47
+ // the tear-down the panel is kept mounted to avoid in the first place.
48
+ //
49
+ // Escape is deliberately not guarded the same way. The provider widgets render
50
+ // a controlled `Dialog` with no `DialogTrigger`, so Radix has no recorded
51
+ // element to hand focus back to and drops it on `<body>` when the dialog
52
+ // closes — measured in Chromium, where focus was still on `<body>` two seconds
53
+ // later. Closing the menu here and calling `focus()` is then the only thing
54
+ // that leaves a keyboard user anywhere usable, so Escape dismisses both layers
55
+ // together on purpose until the provider widgets restore focus themselves.
56
+ function isInsideDialog(target: EventTarget | null): boolean {
57
+ const element = target as Element | null
58
+ return typeof element?.closest === 'function' && element.closest('[role="dialog"]') !== null
59
+ }
60
+ function onDocumentMouseDown(event: MouseEvent) {
61
+ if (isInsideDialog(event.target)) return
62
+ const target = event.target as Node
63
+ if (containerRef.current && !containerRef.current.contains(target)) setOpen(false)
64
+ }
65
+ function onKeyDown(event: KeyboardEvent) {
66
+ if (event.key !== 'Escape') return
67
+ setOpen(false)
68
+ triggerRef.current?.focus()
69
+ }
70
+ document.addEventListener('mousedown', onDocumentMouseDown)
71
+ document.addEventListener('keydown', onKeyDown)
72
+ return () => {
73
+ document.removeEventListener('mousedown', onDocumentMouseDown)
74
+ document.removeEventListener('keydown', onKeyDown)
75
+ }
76
+ }, [open])
77
+
78
+ if (widgets.length === 0) return null
79
+
80
+ return (
81
+ <div ref={containerRef} className="relative inline-block text-left">
82
+ <Button
83
+ ref={triggerRef}
84
+ id={CONNECT_MENU_TRIGGER_ID}
85
+ type="button"
86
+ variant="outline"
87
+ aria-expanded={open}
88
+ aria-controls={CONNECT_MENU_PANEL_ID}
89
+ onClick={() => setOpen((current) => !current)}
90
+ >
91
+ {t('communication_channels.profile.connect.menu', 'Connect channel')}
92
+ <ChevronDown className={cn('size-4 transition-transform', open && 'rotate-180')} />
93
+ </Button>
94
+ {/* A disclosure group, not `role="menu"`: the rows are provider-owned
95
+ widgets rendering plain buttons, and ARIA only allows `menuitem`-shaped
96
+ children under a menu — declaring one would hide them from assistive
97
+ technology instead of describing them. */}
98
+ <div
99
+ id={CONNECT_MENU_PANEL_ID}
100
+ role="group"
101
+ aria-labelledby={CONNECT_MENU_TRIGGER_ID}
102
+ data-testid="connect-channel-menu-panel"
103
+ className={cn(
104
+ // Anchoring flips with the header: the header wraps on a phone, which
105
+ // moves the trigger to the left edge, and right-anchoring a panel
106
+ // wider than the trigger then pushes it off-screen. Left-anchor below
107
+ // `sm`, right-anchor from `sm` up where the trigger sits right. The
108
+ // `max-w-` clamp (mirroring RowActions) keeps a long provider label
109
+ // from widening the panel past the viewport either way.
110
+ 'absolute left-0 top-full z-dropdown mt-2 w-max min-w-56 max-w-[calc(100vw-1rem)] flex-col items-stretch gap-2 rounded-md border bg-background p-2 shadow-md sm:left-auto sm:right-0',
111
+ open ? 'flex' : 'hidden',
112
+ )}
113
+ >
114
+ <InjectionSpot spotId={spotId} context={context} data={{}} widgetsOverride={widgets} />
115
+ </div>
116
+ </div>
117
+ )
118
+ }
119
+
120
+ export default ConnectChannelMenu
@@ -21,11 +21,11 @@ import { Input } from '@open-mercato/ui/primitives/input'
21
21
  import { Label } from '@open-mercato/ui/primitives/label'
22
22
  import { Textarea } from '@open-mercato/ui/primitives/textarea'
23
23
  import { KbdShortcut } from '@open-mercato/ui/primitives/kbd'
24
- import { InjectionSpot } from '@open-mercato/ui/backend/injection/InjectionSpot'
25
24
  import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
26
25
  import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
27
26
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
28
27
  import { useT } from '@open-mercato/shared/lib/i18n/context'
28
+ import { ConnectChannelMenu } from './ConnectChannelMenu'
29
29
 
30
30
  type ChannelRow = {
31
31
  id: string
@@ -146,6 +146,8 @@ export default function ProfileCommunicationChannelsPage() {
146
146
 
147
147
  const reauthRows = rows.filter((r) => r.status === 'requires_reauth')
148
148
 
149
+ const reloadChannels = React.useCallback(() => setReloadKey((k) => k + 1), [])
150
+
149
151
  const onSetPrimary = React.useCallback(
150
152
  async (channelId: string) => {
151
153
  let response
@@ -500,8 +502,8 @@ export default function ProfileCommunicationChannelsPage() {
500
502
  return (
501
503
  <Page>
502
504
  <PageBody>
503
- <header className="mb-4 flex items-baseline justify-between">
504
- <div>
505
+ <header className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
506
+ <div className="min-w-0">
505
507
  <h1 className="text-2xl font-semibold">
506
508
  {t('communication_channels.profile.title', 'My communication channels')}
507
509
  </h1>
@@ -513,12 +515,9 @@ export default function ProfileCommunicationChannelsPage() {
513
515
  </p>
514
516
  </div>
515
517
  {/* Provider connect entry points injected by each channel-* package
516
- (channel-gmail, channel-imap) via UMES. */}
517
- <InjectionSpot
518
- spotId={extensionPoints.hosts.profileConnect.spotId}
519
- context={{ reload: () => setReloadKey((k) => k + 1) }}
520
- data={{}}
521
- />
518
+ (channel-gmail, channel-imap) via UMES. They stack inside one
519
+ dropdown so the header does not widen per installed provider. */}
520
+ <ConnectChannelMenu onConnected={reloadChannels} />
522
521
  </header>
523
522
 
524
523
  {reauthRows.length > 0 ? (
@@ -543,7 +542,7 @@ export default function ProfileCommunicationChannelsPage() {
543
542
  error={errorMessage}
544
543
  emptyState={t(
545
544
  'communication_channels.profile.empty',
546
- 'You have no connected channels yet. Use one of the Connect buttons above to add a channel.',
545
+ 'You have no connected channels yet. Add one using the menu at the top of this page.',
547
546
  )}
548
547
  />
549
548
  <ImportHistoryDialog
@@ -104,6 +104,7 @@ const connectCredentialChannelCommand: CommandHandler<
104
104
  const effectiveUserId = tenantScoped ? null : input.userId
105
105
 
106
106
  // Optional credential validation.
107
+ let adapterIdentifier: string | null = null
107
108
  if (typeof adapter.validateCredentials === 'function') {
108
109
  const validation = await adapter.validateCredentials({
109
110
  providerKey: input.providerKey,
@@ -120,6 +121,9 @@ const connectCredentialChannelCommand: CommandHandler<
120
121
  errorCodes: validation.errorCodes,
121
122
  }
122
123
  }
124
+ if (typeof validation.externalIdentifier === 'string' && validation.externalIdentifier.length > 0) {
125
+ adapterIdentifier = validation.externalIdentifier
126
+ }
123
127
  }
124
128
 
125
129
  // Persist credentials best-effort.
@@ -200,8 +204,12 @@ const connectCredentialChannelCommand: CommandHandler<
200
204
  // carry it as `fromAddress`; other credential providers may use `username` or
201
205
  // `email`. Normalize emails to lowercase so the cross-provider duplicate guard
202
206
  // and the per-(tenant,user,provider,mailbox) heal index match canonically.
207
+ // An identity the adapter reported during validation wins: providers such as
208
+ // Discord have no email-shaped key, and without it every reconnect inserted a
209
+ // duplicate row instead of healing the existing one (#4977).
203
210
  const credBag = input.credentials as Record<string, unknown>
204
211
  const rawIdentifier =
212
+ adapterIdentifier ??
205
213
  (typeof credBag.username === 'string' && credBag.username.length > 0 ? credBag.username : null) ??
206
214
  (typeof credBag.email === 'string' && credBag.email.length > 0 ? credBag.email : null) ??
207
215
  (typeof credBag.fromAddress === 'string' && credBag.fromAddress.length > 0
@@ -233,6 +241,7 @@ const connectCredentialChannelCommand: CommandHandler<
233
241
  organizationId: tenantScoped ? null : input.scope.organizationId ?? null,
234
242
  },
235
243
  pollIntervalSeconds: input.pollIntervalSeconds,
244
+ adoptUnidentifiedChannel: adapterIdentifier !== null,
236
245
  })
237
246
  } catch (err) {
238
247
  // Same mailbox already connected via another provider — reject so we don't
@@ -213,6 +213,7 @@ const ingestInboundMessageCommand: CommandHandler<IngestInboundMessageInput, Ing
213
213
  tenantId: input.scope.tenantId,
214
214
  organizationId: input.scope.organizationId ?? null,
215
215
  lastMessageAt: m.timestamp ?? new Date(),
216
+ assignedUserId: channel.userId ?? null,
216
217
  })
217
218
  em.persist(conversation)
218
219
  conversationCreated = true
@@ -378,6 +379,13 @@ const ingestInboundMessageCommand: CommandHandler<IngestInboundMessageInput, Ing
378
379
  : rawBody
379
380
  const safeSubject = (m.subject ?? '').trim() || '(no subject)'
380
381
 
382
+ // #6106: a per-user channel is its owner's inbox, so the owner is the
383
+ // default assignee. Without this fallback the first message of a thread
384
+ // (no mapping yet) and every message on an unassigned mapping had no
385
+ // recipient, and the participant-scoped inbox showed it to nobody. A
386
+ // manual assignment stays authoritative; tenant-wide channels have no owner.
387
+ const routedAssigneeId = mapping?.assignedUserId ?? channel.userId ?? null
388
+
381
389
  const composeInput = {
382
390
  type: `channel.${input.providerKey}`,
383
391
  visibility: 'public' as const,
@@ -390,8 +398,14 @@ const ingestInboundMessageCommand: CommandHandler<IngestInboundMessageInput, Ing
390
398
  // senders have no address (Discord, Slack, SMS…) are validated without it.
391
399
  // The messages validator fails closed on any type it does not recognize.
392
400
  sourceChannelType: input.channelType,
393
- recipients: mapping?.assignedUserId
394
- ? [{ userId: mapping.assignedUserId, type: 'to' as const }]
401
+ // #6093: this message came in from the channel, so the recipient below
402
+ // is internal routing (the conversation's assignee), not an external
403
+ // addressee. Without the flag the hub's "no recipients on a public
404
+ // message" rule rejected every message in an assigned conversation and
405
+ // the worker dropped it as a permanent failure.
406
+ inboundFromChannel: true,
407
+ recipients: routedAssigneeId
408
+ ? [{ userId: routedAssigneeId, type: 'to' as const }]
395
409
  : [],
396
410
  subject: safeSubject,
397
411
  body: truncatedBody,
@@ -440,6 +454,7 @@ const ingestInboundMessageCommand: CommandHandler<IngestInboundMessageInput, Ing
440
454
  channelId: input.channelId,
441
455
  providerKey: input.providerKey,
442
456
  externalThreadRef: m.externalConversationId,
457
+ assignedUserId: channel.userId ?? null,
443
458
  tenantId: input.scope.tenantId,
444
459
  organizationId: input.scope.organizationId ?? null,
445
460
  })
@@ -8,7 +8,7 @@ export const extensionPoints = defineModuleExtensionPoints({
8
8
  family: 'generic',
9
9
  spotId: 'profile:communication-channels:connect',
10
10
  supported: ['render-widget'],
11
- source: 'backend/profile/communication-channels/page.tsx',
11
+ source: 'backend/profile/communication-channels/ConnectChannelMenu.tsx',
12
12
  }),
13
13
  profileChannelsTable: dataTableExtensionHost({ tableId: 'communication_channels.profile.channels', source: 'backend/profile/communication-channels/page.tsx' }),
14
14
  },
@@ -89,6 +89,7 @@
89
89
  "communication_channels.profile.connect.imap": "IMAP verbinden",
90
90
  "communication_channels.profile.connect.imapTitle": "IMAP-Postfach verbinden",
91
91
  "communication_channels.profile.connect.mailboxAlreadyConnected": "Dieses Postfach ist bereits über einen anderen Anbieter verbunden. Trennen Sie es zuerst, um es mit einem anderen Anbieter erneut zu verbinden.",
92
+ "communication_channels.profile.connect.menu": "Kanal verbinden",
92
93
  "communication_channels.profile.connect.notConfigured": "Dieser Anbieter ist noch nicht konfiguriert. Bitten Sie einen Administrator, die OAuth-Client-ID und das Secret unter Integrationen zu hinterlegen, bevor ein Postfach verbunden wird.",
93
94
  "communication_channels.profile.connect.oauthFailed": "OAuth-Verbindung konnte nicht gestartet werden.",
94
95
  "communication_channels.profile.connect.save": "Verbinden",
@@ -100,7 +101,7 @@
100
101
  "communication_channels.profile.disconnect.description": "Dies entfernt die Verbindung und stoppt die Synchronisierung. Bereits importierte E-Mails bleiben in Ihren Timelines. Sie können den Kanal jederzeit erneut verbinden.",
101
102
  "communication_channels.profile.disconnect.submitting": "Wird getrennt…",
102
103
  "communication_channels.profile.disconnect.title": "Kanal trennen",
103
- "communication_channels.profile.empty": "Sie haben noch keine verbundenen Kanäle. Verwenden Sie oben eine der Schaltflächen zum Verbinden, um einen Kanal hinzuzufügen.",
104
+ "communication_channels.profile.empty": "Sie haben noch keine verbundenen Kanäle. Fügen Sie einen über das Menü oben auf dieser Seite hinzu.",
104
105
  "communication_channels.profile.flash.connected": "Kanal verbunden.",
105
106
  "communication_channels.profile.flash.connectedWithProvider": "Kanal verbunden ({provider}).",
106
107
  "communication_channels.profile.flash.error": "Kanal konnte nicht verbunden werden.",
@@ -89,6 +89,7 @@
89
89
  "communication_channels.profile.connect.imap": "Connect IMAP",
90
90
  "communication_channels.profile.connect.imapTitle": "Connect IMAP mailbox",
91
91
  "communication_channels.profile.connect.mailboxAlreadyConnected": "This mailbox is already connected through another provider. Disconnect it first to reconnect it with a different one.",
92
+ "communication_channels.profile.connect.menu": "Connect channel",
92
93
  "communication_channels.profile.connect.notConfigured": "This provider is not configured yet. Ask an administrator to add the OAuth Client ID and Secret under Integrations before connecting a mailbox.",
93
94
  "communication_channels.profile.connect.oauthFailed": "Could not start OAuth connection.",
94
95
  "communication_channels.profile.connect.save": "Connect",
@@ -100,7 +101,7 @@
100
101
  "communication_channels.profile.disconnect.description": "This removes the connection and stops syncing. Emails already imported stay on your timelines. You can reconnect anytime.",
101
102
  "communication_channels.profile.disconnect.submitting": "Disconnecting…",
102
103
  "communication_channels.profile.disconnect.title": "Disconnect channel",
103
- "communication_channels.profile.empty": "You have no connected channels yet. Use one of the Connect buttons above to add a channel.",
104
+ "communication_channels.profile.empty": "You have no connected channels yet. Add one using the menu at the top of this page.",
104
105
  "communication_channels.profile.flash.connected": "Channel connected.",
105
106
  "communication_channels.profile.flash.connectedWithProvider": "Channel connected ({provider}).",
106
107
  "communication_channels.profile.flash.error": "Failed to connect channel.",
@@ -89,6 +89,7 @@
89
89
  "communication_channels.profile.connect.imap": "Conectar IMAP",
90
90
  "communication_channels.profile.connect.imapTitle": "Conectar buzón IMAP",
91
91
  "communication_channels.profile.connect.mailboxAlreadyConnected": "Este buzón ya está conectado a través de otro proveedor. Desconéctalo primero para volver a conectarlo con uno diferente.",
92
+ "communication_channels.profile.connect.menu": "Conectar canal",
92
93
  "communication_channels.profile.connect.notConfigured": "Este proveedor aún no está configurado. Pide a un administrador que añada el ID de cliente y el secreto de OAuth en Integraciones antes de conectar un buzón.",
93
94
  "communication_channels.profile.connect.oauthFailed": "No se pudo iniciar la conexión OAuth.",
94
95
  "communication_channels.profile.connect.save": "Conectar",
@@ -100,7 +101,7 @@
100
101
  "communication_channels.profile.disconnect.description": "Esto elimina la conexión y detiene la sincronización. Los correos ya importados permanecen en tus cronologías. Puedes volver a conectarlo cuando quieras.",
101
102
  "communication_channels.profile.disconnect.submitting": "Desconectando…",
102
103
  "communication_channels.profile.disconnect.title": "Desconectar canal",
103
- "communication_channels.profile.empty": "Aún no tienes canales conectados. Usa uno de los botones de conexión de arriba para añadir un canal.",
104
+ "communication_channels.profile.empty": "Aún no tienes canales conectados. Añade uno con el menú de la parte superior de esta página.",
104
105
  "communication_channels.profile.flash.connected": "Canal conectado.",
105
106
  "communication_channels.profile.flash.connectedWithProvider": "Canal conectado ({provider}).",
106
107
  "communication_channels.profile.flash.error": "No se pudo conectar el canal.",
@@ -89,6 +89,7 @@
89
89
  "communication_channels.profile.connect.imap": "IMAP 연결",
90
90
  "communication_channels.profile.connect.imapTitle": "IMAP 메일함 연결",
91
91
  "communication_channels.profile.connect.mailboxAlreadyConnected": "이 메일함은 이미 다른 제공업체를 통해 연결되어 있습니다. 다른 제공업체로 다시 연결하려면 먼저 연결을 해제하세요.",
92
+ "communication_channels.profile.connect.menu": "채널 연결",
92
93
  "communication_channels.profile.connect.notConfigured": "이 제공업체는 아직 구성되지 않았습니다. 메일함을 연결하기 전에 관리자에게 통합에서 OAuth 클라이언트 ID와 시크릿을 추가하도록 요청하세요.",
93
94
  "communication_channels.profile.connect.oauthFailed": "OAuth 연결을 시작할 수 없습니다.",
94
95
  "communication_channels.profile.connect.save": "연결",
@@ -100,7 +101,7 @@
100
101
  "communication_channels.profile.disconnect.description": "연결을 제거하고 동기화를 중지합니다. 이미 가져온 이메일은 타임라인에 그대로 유지됩니다. 언제든지 다시 연결할 수 있습니다.",
101
102
  "communication_channels.profile.disconnect.submitting": "연결 해제 중…",
102
103
  "communication_channels.profile.disconnect.title": "채널 연결 해제",
103
- "communication_channels.profile.empty": "아직 연결된 채널이 없습니다. 위의 연결 버튼 중 하나를 사용하여 채널을 추가하세요.",
104
+ "communication_channels.profile.empty": "아직 연결된 채널이 없습니다. 이 페이지 상단의 메뉴에서 채널을 추가하세요.",
104
105
  "communication_channels.profile.flash.connected": "채널이 연결되었습니다.",
105
106
  "communication_channels.profile.flash.connectedWithProvider": "채널이 연결되었습니다 ({provider}).",
106
107
  "communication_channels.profile.flash.error": "채널 연결에 실패했습니다.",
@@ -89,6 +89,7 @@
89
89
  "communication_channels.profile.connect.imap": "Połącz IMAP",
90
90
  "communication_channels.profile.connect.imapTitle": "Połącz skrzynkę IMAP",
91
91
  "communication_channels.profile.connect.mailboxAlreadyConnected": "Ta skrzynka jest już połączona przez innego dostawcę. Najpierw ją rozłącz, aby połączyć ją z innym dostawcą.",
92
+ "communication_channels.profile.connect.menu": "Połącz kanał",
92
93
  "communication_channels.profile.connect.notConfigured": "Ten dostawca nie został jeszcze skonfigurowany. Poproś administratora o dodanie identyfikatora klienta i sekretu OAuth w sekcji Integracje przed połączeniem skrzynki.",
93
94
  "communication_channels.profile.connect.oauthFailed": "Nie udało się rozpocząć połączenia OAuth.",
94
95
  "communication_channels.profile.connect.save": "Połącz",
@@ -100,7 +101,7 @@
100
101
  "communication_channels.profile.disconnect.description": "Spowoduje to usunięcie połączenia i zatrzymanie synchronizacji. Już zaimportowane wiadomości pozostaną na osiach czasu. Możesz połączyć ponownie w dowolnej chwili.",
101
102
  "communication_channels.profile.disconnect.submitting": "Rozłączanie…",
102
103
  "communication_channels.profile.disconnect.title": "Rozłącz kanał",
103
- "communication_channels.profile.empty": "Nie masz jeszcze połączonych kanałów. Użyj jednego z przycisków połączenia powyżej, aby dodać kanał.",
104
+ "communication_channels.profile.empty": "Nie masz jeszcze połączonych kanałów. Dodaj go za pomocą menu u góry tej strony.",
104
105
  "communication_channels.profile.flash.connected": "Kanał połączony.",
105
106
  "communication_channels.profile.flash.connectedWithProvider": "Kanał połączony ({provider}).",
106
107
  "communication_channels.profile.flash.error": "Nie udało się połączyć kanału.",
@@ -519,6 +519,14 @@ export interface ValidateCredentialsResult {
519
519
  * that don't emit codes keep working and callers fall back to `errors`.
520
520
  */
521
521
  errorCodes?: Record<string, string>
522
+ /**
523
+ * Stable identity of the connected account, for providers whose credentials
524
+ * carry no email-shaped `username` / `email` / `fromAddress` (e.g. a Discord
525
+ * bot). When present on a successful validation the connect flow uses it as
526
+ * `CommunicationChannel.externalIdentifier`, so reconnecting the same account
527
+ * heals the existing channel instead of inserting a duplicate row.
528
+ */
529
+ externalIdentifier?: string
522
530
  }
523
531
 
524
532
  // ── The adapter contract ─────────────────────────────────────
@@ -1,4 +1,5 @@
1
1
  import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
2
3
  import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
3
4
  import { CommunicationChannel } from '../data/entities'
4
5
  import type { ChannelAdapter } from './adapter'
@@ -44,6 +45,14 @@ export interface CreateConnectedChannelRowArgs {
44
45
  * the adapter's push capability (push-capable → null, polling-only → 300).
45
46
  */
46
47
  pollIntervalSeconds?: number | null
48
+ /**
49
+ * The identifier was reported by the adapter itself (not sniffed from the
50
+ * credential bag). Rows this user created for the same provider before the
51
+ * adapter reported one carry `external_identifier = NULL` and would never match
52
+ * the heal key, so the newest such row is adopted instead of inserting another
53
+ * one next to it.
54
+ */
55
+ adoptUnidentifiedChannel?: boolean
47
56
  }
48
57
 
49
58
  /**
@@ -163,6 +172,46 @@ export async function createConnectedChannelRow(
163
172
  }
164
173
  }
165
174
 
175
+ if (args.adoptUnidentifiedChannel && effectiveExternalIdentifier && !isTenantWidePush) {
176
+ const unidentified = (await findWithDecryption(
177
+ em,
178
+ CommunicationChannel,
179
+ { tenantId: scope.tenantId, userId, providerKey, externalIdentifier: null, deletedAt: null },
180
+ { orderBy: { createdAt: 'desc' } },
181
+ dscope,
182
+ )) as CommunicationChannel[]
183
+ const [adopted, ...superseded] = unidentified
184
+ if (adopted) {
185
+ // Per-user credentials are stored once per provider, so every older
186
+ // identifier-less row is a duplicate of the adopted one. Left active, a
187
+ // stuck `requires_reauth` duplicate would keep its reauth banner forever.
188
+ const inheritsPrimary = superseded.some((duplicate) => duplicate.isPrimary)
189
+ // Postgres checks the partial `communication_channels_one_primary_per_user_uq`
190
+ // index per statement and one flush does not order the SET-false before
191
+ // the SET-true, so the old primary is cleared in its own phase (each phase
192
+ // flushes) before the adopted row takes the flag, inside one transaction.
193
+ await withAtomicFlush(
194
+ em,
195
+ [
196
+ () => {
197
+ for (const duplicate of superseded) {
198
+ duplicate.isActive = false
199
+ duplicate.isPrimary = false
200
+ duplicate.status = 'disconnected'
201
+ duplicate.lastError = 'superseded_by_reconnect'
202
+ }
203
+ },
204
+ () => {
205
+ applyConnectionState(adopted)
206
+ if (inheritsPrimary) adopted.isPrimary = true
207
+ },
208
+ ],
209
+ { transaction: true },
210
+ )
211
+ return adopted
212
+ }
213
+ }
214
+
166
215
  const channel = em.create(CommunicationChannel, {
167
216
  providerKey,
168
217
  channelType: adapter.channelType,
@@ -470,8 +470,14 @@ export async function POST(req: Request) {
470
470
  sourceHint.data,
471
471
  )
472
472
  : undefined
473
- const { sourceChannelType: _clientSuppliedChannelType, ...clientBody } =
474
- (body ?? {}) as Record<string, unknown>
473
+ // `inboundFromChannel` is likewise server-only (#6093): channel ingest sets
474
+ // it so an inbound message can be addressed to the conversation's assignee,
475
+ // and a request body must not be able to waive the recipients rule with it.
476
+ const {
477
+ sourceChannelType: _clientSuppliedChannelType,
478
+ inboundFromChannel: _clientSuppliedInboundFlag,
479
+ ...clientBody
480
+ } = (body ?? {}) as Record<string, unknown>
475
481
  const input = composeMessageSchema.parse({
476
482
  ...clientBody,
477
483
  ...(sourceChannelType ? { sourceChannelType } : {}),
@@ -30,6 +30,13 @@ type MessageSentEventPayload = {
30
30
  senderUserId: string
31
31
  recipientUserIds: string[]
32
32
  sendViaEmail: boolean
33
+ /**
34
+ * The message arrived from an external channel (#6093). Its `senderUserId`
35
+ * is the channel's system user and its real correspondent lives in
36
+ * `externalName` / `externalEmail`; subscribers that show a sender read it
37
+ * from there.
38
+ */
39
+ inboundFromChannel?: boolean
33
40
  externalEmail?: string | null
34
41
  forwardedFrom?: string
35
42
  replyTo?: string
@@ -303,7 +310,13 @@ const composeMessageCommand: CommandHandler<unknown, { id: string; threadId: str
303
310
  : undefined
304
311
 
305
312
  const isPublicVisibility = input.visibility === 'public'
306
- const sendViaEmail = isPublicVisibility ? true : input.sendViaEmail
313
+ // #6093: a channel-ingested message was RECEIVED, not sent. Ingest passes
314
+ // `sendViaEmail: false` on purpose; forcing it for public visibility would
315
+ // mail the assignee a copy and echo the message to its external sender
316
+ // (#6089) the moment the recipients waiver lets it compose. #6090 removes
317
+ // the forcing for every caller; until then it is skipped here for the
318
+ // ingest path only.
319
+ const sendViaEmail = isPublicVisibility && !input.inboundFromChannel ? true : input.sendViaEmail
307
320
  const message = trx.create(Message, {
308
321
  type: input.type,
309
322
  visibility: input.visibility ?? null,
@@ -407,7 +420,9 @@ const composeMessageCommand: CommandHandler<unknown, { id: string; threadId: str
407
420
  messageId,
408
421
  senderUserId: input.userId,
409
422
  recipientUserIds: input.recipients.map((recipient) => recipient.userId),
410
- sendViaEmail: input.visibility === 'public' ? true : input.sendViaEmail,
423
+ sendViaEmail:
424
+ input.visibility === 'public' && !input.inboundFromChannel ? true : input.sendViaEmail,
425
+ inboundFromChannel: input.inboundFromChannel === true,
411
426
  externalEmail: responseExternalEmail,
412
427
  tenantId: input.tenantId,
413
428
  organizationId: input.organizationId,
@@ -116,6 +116,16 @@ const composeMessageBaseSchema = z.object({
116
116
  * asserting its own channel type.
117
117
  */
118
118
  sourceChannelType: z.string().min(1).max(64).optional(),
119
+ /**
120
+ * Set by channel ingest for a message that arrived FROM an external channel
121
+ * (#6093). On such a message `recipients` are not external addressees — a
122
+ * public message has none — but the internal users the hub routes it to
123
+ * (the conversation's assignee), so the "no recipients on a public message"
124
+ * rule does not apply. Server-only like `sourceChannelType`: the HTTP route
125
+ * strips any client-sent value, so a caller cannot waive the rule by
126
+ * asserting its message came in from a channel.
127
+ */
128
+ inboundFromChannel: z.boolean().optional(),
119
129
  recipients: z.array(messageRecipientSchema).max(100).optional().default([]),
120
130
  subject: z.string().max(500).optional().default(''),
121
131
  body: z.string().max(50000).optional().default(''),
@@ -155,7 +165,11 @@ function refineComposeMessage(value: ComposeMessageRefinementValue, ctx: z.Refin
155
165
  message: 'externalEmail is required when visibility is public',
156
166
  })
157
167
  }
158
- if (recipientCount > 0) {
168
+ // #6093: a channel-ingested message is addressed to the assignee of its
169
+ // conversation; that recipient is internal routing, not a second
170
+ // external addressee, so only a user-composed public message is bound
171
+ // by this rule.
172
+ if (recipientCount > 0 && !value.inboundFromChannel) {
159
173
  ctx.addIssue({
160
174
  code: z.ZodIssueCode.custom,
161
175
  path: ['recipients'],
@@ -199,22 +213,23 @@ function refineComposeMessage(value: ComposeMessageRefinementValue, ctx: z.Refin
199
213
  }
200
214
 
201
215
  /**
202
- * Full compose contract, including the server-resolved `sourceChannelType`.
203
- * Used by the `messages.messages.compose` command and by the HTTP route AFTER
204
- * it has resolved the channel type itself.
216
+ * Full compose contract, including the server-resolved `sourceChannelType` and
217
+ * the ingest-only `inboundFromChannel`. Used by the `messages.messages.compose`
218
+ * command and by the HTTP route AFTER it has resolved the channel type itself.
205
219
  */
206
220
  export const composeMessageSchema = composeMessageBaseSchema.superRefine(refineComposeMessage)
207
221
 
208
222
  /**
209
- * Client-facing compose contract — the same rules minus `sourceChannelType`,
210
- * which is never accepted from a request body (#4975). Published in OpenAPI so
211
- * the documented request shape matches what `POST /api/messages` actually reads:
212
- * the route discards any client-sent channel type, resolves the real one from
213
- * the referenced conversation or parent message, and only then validates against
214
- * {@link composeMessageSchema}.
223
+ * Client-facing compose contract — the same rules minus the server-only fields:
224
+ * `sourceChannelType` is never accepted from a request body (#4975) and neither
225
+ * is `inboundFromChannel` (#6093). Published in OpenAPI so the documented
226
+ * request shape matches what `POST /api/messages` actually reads: the route
227
+ * discards any client-sent value for these, resolves the real channel type from
228
+ * the referenced conversation or parent message, and only then validates
229
+ * against {@link composeMessageSchema}.
215
230
  */
216
231
  export const composeMessageRequestSchema = composeMessageBaseSchema
217
- .omit({ sourceChannelType: true })
232
+ .omit({ sourceChannelType: true, inboundFromChannel: true })
218
233
  .superRefine(refineComposeMessage)
219
234
 
220
235
  export const updateDraftSchema = z.object({
@@ -22,6 +22,8 @@ type MessageSentPayload = {
22
22
  senderUserId: string
23
23
  recipientUserIds: string[]
24
24
  sendViaEmail: boolean
25
+ /** Set by compose for channel-ingested messages (#6093); see `resolveNotificationVariables`. */
26
+ inboundFromChannel?: boolean
25
27
  externalEmail?: string | null
26
28
  forwardedFrom?: string
27
29
  replyTo?: string
@@ -72,13 +74,23 @@ async function resolveNotificationVariables(payload: MessageSentPayload, ctx: Re
72
74
  ),
73
75
  ])
74
76
 
77
+ // #6093: a channel-ingested message is composed on behalf of the channel's
78
+ // system user (or, without one, the assignee), so `senderUserId` is not who
79
+ // wrote it. The correspondent is on the message itself.
80
+ const externalFrom = payload.inboundFromChannel
81
+ ? [message?.externalName, message?.externalEmail].find(
82
+ (value): value is string => typeof value === 'string' && value.trim().length > 0,
83
+ )
84
+ : undefined
85
+
75
86
  return {
76
87
  title: typeof message?.subject === 'string' ? message.subject : '',
77
- from: typeof sender?.name === 'string' && sender.name.trim().length > 0
78
- ? sender.name
79
- : typeof sender?.email === 'string'
80
- ? sender.email
81
- : '',
88
+ from: externalFrom
89
+ ?? (typeof sender?.name === 'string' && sender.name.trim().length > 0
90
+ ? sender.name
91
+ : typeof sender?.email === 'string'
92
+ ? sender.email
93
+ : ''),
82
94
  }
83
95
  } catch {
84
96
  return { title: '', from: '' }
@@ -6,8 +6,7 @@ Use the sales module for orders, quotes, invoices, shipments, and payments. This
6
6
 
7
7
  1. **MUST use `salesCalculationService` from DI** for document math.
8
8
  2. **MUST follow document flow**: Quote → Order → Invoice — no skipping steps
9
- 3. **MUST use `selectBestPrice`** from catalog pricing helpers.
10
- 4. **MUST scope all documents to a channel** — channel selection affects pricing, numbering, and visibility
9
+ 3. **MUST scope all documents to a channel** — channel selection affects pricing, numbering, and visibility
11
10
 
12
11
  ## Ask First
13
12
 
@@ -51,7 +50,7 @@ const calcService = container.resolve('salesCalculationService')
51
50
 
52
51
  - Dispatches `sales.line.calculate.*` / `sales.document.calculate.*` events
53
52
  - Register line/totals calculators or override via DI
54
- - For catalog pricing: use `selectBestPrice`, `resolvePriceVariantId` from catalog module
53
+ - **Not yet wired:** no code here calls catalog's `selectBestPrice`/`catalogPricingService` — lines are priced directly, not resolved from `CatalogProductPrice`. Out of scope for the pricing-engine spec; future wiring routes through `catalogPricingService` — never reimplement pricing inline.
55
54
 
56
55
  ## Data Model Constraints
57
56