@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.js +166 -0
- package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.meta.js +16 -0
- package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.meta.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/prices/create/page.js +68 -0
- package/dist/modules/catalog/backend/catalog/prices/create/page.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/prices/create/page.meta.js +25 -0
- package/dist/modules/catalog/backend/catalog/prices/create/page.meta.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/prices/page.js +11 -0
- package/dist/modules/catalog/backend/catalog/prices/page.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/prices/page.meta.js +16 -0
- package/dist/modules/catalog/backend/catalog/prices/page.meta.js.map +7 -0
- package/dist/modules/catalog/components/prices/PriceScopeSelectors.js +201 -0
- package/dist/modules/catalog/components/prices/PriceScopeSelectors.js.map +7 -0
- package/dist/modules/catalog/components/prices/PricesDataTable.js +273 -0
- package/dist/modules/catalog/components/prices/PricesDataTable.js.map +7 -0
- package/dist/modules/catalog/components/prices/normalizePriceRecord.js +43 -0
- package/dist/modules/catalog/components/prices/normalizePriceRecord.js.map +7 -0
- package/dist/modules/catalog/components/prices/priceFormFields.js +195 -0
- package/dist/modules/catalog/components/prices/priceFormFields.js.map +7 -0
- package/dist/modules/catalog/components/prices/useBatchLabels.js +42 -0
- package/dist/modules/catalog/components/prices/useBatchLabels.js.map +7 -0
- package/dist/modules/catalog/data/validators.js +21 -2
- package/dist/modules/catalog/data/validators.js.map +2 -2
- package/dist/modules/catalog/extension-points.js +2 -1
- package/dist/modules/catalog/extension-points.js.map +2 -2
- package/dist/modules/catalog/lib/pricing.js +37 -8
- package/dist/modules/catalog/lib/pricing.js.map +2 -2
- package/dist/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.js +88 -0
- package/dist/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.js.map +7 -0
- package/dist/modules/communication_channels/backend/profile/communication-channels/page.js +6 -12
- package/dist/modules/communication_channels/backend/profile/communication-channels/page.js.map +2 -2
- package/dist/modules/communication_channels/commands/connect-credential-channel.js +7 -2
- package/dist/modules/communication_channels/commands/connect-credential-channel.js.map +2 -2
- package/dist/modules/communication_channels/commands/ingest-inbound-message.js +11 -2
- package/dist/modules/communication_channels/commands/ingest-inbound-message.js.map +2 -2
- package/dist/modules/communication_channels/extension-points.js +1 -1
- package/dist/modules/communication_channels/extension-points.js.map +1 -1
- package/dist/modules/communication_channels/lib/connect-channel.js +33 -0
- package/dist/modules/communication_channels/lib/connect-channel.js.map +2 -2
- package/dist/modules/messages/api/route.js +5 -1
- package/dist/modules/messages/api/route.js.map +2 -2
- package/dist/modules/messages/commands/messages.js +3 -2
- package/dist/modules/messages/commands/messages.js.map +2 -2
- package/dist/modules/messages/data/validators.js +12 -2
- package/dist/modules/messages/data/validators.js.map +2 -2
- package/dist/modules/messages/subscribers/message-notification.js +4 -1
- package/dist/modules/messages/subscribers/message-notification.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/catalog/AGENTS.md +6 -2
- package/src/modules/catalog/backend/catalog/prices/[id]/edit/page.meta.ts +12 -0
- package/src/modules/catalog/backend/catalog/prices/[id]/edit/page.tsx +198 -0
- package/src/modules/catalog/backend/catalog/prices/create/page.meta.ts +23 -0
- package/src/modules/catalog/backend/catalog/prices/create/page.tsx +70 -0
- package/src/modules/catalog/backend/catalog/prices/page.meta.ts +12 -0
- package/src/modules/catalog/backend/catalog/prices/page.tsx +14 -0
- package/src/modules/catalog/components/prices/PriceScopeSelectors.tsx +243 -0
- package/src/modules/catalog/components/prices/PricesDataTable.tsx +320 -0
- package/src/modules/catalog/components/prices/normalizePriceRecord.ts +71 -0
- package/src/modules/catalog/components/prices/priceFormFields.tsx +228 -0
- package/src/modules/catalog/components/prices/useBatchLabels.ts +57 -0
- package/src/modules/catalog/data/validators.ts +43 -2
- package/src/modules/catalog/extension-points.ts +1 -0
- package/src/modules/catalog/i18n/de.json +72 -0
- package/src/modules/catalog/i18n/en.json +72 -0
- package/src/modules/catalog/i18n/es.json +72 -0
- package/src/modules/catalog/i18n/ko.json +72 -0
- package/src/modules/catalog/i18n/pl.json +72 -0
- package/src/modules/catalog/lib/pricing.ts +103 -9
- package/src/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.tsx +120 -0
- package/src/modules/communication_channels/backend/profile/communication-channels/page.tsx +9 -10
- package/src/modules/communication_channels/commands/connect-credential-channel.ts +9 -0
- package/src/modules/communication_channels/commands/ingest-inbound-message.ts +17 -2
- package/src/modules/communication_channels/extension-points.ts +1 -1
- package/src/modules/communication_channels/i18n/de.json +2 -1
- package/src/modules/communication_channels/i18n/en.json +2 -1
- package/src/modules/communication_channels/i18n/es.json +2 -1
- package/src/modules/communication_channels/i18n/ko.json +2 -1
- package/src/modules/communication_channels/i18n/pl.json +2 -1
- package/src/modules/communication_channels/lib/adapter.ts +8 -0
- package/src/modules/communication_channels/lib/connect-channel.ts +49 -0
- package/src/modules/messages/api/route.ts +8 -2
- package/src/modules/messages/commands/messages.ts +17 -2
- package/src/modules/messages/data/validators.ts +26 -11
- package/src/modules/messages/subscribers/message-notification.ts +17 -5
- 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-
|
|
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
|
-
|
|
518
|
-
|
|
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.
|
|
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
|
-
|
|
394
|
-
|
|
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/
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
474
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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`
|
|
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
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
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:
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
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
|
-
-
|
|
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
|
|