@open-mercato/core 0.8.1-develop.7221.1.ece7ca874e → 0.8.1-develop.7227.1.9248c14599
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/api/post/channels/[id]/import-history/route.js +5 -1
- package/dist/modules/communication_channels/api/post/channels/[id]/import-history/route.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 +17 -19
- 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/commands/queue-import-history.js +40 -7
- package/dist/modules/communication_channels/commands/queue-import-history.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/communication_channels/lib/import-history-limits.js +69 -0
- package/dist/modules/communication_channels/lib/import-history-limits.js.map +7 -0
- package/dist/modules/communication_channels/workers/channel-import-history.js +36 -2
- package/dist/modules/communication_channels/workers/channel-import-history.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/dist/modules/query_index/lib/engine.js +19 -1
- package/dist/modules/query_index/lib/engine.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/api/post/channels/[id]/import-history/route.ts +6 -1
- package/src/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.tsx +120 -0
- package/src/modules/communication_channels/backend/profile/communication-channels/page.tsx +20 -17
- 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/commands/queue-import-history.ts +59 -11
- package/src/modules/communication_channels/extension-points.ts +1 -1
- package/src/modules/communication_channels/i18n/de.json +4 -3
- package/src/modules/communication_channels/i18n/en.json +4 -3
- package/src/modules/communication_channels/i18n/es.json +4 -3
- package/src/modules/communication_channels/i18n/ko.json +4 -3
- package/src/modules/communication_channels/i18n/pl.json +4 -3
- package/src/modules/communication_channels/lib/adapter.ts +16 -2
- package/src/modules/communication_channels/lib/connect-channel.ts +49 -0
- package/src/modules/communication_channels/lib/import-history-limits.ts +109 -0
- package/src/modules/communication_channels/workers/channel-import-history.ts +46 -5
- 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/query_index/lib/engine.ts +23 -1
- package/src/modules/sales/AGENTS.md +2 -3
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../../../src/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.tsx"],
|
|
4
|
+
"sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport { ChevronDown } from 'lucide-react'\nimport { extensionPoints } from '@open-mercato/core/modules/communication_channels/extension-points'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { InjectionSpot, useInjectionWidgets } from '@open-mercato/ui/backend/injection/InjectionSpot'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { cn } from '@open-mercato/shared/lib/utils'\n\nconst CONNECT_MENU_TRIGGER_ID = 'communication-channels-connect-trigger'\nconst CONNECT_MENU_PANEL_ID = 'communication-channels-connect-menu'\n\ntype ConnectChannelMenuProps = {\n onConnected: () => void\n}\n\n/**\n * Single \"Connect channel\" entry point for the profile page.\n *\n * Every `channel-*` package injects its own connect control into the\n * `profile:communication-channels:connect` spot. Rendering that spot inline put\n * each provider button side by side in the page header, unspaced and growing\n * wider with every installed provider (#5595). The widgets now stack inside a\n * dropdown, so a new provider lengthens a list instead of widening a row.\n *\n * The panel stays mounted and is hidden with CSS rather than being conditionally\n * rendered: `channel-imap` and `channel-discord` own their connect `Dialog`\n * inside the injected widget, so unmounting the panel on close would tear down\n * an open dialog.\n */\nexport function ConnectChannelMenu({ onConnected }: ConnectChannelMenuProps): React.JSX.Element | null {\n const t = useT()\n const spotId = extensionPoints.hosts.profileConnect.spotId\n const [open, setOpen] = React.useState(false)\n const containerRef = React.useRef<HTMLDivElement>(null)\n const triggerRef = React.useRef<HTMLButtonElement>(null)\n\n const context = React.useMemo(() => ({ reload: onConnected }), [onConnected])\n const { widgets } = useInjectionWidgets(spotId, { context, triggerOnLoad: true })\n\n React.useEffect(() => {\n if (!open) return\n // A provider's credential dialog renders through a portal at `document.body`,\n // so it sits outside `containerRef` and a click inside it reads as an outside\n // click. Dismissing on those would collapse the panel behind the open dialog \u2014\n // the tear-down the panel is kept mounted to avoid in the first place.\n //\n // Escape is deliberately not guarded the same way. The provider widgets render\n // a controlled `Dialog` with no `DialogTrigger`, so Radix has no recorded\n // element to hand focus back to and drops it on `<body>` when the dialog\n // closes \u2014 measured in Chromium, where focus was still on `<body>` two seconds\n // later. Closing the menu here and calling `focus()` is then the only thing\n // that leaves a keyboard user anywhere usable, so Escape dismisses both layers\n // together on purpose until the provider widgets restore focus themselves.\n function isInsideDialog(target: EventTarget | null): boolean {\n const element = target as Element | null\n return typeof element?.closest === 'function' && element.closest('[role=\"dialog\"]') !== null\n }\n function onDocumentMouseDown(event: MouseEvent) {\n if (isInsideDialog(event.target)) return\n const target = event.target as Node\n if (containerRef.current && !containerRef.current.contains(target)) setOpen(false)\n }\n function onKeyDown(event: KeyboardEvent) {\n if (event.key !== 'Escape') return\n setOpen(false)\n triggerRef.current?.focus()\n }\n document.addEventListener('mousedown', onDocumentMouseDown)\n document.addEventListener('keydown', onKeyDown)\n return () => {\n document.removeEventListener('mousedown', onDocumentMouseDown)\n document.removeEventListener('keydown', onKeyDown)\n }\n }, [open])\n\n if (widgets.length === 0) return null\n\n return (\n <div ref={containerRef} className=\"relative inline-block text-left\">\n <Button\n ref={triggerRef}\n id={CONNECT_MENU_TRIGGER_ID}\n type=\"button\"\n variant=\"outline\"\n aria-expanded={open}\n aria-controls={CONNECT_MENU_PANEL_ID}\n onClick={() => setOpen((current) => !current)}\n >\n {t('communication_channels.profile.connect.menu', 'Connect channel')}\n <ChevronDown className={cn('size-4 transition-transform', open && 'rotate-180')} />\n </Button>\n {/* A disclosure group, not `role=\"menu\"`: the rows are provider-owned\n widgets rendering plain buttons, and ARIA only allows `menuitem`-shaped\n children under a menu \u2014 declaring one would hide them from assistive\n technology instead of describing them. */}\n <div\n id={CONNECT_MENU_PANEL_ID}\n role=\"group\"\n aria-labelledby={CONNECT_MENU_TRIGGER_ID}\n data-testid=\"connect-channel-menu-panel\"\n className={cn(\n // Anchoring flips with the header: the header wraps on a phone, which\n // moves the trigger to the left edge, and right-anchoring a panel\n // wider than the trigger then pushes it off-screen. Left-anchor below\n // `sm`, right-anchor from `sm` up where the trigger sits right. The\n // `max-w-` clamp (mirroring RowActions) keeps a long provider label\n // from widening the panel past the viewport either way.\n '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',\n open ? 'flex' : 'hidden',\n )}\n >\n <InjectionSpot spotId={spotId} context={context} data={{}} widgetsOverride={widgets} />\n </div>\n </div>\n )\n}\n\nexport default ConnectChannelMenu\n"],
|
|
5
|
+
"mappings": ";AAiFM,SAUE,KAVF;AA/EN,YAAY,WAAW;AACvB,SAAS,mBAAmB;AAC5B,SAAS,uBAAuB;AAChC,SAAS,cAAc;AACvB,SAAS,eAAe,2BAA2B;AACnD,SAAS,YAAY;AACrB,SAAS,UAAU;AAEnB,MAAM,0BAA0B;AAChC,MAAM,wBAAwB;AAoBvB,SAAS,mBAAmB,EAAE,YAAY,GAAsD;AACrG,QAAM,IAAI,KAAK;AACf,QAAM,SAAS,gBAAgB,MAAM,eAAe;AACpD,QAAM,CAAC,MAAM,OAAO,IAAI,MAAM,SAAS,KAAK;AAC5C,QAAM,eAAe,MAAM,OAAuB,IAAI;AACtD,QAAM,aAAa,MAAM,OAA0B,IAAI;AAEvD,QAAM,UAAU,MAAM,QAAQ,OAAO,EAAE,QAAQ,YAAY,IAAI,CAAC,WAAW,CAAC;AAC5E,QAAM,EAAE,QAAQ,IAAI,oBAAoB,QAAQ,EAAE,SAAS,eAAe,KAAK,CAAC;AAEhF,QAAM,UAAU,MAAM;AACpB,QAAI,CAAC,KAAM;AAaX,aAAS,eAAe,QAAqC;AAC3D,YAAM,UAAU;AAChB,aAAO,OAAO,SAAS,YAAY,cAAc,QAAQ,QAAQ,iBAAiB,MAAM;AAAA,IAC1F;AACA,aAAS,oBAAoB,OAAmB;AAC9C,UAAI,eAAe,MAAM,MAAM,EAAG;AAClC,YAAM,SAAS,MAAM;AACrB,UAAI,aAAa,WAAW,CAAC,aAAa,QAAQ,SAAS,MAAM,EAAG,SAAQ,KAAK;AAAA,IACnF;AACA,aAAS,UAAU,OAAsB;AACvC,UAAI,MAAM,QAAQ,SAAU;AAC5B,cAAQ,KAAK;AACb,iBAAW,SAAS,MAAM;AAAA,IAC5B;AACA,aAAS,iBAAiB,aAAa,mBAAmB;AAC1D,aAAS,iBAAiB,WAAW,SAAS;AAC9C,WAAO,MAAM;AACX,eAAS,oBAAoB,aAAa,mBAAmB;AAC7D,eAAS,oBAAoB,WAAW,SAAS;AAAA,IACnD;AAAA,EACF,GAAG,CAAC,IAAI,CAAC;AAET,MAAI,QAAQ,WAAW,EAAG,QAAO;AAEjC,SACE,qBAAC,SAAI,KAAK,cAAc,WAAU,mCAChC;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,KAAK;AAAA,QACL,IAAI;AAAA,QACJ,MAAK;AAAA,QACL,SAAQ;AAAA,QACR,iBAAe;AAAA,QACf,iBAAe;AAAA,QACf,SAAS,MAAM,QAAQ,CAAC,YAAY,CAAC,OAAO;AAAA,QAE3C;AAAA,YAAE,+CAA+C,iBAAiB;AAAA,UACnE,oBAAC,eAAY,WAAW,GAAG,+BAA+B,QAAQ,YAAY,GAAG;AAAA;AAAA;AAAA,IACnF;AAAA,IAKA;AAAA,MAAC;AAAA;AAAA,QACC,IAAI;AAAA,QACJ,MAAK;AAAA,QACL,mBAAiB;AAAA,QACjB,eAAY;AAAA,QACZ,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,UAOT;AAAA,UACA,OAAO,SAAS;AAAA,QAClB;AAAA,QAEA,8BAAC,iBAAc,QAAgB,SAAkB,MAAM,CAAC,GAAG,iBAAiB,SAAS;AAAA;AAAA,IACvF;AAAA,KACF;AAEJ;AAEA,IAAO,6BAAQ;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { extensionPoints } from "@open-mercato/core/modules/communication_channels/extension-points";
|
|
5
|
+
import { getImportHistoryLimits } from "@open-mercato/core/modules/communication_channels/lib/import-history-limits";
|
|
5
6
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
6
7
|
import { Page, PageBody } from "@open-mercato/ui/backend/Page";
|
|
7
8
|
import { DataTable } from "@open-mercato/ui/backend/DataTable";
|
|
@@ -20,11 +21,11 @@ import { Input } from "@open-mercato/ui/primitives/input";
|
|
|
20
21
|
import { Label } from "@open-mercato/ui/primitives/label";
|
|
21
22
|
import { Textarea } from "@open-mercato/ui/primitives/textarea";
|
|
22
23
|
import { KbdShortcut } from "@open-mercato/ui/primitives/kbd";
|
|
23
|
-
import { InjectionSpot } from "@open-mercato/ui/backend/injection/InjectionSpot";
|
|
24
24
|
import { apiCall } from "@open-mercato/ui/backend/utils/apiCall";
|
|
25
25
|
import { useGuardedMutation } from "@open-mercato/ui/backend/injection/useGuardedMutation";
|
|
26
26
|
import { flash } from "@open-mercato/ui/backend/FlashMessages";
|
|
27
27
|
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
28
|
+
import { ConnectChannelMenu } from "./ConnectChannelMenu.js";
|
|
28
29
|
const PROFILE_CHANNELS_MUTATION_CONTEXT_ID = "communication-channels-profile";
|
|
29
30
|
const IMPORT_HISTORY_MUTATION_CONTEXT_ID = "communication-channels-import-history";
|
|
30
31
|
const DISCONNECT_MUTATION_CONTEXT_ID = "communication-channels-disconnect";
|
|
@@ -98,6 +99,7 @@ function ProfileCommunicationChannelsPage() {
|
|
|
98
99
|
};
|
|
99
100
|
}, [reloadKey, t]);
|
|
100
101
|
const reauthRows = rows.filter((r) => r.status === "requires_reauth");
|
|
102
|
+
const reloadChannels = React.useCallback(() => setReloadKey((k) => k + 1), []);
|
|
101
103
|
const onSetPrimary = React.useCallback(
|
|
102
104
|
async (channelId) => {
|
|
103
105
|
let response;
|
|
@@ -383,22 +385,15 @@ function ProfileCommunicationChannelsPage() {
|
|
|
383
385
|
[onSetPrimary, onPollNow, onRegisterPush, t]
|
|
384
386
|
);
|
|
385
387
|
return /* @__PURE__ */ jsx(Page, { children: /* @__PURE__ */ jsxs(PageBody, { children: [
|
|
386
|
-
/* @__PURE__ */ jsxs("header", { className: "mb-4 flex items-
|
|
387
|
-
/* @__PURE__ */ jsxs("div", { children: [
|
|
388
|
+
/* @__PURE__ */ jsxs("header", { className: "mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4", children: [
|
|
389
|
+
/* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [
|
|
388
390
|
/* @__PURE__ */ jsx("h1", { className: "text-2xl font-semibold", children: t("communication_channels.profile.title", "My communication channels") }),
|
|
389
391
|
/* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: t(
|
|
390
392
|
"communication_channels.profile.subtitle",
|
|
391
393
|
"Connect your communication channels so outbound messages come from your own account and inbound messages land in your unified inbox."
|
|
392
394
|
) })
|
|
393
395
|
] }),
|
|
394
|
-
/* @__PURE__ */ jsx(
|
|
395
|
-
InjectionSpot,
|
|
396
|
-
{
|
|
397
|
-
spotId: extensionPoints.hosts.profileConnect.spotId,
|
|
398
|
-
context: { reload: () => setReloadKey((k) => k + 1) },
|
|
399
|
-
data: {}
|
|
400
|
-
}
|
|
401
|
-
)
|
|
396
|
+
/* @__PURE__ */ jsx(ConnectChannelMenu, { onConnected: reloadChannels })
|
|
402
397
|
] }),
|
|
403
398
|
reauthRows.length > 0 ? /* @__PURE__ */ jsx(Alert, { status: "warning", className: "mb-4", children: /* @__PURE__ */ jsx(AlertDescription, { children: t(
|
|
404
399
|
"communication_channels.profile.alerts.requiresReauth",
|
|
@@ -417,7 +412,7 @@ function ProfileCommunicationChannelsPage() {
|
|
|
417
412
|
error: errorMessage,
|
|
418
413
|
emptyState: t(
|
|
419
414
|
"communication_channels.profile.empty",
|
|
420
|
-
"You have no connected channels yet.
|
|
415
|
+
"You have no connected channels yet. Add one using the menu at the top of this page."
|
|
421
416
|
)
|
|
422
417
|
}
|
|
423
418
|
),
|
|
@@ -447,6 +442,7 @@ function ProfileCommunicationChannelsPage() {
|
|
|
447
442
|
}
|
|
448
443
|
function ImportHistoryDialog({ channel, onClose, onQueued }) {
|
|
449
444
|
const t = useT();
|
|
445
|
+
const importLimits = React.useMemo(() => getImportHistoryLimits(channel?.providerKey), [channel?.providerKey]);
|
|
450
446
|
const [sinceDays, setSinceDays] = React.useState("30");
|
|
451
447
|
const [contactEmails, setContactEmails] = React.useState("");
|
|
452
448
|
const [maxMessages, setMaxMessages] = React.useState("500");
|
|
@@ -470,16 +466,18 @@ function ImportHistoryDialog({ channel, onClose, onQueued }) {
|
|
|
470
466
|
const sinceNum = Number.parseInt(sinceDays, 10);
|
|
471
467
|
const maxNum = Number.parseInt(maxMessages, 10);
|
|
472
468
|
const errors = {};
|
|
473
|
-
if (!Number.isFinite(sinceNum) || sinceNum < 1 || sinceNum >
|
|
469
|
+
if (!Number.isFinite(sinceNum) || sinceNum < 1 || sinceNum > importLimits.maxSinceDays) {
|
|
474
470
|
errors.sinceDays = t(
|
|
475
471
|
"communication_channels.profile.importHistory.errors.sinceDays",
|
|
476
|
-
"Choose a number between 1 and
|
|
472
|
+
"Choose a number between 1 and {max} days.",
|
|
473
|
+
{ max: importLimits.maxSinceDays }
|
|
477
474
|
);
|
|
478
475
|
}
|
|
479
|
-
if (!Number.isFinite(maxNum) || maxNum < 1 || maxNum >
|
|
476
|
+
if (!Number.isFinite(maxNum) || maxNum < 1 || maxNum > importLimits.maxMessages) {
|
|
480
477
|
errors.maxMessages = t(
|
|
481
478
|
"communication_channels.profile.importHistory.errors.maxMessages",
|
|
482
|
-
"Choose a number between 1 and
|
|
479
|
+
"Choose a number between 1 and {max} messages.",
|
|
480
|
+
{ max: importLimits.maxMessages }
|
|
483
481
|
);
|
|
484
482
|
}
|
|
485
483
|
const parsedEmails = contactEmails.split(/[\s,;]+/).map((s) => s.trim()).filter(Boolean);
|
|
@@ -551,7 +549,7 @@ function ImportHistoryDialog({ channel, onClose, onQueued }) {
|
|
|
551
549
|
"success"
|
|
552
550
|
);
|
|
553
551
|
onQueued();
|
|
554
|
-
}, [channel, sinceDays, maxMessages, contactEmails, submitting, t, onQueued, retryLastMutation, runMutation]);
|
|
552
|
+
}, [channel, sinceDays, maxMessages, contactEmails, submitting, t, onQueued, retryLastMutation, runMutation, importLimits]);
|
|
555
553
|
const handleKeyDown = React.useCallback(
|
|
556
554
|
(event) => {
|
|
557
555
|
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
|
@@ -580,7 +578,7 @@ function ImportHistoryDialog({ channel, onClose, onQueued }) {
|
|
|
580
578
|
id: "import-history-since",
|
|
581
579
|
type: "number",
|
|
582
580
|
min: 1,
|
|
583
|
-
max:
|
|
581
|
+
max: importLimits.maxSinceDays,
|
|
584
582
|
value: sinceDays,
|
|
585
583
|
onChange: (e) => setSinceDays(e.target.value),
|
|
586
584
|
"aria-invalid": Boolean(fieldErrors.sinceDays)
|
|
@@ -620,7 +618,7 @@ function ImportHistoryDialog({ channel, onClose, onQueued }) {
|
|
|
620
618
|
id: "import-history-max",
|
|
621
619
|
type: "number",
|
|
622
620
|
min: 1,
|
|
623
|
-
max:
|
|
621
|
+
max: importLimits.maxMessages,
|
|
624
622
|
value: maxMessages,
|
|
625
623
|
onChange: (e) => setMaxMessages(e.target.value),
|
|
626
624
|
"aria-invalid": Boolean(fieldErrors.maxMessages)
|
package/dist/modules/communication_channels/backend/profile/communication-channels/page.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/communication_channels/backend/profile/communication-channels/page.tsx"],
|
|
4
|
-
"sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport { extensionPoints } from '@open-mercato/core/modules/communication_channels/extension-points'\nimport type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Page, PageBody } from '@open-mercato/ui/backend/Page'\nimport { DataTable } from '@open-mercato/ui/backend/DataTable'\nimport { Tag } from '@open-mercato/ui/primitives/tag'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { Alert, AlertDescription } from '@open-mercato/ui/primitives/alert'\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from '@open-mercato/ui/primitives/dialog'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { Label } from '@open-mercato/ui/primitives/label'\nimport { Textarea } from '@open-mercato/ui/primitives/textarea'\nimport { KbdShortcut } from '@open-mercato/ui/primitives/kbd'\nimport { InjectionSpot } from '@open-mercato/ui/backend/injection/InjectionSpot'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\ntype ChannelRow = {\n id: string\n providerKey: string\n channelType: string\n displayName: string\n externalIdentifier: string | null\n isPrimary: boolean\n isActive: boolean\n status: 'connected' | 'requires_reauth' | 'error' | 'disconnected'\n lastError: string | null\n pollIntervalSeconds: number | null\n lastPolledAt: string | null\n /** Spec C \u2014 push delivery state (null when provider doesn't support push). */\n pushStatus: 'active' | 'inactive' | 'failed' | null\n lastPushError: { code: string | null; message: string | null; at: string | null } | null\n /**\n * `true` when the adapter declares real-time push, so the hub's poll worker\n * skips this channel entirely \u2014 inbound arrives over the provider connection.\n */\n supportsRealtimePush: boolean\n /** `true` when the adapter implements `registerPush` (Gmail-style subscriptions). */\n supportsPushRegistration: boolean\n createdAt: string | null\n}\n\nconst PROFILE_CHANNELS_MUTATION_CONTEXT_ID = 'communication-channels-profile'\nconst IMPORT_HISTORY_MUTATION_CONTEXT_ID = 'communication-channels-import-history'\nconst DISCONNECT_MUTATION_CONTEXT_ID = 'communication-channels-disconnect'\n\ntype ChannelMutationContext = {\n formId: string\n resourceKind: string\n resourceId: string\n retryLastMutation: () => Promise<boolean>\n}\n\nexport default function ProfileCommunicationChannelsPage() {\n const t = useT()\n const router = useRouter()\n const searchParams = useSearchParams()\n const flashType = searchParams?.get('flash')\n const flashCode = searchParams?.get('code')\n const flashProvider = searchParams?.get('provider')\n\n const [rows, setRows] = React.useState<ChannelRow[]>([])\n const [isLoading, setIsLoading] = React.useState(true)\n const [errorMessage, setErrorMessage] = React.useState<string | null>(null)\n const [reloadKey, setReloadKey] = React.useState(0)\n const [importChannel, setImportChannel] = React.useState<ChannelRow | null>(null)\n const [disconnectChannel, setDisconnectChannel] = React.useState<ChannelRow | null>(null)\n const { runMutation, retryLastMutation } = useGuardedMutation<ChannelMutationContext>({\n contextId: PROFILE_CHANNELS_MUTATION_CONTEXT_ID,\n blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),\n })\n\n React.useEffect(() => {\n if (flashType === 'connected') {\n flash(\n flashProvider\n ? t('communication_channels.profile.flash.connectedWithProvider', 'Channel connected ({provider}).', {\n provider: flashProvider,\n })\n : t('communication_channels.profile.flash.connected', 'Channel connected.'),\n 'success',\n )\n } else if (flashType === 'error') {\n flash(\n flashCode === 'oauth_client_not_configured'\n ? t(\n 'communication_channels.profile.connect.notConfigured',\n 'This provider is not configured yet. Ask an administrator to add the OAuth Client ID and Secret under Integrations before connecting a mailbox.',\n )\n : flashCode === 'mailbox_already_connected'\n ? t(\n 'communication_channels.profile.connect.mailboxAlreadyConnected',\n 'This mailbox is already connected through another provider. Disconnect it first to reconnect it with a different one.',\n )\n : flashCode\n ? t('communication_channels.profile.flash.errorWithCode', 'Failed to connect channel \u2014 {code}.', {\n code: flashCode,\n })\n : t('communication_channels.profile.flash.error', 'Failed to connect channel.'),\n 'error',\n )\n }\n }, [flashType, flashCode, flashProvider, t])\n\n React.useEffect(() => {\n let cancelled = false\n async function load() {\n setIsLoading(true)\n setErrorMessage(null)\n const response = await apiCall<{ items?: ChannelRow[] }>(\n '/api/communication_channels/me/channels',\n ).catch((err: unknown) => ({\n ok: false,\n result: { error: err instanceof Error ? err.message : 'Failed to load channels' },\n }))\n if (cancelled) return\n if (!response.ok) {\n const body = response.result as { error?: string } | undefined\n setErrorMessage(\n body?.error ?? t('communication_channels.errors.loadList', 'Failed to load channels'),\n )\n setRows([])\n } else {\n const data = (response.result ?? {}) as { items?: ChannelRow[] }\n setRows(Array.isArray(data.items) ? data.items : [])\n }\n setIsLoading(false)\n }\n void load()\n return () => {\n cancelled = true\n }\n }, [reloadKey, t])\n\n const reauthRows = rows.filter((r) => r.status === 'requires_reauth')\n\n const onSetPrimary = React.useCallback(\n async (channelId: string) => {\n let response\n try {\n response = await runMutation({\n operation: () => apiCall(\n `/api/communication_channels/channels/${encodeURIComponent(channelId)}/set-primary`,\n { method: 'POST' },\n ),\n context: {\n formId: PROFILE_CHANNELS_MUTATION_CONTEXT_ID,\n resourceKind: 'communication_channels.channel',\n resourceId: channelId,\n retryLastMutation,\n },\n mutationPayload: { isPrimary: true },\n })\n } catch (err) {\n flash(err instanceof Error ? err.message : t('communication_channels.profile.actions.setPrimaryFailed', 'Failed to set as primary'), 'error')\n return\n }\n if (!response.ok) {\n const body = response.result as { error?: string } | undefined\n flash(\n body?.error ?? t('communication_channels.profile.actions.setPrimaryFailed', 'Failed to set as primary'),\n 'error',\n )\n return\n }\n flash(\n t('communication_channels.profile.actions.setPrimarySuccess', 'Marked as primary.'),\n 'success',\n )\n setReloadKey((k) => k + 1)\n },\n [retryLastMutation, runMutation, t],\n )\n\n const onRegisterPush = React.useCallback(\n async (channelId: string) => {\n let response\n try {\n response = await runMutation({\n operation: () => apiCall<{ pushStatus?: string; error?: { code: string; message: string } }>(\n `/api/communication_channels/channels/${encodeURIComponent(channelId)}/push/register`,\n { method: 'POST' },\n ),\n context: {\n formId: PROFILE_CHANNELS_MUTATION_CONTEXT_ID,\n resourceKind: 'communication_channels.channel',\n resourceId: channelId,\n retryLastMutation,\n },\n mutationPayload: { action: 'push-register' },\n })\n } catch (err) {\n flash(err instanceof Error ? err.message : t('communication_channels.push.button.reregister', 'Re-register push'), 'error')\n return\n }\n if (!response.ok) {\n const body = response.result as { error?: string } | undefined\n flash(body?.error ?? t('communication_channels.push.flash.registerFailed', 'Failed to register push'), 'error')\n return\n }\n const result = (response.result ?? {}) as { pushStatus?: string }\n if (result.pushStatus === 'active') {\n flash(t('communication_channels.push.status.active', 'Push active'), 'success')\n } else {\n flash(\n t(\n 'communication_channels.push.status.failed',\n 'Push registration returned a non-active status \u2014 falling back to polling.',\n ),\n 'error',\n )\n }\n setReloadKey((k) => k + 1)\n },\n [retryLastMutation, runMutation, t],\n )\n\n const onPollNow = React.useCallback(\n async (channelId: string) => {\n let response\n try {\n response = await runMutation({\n operation: () => apiCall(\n `/api/communication_channels/channels/${encodeURIComponent(channelId)}/poll-now`,\n { method: 'POST' },\n ),\n context: {\n formId: PROFILE_CHANNELS_MUTATION_CONTEXT_ID,\n resourceKind: 'communication_channels.channel',\n resourceId: channelId,\n retryLastMutation,\n },\n mutationPayload: { action: 'poll-now' },\n })\n } catch (err) {\n flash(err instanceof Error ? err.message : t('communication_channels.profile.actions.pollNowFailed', 'Failed to trigger poll'), 'error')\n return\n }\n if (!response.ok) {\n const body = response.result as { error?: string } | undefined\n flash(\n body?.error ?? t('communication_channels.profile.actions.pollNowFailed', 'Failed to trigger poll'),\n 'error',\n )\n return\n }\n flash(\n t(\n 'communication_channels.profile.actions.pollNowSuccess',\n 'Poll triggered \u2014 new messages will appear on linked Person timelines in a few seconds.',\n ),\n 'success',\n )\n // Give the worker a moment to fetch + ingest, then refetch our channel list\n // so `lastPolledAt` updates in the UI.\n setTimeout(() => setReloadKey((k) => k + 1), 1500)\n },\n [retryLastMutation, runMutation, t],\n )\n\n const columns = React.useMemo<ColumnDef<ChannelRow>[]>(\n () => [\n {\n header: t('communication_channels.columns.displayName', 'Channel'),\n accessorKey: 'displayName',\n },\n {\n header: t('communication_channels.columns.provider', 'Provider'),\n accessorKey: 'providerKey',\n cell: ({ row }) => (\n <Tag variant=\"info\">\n {t(\n `communication_channels.channel.providers.${row.original.providerKey}`,\n row.original.providerKey,\n )}\n </Tag>\n ),\n },\n {\n header: t('communication_channels.columns.identifier', 'Email / username'),\n accessorKey: 'externalIdentifier',\n cell: ({ row }) => row.original.externalIdentifier ?? '\u2014',\n meta: { truncate: true, maxWidth: 240 },\n },\n {\n header: t('communication_channels.profile.columns.primary', 'Primary'),\n accessorKey: 'isPrimary',\n cell: ({ row }) =>\n row.original.isPrimary ? (\n <Tag variant=\"success\" dot>\n {t('communication_channels.profile.primary', 'Primary')}\n </Tag>\n ) : (\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={() => void onSetPrimary(row.original.id)}\n aria-label={t('communication_channels.profile.actions.setPrimary', 'Set as primary')}\n >\n {t('communication_channels.profile.actions.setPrimary', 'Set as primary')}\n </Button>\n ),\n },\n {\n header: t('communication_channels.columns.status', 'Status'),\n accessorKey: 'status',\n cell: ({ row }) => statusTag(row.original.status, t),\n },\n {\n id: 'pushStatus',\n // Its own key: sharing `push.status.active` made the header render\n // \"Push active\" over a column whose rows say \"Polling only\" (#4980).\n header: t('communication_channels.profile.columns.push', 'Push'),\n cell: ({ row }) => {\n const ps = row.original.pushStatus\n const errorTitle = row.original.lastPushError?.message ?? undefined\n // Derived from the adapter's declared capabilities, never from the\n // provider name: `supportsPushRegistration` means push subscriptions\n // can be (re-)registered from here, `supportsRealtimePush` means the\n // hub's poll worker skips the channel because the provider connection\n // delivers inbound itself (#4980).\n if (!row.original.supportsPushRegistration) {\n if (!row.original.supportsRealtimePush) {\n return (\n <span className=\"text-xs text-muted-foreground\">\n {t('communication_channels.push.status.inactive', 'Polling only')}\n </span>\n )\n }\n if (ps === 'failed') {\n return (\n <Tag variant=\"error\" dot title={errorTitle}>\n {t('communication_channels.push.status.pushDrivenFailed', 'Push connection failed')}\n </Tag>\n )\n }\n return (\n <Tag variant=\"success\" dot>\n {t('communication_channels.push.status.pushDriven', 'Push-driven')}\n </Tag>\n )\n }\n if (ps === 'active') {\n return (\n <Tag variant=\"success\" dot>\n {t('communication_channels.push.status.active', 'Push active')}\n </Tag>\n )\n }\n if (ps === 'failed') {\n return (\n <div className=\"flex items-center gap-2\">\n <Tag variant=\"error\" dot>\n {t('communication_channels.push.status.failed', 'Push failed \u2014 using polling')}\n </Tag>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={() => void onRegisterPush(row.original.id)}\n aria-label={t('communication_channels.push.button.reregister', 'Re-register push')}\n title={errorTitle}\n >\n {t('communication_channels.push.button.reregister', 'Re-register push')}\n </Button>\n </div>\n )\n }\n // null or 'inactive' \u2014 the provider can register push but has not yet.\n // Only a hub-polled channel falls back to polling meanwhile; for a\n // push-driven one nothing is delivering inbound at all, so claiming\n // \"Polling only\" would repeat the defect this issue is about (#4980).\n return (\n <div className=\"flex items-center gap-2\">\n <span className=\"text-xs text-muted-foreground\">\n {row.original.supportsRealtimePush\n ? t('communication_channels.push.status.notRegistered', 'Push not registered')\n : t('communication_channels.push.status.inactive', 'Polling only')}\n </span>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={() => void onRegisterPush(row.original.id)}\n aria-label={t('communication_channels.push.button.reregister', 'Re-register push')}\n >\n {t('communication_channels.push.button.reregister', 'Re-register push')}\n </Button>\n </div>\n )\n },\n },\n {\n header: t('communication_channels.profile.columns.lastPolled', 'Last synced'),\n accessorKey: 'lastPolledAt',\n cell: ({ row }) =>\n row.original.lastPolledAt\n ? new Date(row.original.lastPolledAt).toLocaleString()\n : '\u2014',\n },\n {\n id: 'importHistory',\n header: t('communication_channels.profile.columns.importHistory', 'History'),\n cell: ({ row }) => {\n const eligible =\n row.original.isActive &&\n row.original.status === 'connected' &&\n row.original.channelType === 'email'\n const label = t('communication_channels.profile.actions.importHistory', 'Import history')\n return (\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={() => setImportChannel(row.original)}\n disabled={!eligible}\n aria-label={label}\n >\n {label}\n </Button>\n )\n },\n },\n {\n id: 'pollNow',\n header: t('communication_channels.profile.columns.pollNow', 'Sync'),\n cell: ({ row }) => {\n // Allowed from 'connected' AND 'error' \u2014 the latter lets the user\n // recover a stuck channel without disconnecting + reconnecting.\n // 'requires_reauth' and 'disconnected' are owned by other flows.\n // A push-driven channel is never polled by the worker, so offering the\n // action at all would promise a sync that cannot happen (#4980).\n const pushDriven = row.original.supportsRealtimePush\n const pollable =\n row.original.isActive &&\n !pushDriven &&\n (row.original.status === 'connected' || row.original.status === 'error')\n const label =\n row.original.status === 'error'\n ? t('communication_channels.profile.actions.retryPoll', 'Retry')\n : t('communication_channels.profile.actions.pollNow', 'Poll now')\n const disabledReason = pushDriven\n ? t(\n 'communication_channels.profile.actions.pollNowPushDriven',\n 'This channel is push-driven \u2014 inbound messages arrive over the provider connection, so polling does not apply.',\n )\n : undefined\n const button = (\n <Button\n type=\"button\"\n variant={row.original.status === 'error' ? 'default' : 'outline'}\n size=\"sm\"\n onClick={() => void onPollNow(row.original.id)}\n disabled={!pollable}\n aria-label={disabledReason ? `${label} \u2014 ${disabledReason}` : label}\n >\n {label}\n </Button>\n )\n // A disabled button does not receive hover events in every browser, so\n // the explanation lives on a wrapper the pointer can still reach.\n return disabledReason ? <span title={disabledReason}>{button}</span> : button\n },\n },\n {\n id: 'disconnect',\n header: t('communication_channels.profile.columns.disconnect', 'Connection'),\n cell: ({ row }) => {\n const label = t('communication_channels.profile.actions.disconnect', 'Disconnect')\n return (\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={() => setDisconnectChannel(row.original)}\n aria-label={label}\n >\n {label}\n </Button>\n )\n },\n },\n ],\n [onSetPrimary, onPollNow, onRegisterPush, t],\n )\n\n return (\n <Page>\n <PageBody>\n <header className=\"mb-4 flex items-baseline justify-between\">\n <div>\n <h1 className=\"text-2xl font-semibold\">\n {t('communication_channels.profile.title', 'My communication channels')}\n </h1>\n <p className=\"text-sm text-muted-foreground\">\n {t(\n 'communication_channels.profile.subtitle',\n 'Connect your communication channels so outbound messages come from your own account and inbound messages land in your unified inbox.',\n )}\n </p>\n </div>\n {/* Provider connect entry points injected by each channel-* package\n (channel-gmail, channel-imap) via UMES. */}\n <InjectionSpot\n spotId={extensionPoints.hosts.profileConnect.spotId}\n context={{ reload: () => setReloadKey((k) => k + 1) }}\n data={{}}\n />\n </header>\n\n {reauthRows.length > 0 ? (\n <Alert status=\"warning\" className=\"mb-4\">\n <AlertDescription>\n {t(\n 'communication_channels.profile.alerts.requiresReauth',\n '{count} channel(s) need reconnection. Click \"Reconnect\" on the affected channel below.',\n { count: reauthRows.length },\n )}\n </AlertDescription>\n </Alert>\n ) : null}\n\n <DataTable<ChannelRow>\n title={t('communication_channels.profile.tableTitle', 'Your channels')}\n titleHeadingLevel={2}\n extensionTableId={extensionPoints.hosts.profileChannelsTable.tableId}\n columns={columns}\n data={rows}\n isLoading={isLoading}\n error={errorMessage}\n emptyState={t(\n 'communication_channels.profile.empty',\n 'You have no connected channels yet. Use one of the Connect buttons above to add a channel.',\n )}\n />\n <ImportHistoryDialog\n channel={importChannel}\n onClose={() => setImportChannel(null)}\n onQueued={() => {\n setImportChannel(null)\n router.refresh()\n }}\n />\n <DisconnectChannelDialog\n channel={disconnectChannel}\n onClose={() => setDisconnectChannel(null)}\n onDisconnected={() => {\n setDisconnectChannel(null)\n setReloadKey((k) => k + 1)\n }}\n />\n </PageBody>\n </Page>\n )\n}\n\ntype ImportHistoryDialogProps = {\n channel: ChannelRow | null\n onClose: () => void\n onQueued: () => void\n}\n\nfunction ImportHistoryDialog({ channel, onClose, onQueued }: ImportHistoryDialogProps): React.JSX.Element {\n const t = useT()\n const [sinceDays, setSinceDays] = React.useState('30')\n const [contactEmails, setContactEmails] = React.useState('')\n const [maxMessages, setMaxMessages] = React.useState('500')\n const [fieldErrors, setFieldErrors] = React.useState<Record<string, string>>({})\n const [submitting, setSubmitting] = React.useState(false)\n const { runMutation, retryLastMutation } = useGuardedMutation<ChannelMutationContext>({\n contextId: IMPORT_HISTORY_MUTATION_CONTEXT_ID,\n blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),\n })\n\n React.useEffect(() => {\n if (channel) {\n setSinceDays('30')\n setContactEmails('')\n setMaxMessages('500')\n setFieldErrors({})\n setSubmitting(false)\n }\n }, [channel?.id])\n\n const handleSubmit = React.useCallback(async () => {\n if (!channel || submitting) return\n const sinceNum = Number.parseInt(sinceDays, 10)\n const maxNum = Number.parseInt(maxMessages, 10)\n const errors: Record<string, string> = {}\n if (!Number.isFinite(sinceNum) || sinceNum < 1 || sinceNum > 365) {\n errors.sinceDays = t(\n 'communication_channels.profile.importHistory.errors.sinceDays',\n 'Choose a number between 1 and 365 days.',\n )\n }\n if (!Number.isFinite(maxNum) || maxNum < 1 || maxNum > 5000) {\n errors.maxMessages = t(\n 'communication_channels.profile.importHistory.errors.maxMessages',\n 'Choose a number between 1 and 5000 messages.',\n )\n }\n const parsedEmails = contactEmails\n .split(/[\\s,;]+/)\n .map((s) => s.trim())\n .filter(Boolean)\n if (parsedEmails.length > 0 && parsedEmails.some((s) => !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(s))) {\n errors.contactEmails = t(\n 'communication_channels.profile.importHistory.errors.contactEmails',\n 'One or more entries is not a valid email address.',\n )\n }\n if (Object.keys(errors).length > 0) {\n setFieldErrors(errors)\n return\n }\n setFieldErrors({})\n setSubmitting(true)\n const mutationPayload = {\n sinceDays: sinceNum,\n maxMessages: maxNum,\n ...(parsedEmails.length > 0 ? { contactEmails: parsedEmails } : {}),\n }\n let response\n try {\n response = await runMutation({\n operation: () => apiCall<{ progressJobId?: string }>(\n `/api/communication_channels/channels/${encodeURIComponent(channel.id)}/import-history`,\n {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(mutationPayload),\n },\n ),\n context: {\n formId: IMPORT_HISTORY_MUTATION_CONTEXT_ID,\n resourceKind: 'communication_channels.channel',\n resourceId: channel.id,\n retryLastMutation,\n },\n mutationPayload,\n })\n } catch (err) {\n setSubmitting(false)\n flash(\n err instanceof Error\n ? err.message\n : t('communication_channels.profile.importHistory.flash.error', 'Failed to queue history import.'),\n 'error',\n )\n return\n }\n setSubmitting(false)\n if (!response.ok) {\n const body = response.result as { error?: string; fieldErrors?: Record<string, string> } | undefined\n if (body?.fieldErrors && Object.keys(body.fieldErrors).length > 0) {\n setFieldErrors(body.fieldErrors)\n return\n }\n flash(\n body?.error ??\n t(\n 'communication_channels.profile.importHistory.flash.error',\n 'Failed to queue history import.',\n ),\n 'error',\n )\n return\n }\n flash(\n t(\n 'communication_channels.profile.importHistory.flash.success',\n 'History import queued \u2014 track progress in the top bar.',\n ),\n 'success',\n )\n onQueued()\n }, [channel, sinceDays, maxMessages, contactEmails, submitting, t, onQueued, retryLastMutation, runMutation])\n\n const handleKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {\n event.preventDefault()\n void handleSubmit()\n }\n },\n [handleSubmit],\n )\n\n return (\n <Dialog open={channel !== null} onOpenChange={(open) => { if (!open) onClose() }}>\n <DialogContent onKeyDown={handleKeyDown}>\n <DialogHeader>\n <DialogTitle>\n {t('communication_channels.profile.importHistory.title', 'Import channel history')}\n </DialogTitle>\n <DialogDescription>\n {t(\n 'communication_channels.profile.importHistory.description',\n 'Pull older messages this channel never observed at connect-time. Filters narrow the search server-side.',\n )}\n </DialogDescription>\n </DialogHeader>\n\n <div className=\"space-y-4\">\n <div className=\"space-y-1\">\n <Label htmlFor=\"import-history-since\">\n {t('communication_channels.profile.importHistory.fields.sinceDays', 'Look back (days)')}\n </Label>\n <Input\n id=\"import-history-since\"\n type=\"number\"\n min={1}\n max={365}\n value={sinceDays}\n onChange={(e) => setSinceDays(e.target.value)}\n aria-invalid={Boolean(fieldErrors.sinceDays)}\n />\n {fieldErrors.sinceDays ? (\n <p className=\"text-xs text-status-error-text\">{fieldErrors.sinceDays}</p>\n ) : null}\n </div>\n\n <div className=\"space-y-1\">\n <Label htmlFor=\"import-history-emails\">\n {t(\n 'communication_channels.profile.importHistory.fields.contactEmails',\n 'Filter by sender (optional)',\n )}\n </Label>\n <Textarea\n id=\"import-history-emails\"\n rows={3}\n value={contactEmails}\n onChange={(e) => setContactEmails(e.target.value)}\n placeholder={t(\n 'communication_channels.profile.importHistory.fields.contactEmailsPlaceholder',\n 'alice@example.com, bob@example.com',\n )}\n aria-invalid={Boolean(fieldErrors.contactEmails)}\n />\n {fieldErrors.contactEmails ? (\n <p className=\"text-xs text-status-error-text\">{fieldErrors.contactEmails}</p>\n ) : (\n <p className=\"text-xs text-muted-foreground\">\n {t(\n 'communication_channels.profile.importHistory.fields.contactEmailsHint',\n 'Leave empty to scan all senders in the window.',\n )}\n </p>\n )}\n </div>\n\n <div className=\"space-y-1\">\n <Label htmlFor=\"import-history-max\">\n {t('communication_channels.profile.importHistory.fields.maxMessages', 'Maximum messages')}\n </Label>\n <Input\n id=\"import-history-max\"\n type=\"number\"\n min={1}\n max={5000}\n value={maxMessages}\n onChange={(e) => setMaxMessages(e.target.value)}\n aria-invalid={Boolean(fieldErrors.maxMessages)}\n />\n {fieldErrors.maxMessages ? (\n <p className=\"text-xs text-status-error-text\">{fieldErrors.maxMessages}</p>\n ) : null}\n </div>\n\n {fieldErrors.channelId ? (\n <Alert status=\"warning\">\n <AlertDescription>{fieldErrors.channelId}</AlertDescription>\n </Alert>\n ) : null}\n </div>\n\n <DialogFooter>\n <span className=\"mr-auto text-xs text-muted-foreground\">\n <KbdShortcut keys={['\u2318', 'Enter']} />\n </span>\n <Button type=\"button\" variant=\"outline\" onClick={onClose} disabled={submitting}>\n {t('communication_channels.profile.importHistory.cancel', 'Cancel')}\n </Button>\n <Button type=\"button\" onClick={() => void handleSubmit()} disabled={submitting}>\n {submitting\n ? t('communication_channels.profile.importHistory.submitting', 'Queueing\u2026')\n : t('communication_channels.profile.importHistory.submit', 'Start import')}\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n )\n}\n\ntype DisconnectChannelDialogProps = {\n channel: ChannelRow | null\n onClose: () => void\n onDisconnected: () => void\n}\n\nfunction DisconnectChannelDialog({\n channel,\n onClose,\n onDisconnected,\n}: DisconnectChannelDialogProps): React.JSX.Element {\n const t = useT()\n const [submitting, setSubmitting] = React.useState(false)\n const { runMutation, retryLastMutation } = useGuardedMutation<ChannelMutationContext>({\n contextId: DISCONNECT_MUTATION_CONTEXT_ID,\n blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),\n })\n\n React.useEffect(() => {\n if (channel) setSubmitting(false)\n }, [channel?.id])\n\n const handleConfirm = React.useCallback(async () => {\n if (!channel || submitting) return\n setSubmitting(true)\n let response\n try {\n response = await runMutation({\n // optimistic-lock-exempt: self-service connect/disconnect of the\n // signed-in operator's OWN communication channel (an integration link),\n // not a shared multi-editor record. Disconnect is a terminal action\n // keyed by channel id; there is no concurrent-edit lost-update window to\n // guard, and the row carries no client-surfaced `updatedAt` round-trip.\n operation: () => apiCall(\n `/api/communication_channels/channels/${encodeURIComponent(channel.id)}`,\n { method: 'DELETE' },\n ),\n context: {\n formId: DISCONNECT_MUTATION_CONTEXT_ID,\n resourceKind: 'communication_channels.channel',\n resourceId: channel.id,\n retryLastMutation,\n },\n mutationPayload: { action: 'disconnect' },\n })\n } catch (err) {\n setSubmitting(false)\n flash(\n err instanceof Error\n ? err.message\n : t('communication_channels.profile.actions.disconnectFailed', 'Failed to disconnect channel'),\n 'error',\n )\n return\n }\n setSubmitting(false)\n if (!response.ok) {\n const body = response.result as { error?: string } | undefined\n flash(\n body?.error ??\n t('communication_channels.profile.actions.disconnectFailed', 'Failed to disconnect channel'),\n 'error',\n )\n return\n }\n flash(\n t(\n 'communication_channels.profile.actions.disconnectSuccess',\n 'Channel disconnected. You can reconnect it anytime.',\n ),\n 'success',\n )\n onDisconnected()\n }, [channel, submitting, runMutation, retryLastMutation, t, onDisconnected])\n\n const handleKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {\n event.preventDefault()\n void handleConfirm()\n }\n },\n [handleConfirm],\n )\n\n return (\n <Dialog open={channel !== null} onOpenChange={(open) => { if (!open) onClose() }}>\n <DialogContent onKeyDown={handleKeyDown}>\n <DialogHeader>\n <DialogTitle>\n {t('communication_channels.profile.disconnect.title', 'Disconnect channel')}\n </DialogTitle>\n <DialogDescription>\n {t(\n 'communication_channels.profile.disconnect.description',\n 'This removes the connection and stops syncing. Emails already imported stay on your timelines. You can reconnect anytime.',\n )}\n </DialogDescription>\n </DialogHeader>\n\n {channel ? (\n <p className=\"text-sm font-medium\">{channel.externalIdentifier ?? channel.displayName}</p>\n ) : null}\n\n <DialogFooter>\n <span className=\"mr-auto text-xs text-muted-foreground\">\n <KbdShortcut keys={['\u2318', 'Enter']} />\n </span>\n <Button type=\"button\" variant=\"outline\" onClick={onClose} disabled={submitting}>\n {t('communication_channels.profile.disconnect.cancel', 'Cancel')}\n </Button>\n <Button type=\"button\" variant=\"destructive-solid\" onClick={() => void handleConfirm()} disabled={submitting}>\n {submitting\n ? t('communication_channels.profile.disconnect.submitting', 'Disconnecting\u2026')\n : t('communication_channels.profile.disconnect.confirm', 'Disconnect')}\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n )\n}\n\nfunction statusTag(\n status: ChannelRow['status'],\n t: (key: string, fallback?: string) => string,\n): React.ReactNode {\n switch (status) {\n case 'connected':\n return (\n <Tag variant=\"success\" dot>\n {t('communication_channels.status.connected', 'Connected')}\n </Tag>\n )\n case 'requires_reauth':\n return (\n <Tag variant=\"warning\" dot>\n {t('communication_channels.status.requiresReauth', 'Needs reconnection')}\n </Tag>\n )\n case 'error':\n return (\n <Tag variant=\"error\" dot>\n {t('communication_channels.status.error', 'Error')}\n </Tag>\n )\n case 'disconnected':\n return <Tag variant=\"neutral\">{t('communication_channels.status.disconnected', 'Disconnected')}</Tag>\n default:\n return <Tag variant=\"neutral\">{status}</Tag>\n }\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport { extensionPoints } from '@open-mercato/core/modules/communication_channels/extension-points'\nimport { getImportHistoryLimits } from '@open-mercato/core/modules/communication_channels/lib/import-history-limits'\nimport type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Page, PageBody } from '@open-mercato/ui/backend/Page'\nimport { DataTable } from '@open-mercato/ui/backend/DataTable'\nimport { Tag } from '@open-mercato/ui/primitives/tag'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { Alert, AlertDescription } from '@open-mercato/ui/primitives/alert'\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from '@open-mercato/ui/primitives/dialog'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { Label } from '@open-mercato/ui/primitives/label'\nimport { Textarea } from '@open-mercato/ui/primitives/textarea'\nimport { KbdShortcut } from '@open-mercato/ui/primitives/kbd'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { ConnectChannelMenu } from './ConnectChannelMenu'\n\ntype ChannelRow = {\n id: string\n providerKey: string\n channelType: string\n displayName: string\n externalIdentifier: string | null\n isPrimary: boolean\n isActive: boolean\n status: 'connected' | 'requires_reauth' | 'error' | 'disconnected'\n lastError: string | null\n pollIntervalSeconds: number | null\n lastPolledAt: string | null\n /** Spec C \u2014 push delivery state (null when provider doesn't support push). */\n pushStatus: 'active' | 'inactive' | 'failed' | null\n lastPushError: { code: string | null; message: string | null; at: string | null } | null\n /**\n * `true` when the adapter declares real-time push, so the hub's poll worker\n * skips this channel entirely \u2014 inbound arrives over the provider connection.\n */\n supportsRealtimePush: boolean\n /** `true` when the adapter implements `registerPush` (Gmail-style subscriptions). */\n supportsPushRegistration: boolean\n createdAt: string | null\n}\n\nconst PROFILE_CHANNELS_MUTATION_CONTEXT_ID = 'communication-channels-profile'\nconst IMPORT_HISTORY_MUTATION_CONTEXT_ID = 'communication-channels-import-history'\nconst DISCONNECT_MUTATION_CONTEXT_ID = 'communication-channels-disconnect'\n\ntype ChannelMutationContext = {\n formId: string\n resourceKind: string\n resourceId: string\n retryLastMutation: () => Promise<boolean>\n}\n\nexport default function ProfileCommunicationChannelsPage() {\n const t = useT()\n const router = useRouter()\n const searchParams = useSearchParams()\n const flashType = searchParams?.get('flash')\n const flashCode = searchParams?.get('code')\n const flashProvider = searchParams?.get('provider')\n\n const [rows, setRows] = React.useState<ChannelRow[]>([])\n const [isLoading, setIsLoading] = React.useState(true)\n const [errorMessage, setErrorMessage] = React.useState<string | null>(null)\n const [reloadKey, setReloadKey] = React.useState(0)\n const [importChannel, setImportChannel] = React.useState<ChannelRow | null>(null)\n const [disconnectChannel, setDisconnectChannel] = React.useState<ChannelRow | null>(null)\n const { runMutation, retryLastMutation } = useGuardedMutation<ChannelMutationContext>({\n contextId: PROFILE_CHANNELS_MUTATION_CONTEXT_ID,\n blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),\n })\n\n React.useEffect(() => {\n if (flashType === 'connected') {\n flash(\n flashProvider\n ? t('communication_channels.profile.flash.connectedWithProvider', 'Channel connected ({provider}).', {\n provider: flashProvider,\n })\n : t('communication_channels.profile.flash.connected', 'Channel connected.'),\n 'success',\n )\n } else if (flashType === 'error') {\n flash(\n flashCode === 'oauth_client_not_configured'\n ? t(\n 'communication_channels.profile.connect.notConfigured',\n 'This provider is not configured yet. Ask an administrator to add the OAuth Client ID and Secret under Integrations before connecting a mailbox.',\n )\n : flashCode === 'mailbox_already_connected'\n ? t(\n 'communication_channels.profile.connect.mailboxAlreadyConnected',\n 'This mailbox is already connected through another provider. Disconnect it first to reconnect it with a different one.',\n )\n : flashCode\n ? t('communication_channels.profile.flash.errorWithCode', 'Failed to connect channel \u2014 {code}.', {\n code: flashCode,\n })\n : t('communication_channels.profile.flash.error', 'Failed to connect channel.'),\n 'error',\n )\n }\n }, [flashType, flashCode, flashProvider, t])\n\n React.useEffect(() => {\n let cancelled = false\n async function load() {\n setIsLoading(true)\n setErrorMessage(null)\n const response = await apiCall<{ items?: ChannelRow[] }>(\n '/api/communication_channels/me/channels',\n ).catch((err: unknown) => ({\n ok: false,\n result: { error: err instanceof Error ? err.message : 'Failed to load channels' },\n }))\n if (cancelled) return\n if (!response.ok) {\n const body = response.result as { error?: string } | undefined\n setErrorMessage(\n body?.error ?? t('communication_channels.errors.loadList', 'Failed to load channels'),\n )\n setRows([])\n } else {\n const data = (response.result ?? {}) as { items?: ChannelRow[] }\n setRows(Array.isArray(data.items) ? data.items : [])\n }\n setIsLoading(false)\n }\n void load()\n return () => {\n cancelled = true\n }\n }, [reloadKey, t])\n\n const reauthRows = rows.filter((r) => r.status === 'requires_reauth')\n\n const reloadChannels = React.useCallback(() => setReloadKey((k) => k + 1), [])\n\n const onSetPrimary = React.useCallback(\n async (channelId: string) => {\n let response\n try {\n response = await runMutation({\n operation: () => apiCall(\n `/api/communication_channels/channels/${encodeURIComponent(channelId)}/set-primary`,\n { method: 'POST' },\n ),\n context: {\n formId: PROFILE_CHANNELS_MUTATION_CONTEXT_ID,\n resourceKind: 'communication_channels.channel',\n resourceId: channelId,\n retryLastMutation,\n },\n mutationPayload: { isPrimary: true },\n })\n } catch (err) {\n flash(err instanceof Error ? err.message : t('communication_channels.profile.actions.setPrimaryFailed', 'Failed to set as primary'), 'error')\n return\n }\n if (!response.ok) {\n const body = response.result as { error?: string } | undefined\n flash(\n body?.error ?? t('communication_channels.profile.actions.setPrimaryFailed', 'Failed to set as primary'),\n 'error',\n )\n return\n }\n flash(\n t('communication_channels.profile.actions.setPrimarySuccess', 'Marked as primary.'),\n 'success',\n )\n setReloadKey((k) => k + 1)\n },\n [retryLastMutation, runMutation, t],\n )\n\n const onRegisterPush = React.useCallback(\n async (channelId: string) => {\n let response\n try {\n response = await runMutation({\n operation: () => apiCall<{ pushStatus?: string; error?: { code: string; message: string } }>(\n `/api/communication_channels/channels/${encodeURIComponent(channelId)}/push/register`,\n { method: 'POST' },\n ),\n context: {\n formId: PROFILE_CHANNELS_MUTATION_CONTEXT_ID,\n resourceKind: 'communication_channels.channel',\n resourceId: channelId,\n retryLastMutation,\n },\n mutationPayload: { action: 'push-register' },\n })\n } catch (err) {\n flash(err instanceof Error ? err.message : t('communication_channels.push.button.reregister', 'Re-register push'), 'error')\n return\n }\n if (!response.ok) {\n const body = response.result as { error?: string } | undefined\n flash(body?.error ?? t('communication_channels.push.flash.registerFailed', 'Failed to register push'), 'error')\n return\n }\n const result = (response.result ?? {}) as { pushStatus?: string }\n if (result.pushStatus === 'active') {\n flash(t('communication_channels.push.status.active', 'Push active'), 'success')\n } else {\n flash(\n t(\n 'communication_channels.push.status.failed',\n 'Push registration returned a non-active status \u2014 falling back to polling.',\n ),\n 'error',\n )\n }\n setReloadKey((k) => k + 1)\n },\n [retryLastMutation, runMutation, t],\n )\n\n const onPollNow = React.useCallback(\n async (channelId: string) => {\n let response\n try {\n response = await runMutation({\n operation: () => apiCall(\n `/api/communication_channels/channels/${encodeURIComponent(channelId)}/poll-now`,\n { method: 'POST' },\n ),\n context: {\n formId: PROFILE_CHANNELS_MUTATION_CONTEXT_ID,\n resourceKind: 'communication_channels.channel',\n resourceId: channelId,\n retryLastMutation,\n },\n mutationPayload: { action: 'poll-now' },\n })\n } catch (err) {\n flash(err instanceof Error ? err.message : t('communication_channels.profile.actions.pollNowFailed', 'Failed to trigger poll'), 'error')\n return\n }\n if (!response.ok) {\n const body = response.result as { error?: string } | undefined\n flash(\n body?.error ?? t('communication_channels.profile.actions.pollNowFailed', 'Failed to trigger poll'),\n 'error',\n )\n return\n }\n flash(\n t(\n 'communication_channels.profile.actions.pollNowSuccess',\n 'Poll triggered \u2014 new messages will appear on linked Person timelines in a few seconds.',\n ),\n 'success',\n )\n // Give the worker a moment to fetch + ingest, then refetch our channel list\n // so `lastPolledAt` updates in the UI.\n setTimeout(() => setReloadKey((k) => k + 1), 1500)\n },\n [retryLastMutation, runMutation, t],\n )\n\n const columns = React.useMemo<ColumnDef<ChannelRow>[]>(\n () => [\n {\n header: t('communication_channels.columns.displayName', 'Channel'),\n accessorKey: 'displayName',\n },\n {\n header: t('communication_channels.columns.provider', 'Provider'),\n accessorKey: 'providerKey',\n cell: ({ row }) => (\n <Tag variant=\"info\">\n {t(\n `communication_channels.channel.providers.${row.original.providerKey}`,\n row.original.providerKey,\n )}\n </Tag>\n ),\n },\n {\n header: t('communication_channels.columns.identifier', 'Email / username'),\n accessorKey: 'externalIdentifier',\n cell: ({ row }) => row.original.externalIdentifier ?? '\u2014',\n meta: { truncate: true, maxWidth: 240 },\n },\n {\n header: t('communication_channels.profile.columns.primary', 'Primary'),\n accessorKey: 'isPrimary',\n cell: ({ row }) =>\n row.original.isPrimary ? (\n <Tag variant=\"success\" dot>\n {t('communication_channels.profile.primary', 'Primary')}\n </Tag>\n ) : (\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={() => void onSetPrimary(row.original.id)}\n aria-label={t('communication_channels.profile.actions.setPrimary', 'Set as primary')}\n >\n {t('communication_channels.profile.actions.setPrimary', 'Set as primary')}\n </Button>\n ),\n },\n {\n header: t('communication_channels.columns.status', 'Status'),\n accessorKey: 'status',\n cell: ({ row }) => statusTag(row.original.status, t),\n },\n {\n id: 'pushStatus',\n // Its own key: sharing `push.status.active` made the header render\n // \"Push active\" over a column whose rows say \"Polling only\" (#4980).\n header: t('communication_channels.profile.columns.push', 'Push'),\n cell: ({ row }) => {\n const ps = row.original.pushStatus\n const errorTitle = row.original.lastPushError?.message ?? undefined\n // Derived from the adapter's declared capabilities, never from the\n // provider name: `supportsPushRegistration` means push subscriptions\n // can be (re-)registered from here, `supportsRealtimePush` means the\n // hub's poll worker skips the channel because the provider connection\n // delivers inbound itself (#4980).\n if (!row.original.supportsPushRegistration) {\n if (!row.original.supportsRealtimePush) {\n return (\n <span className=\"text-xs text-muted-foreground\">\n {t('communication_channels.push.status.inactive', 'Polling only')}\n </span>\n )\n }\n if (ps === 'failed') {\n return (\n <Tag variant=\"error\" dot title={errorTitle}>\n {t('communication_channels.push.status.pushDrivenFailed', 'Push connection failed')}\n </Tag>\n )\n }\n return (\n <Tag variant=\"success\" dot>\n {t('communication_channels.push.status.pushDriven', 'Push-driven')}\n </Tag>\n )\n }\n if (ps === 'active') {\n return (\n <Tag variant=\"success\" dot>\n {t('communication_channels.push.status.active', 'Push active')}\n </Tag>\n )\n }\n if (ps === 'failed') {\n return (\n <div className=\"flex items-center gap-2\">\n <Tag variant=\"error\" dot>\n {t('communication_channels.push.status.failed', 'Push failed \u2014 using polling')}\n </Tag>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={() => void onRegisterPush(row.original.id)}\n aria-label={t('communication_channels.push.button.reregister', 'Re-register push')}\n title={errorTitle}\n >\n {t('communication_channels.push.button.reregister', 'Re-register push')}\n </Button>\n </div>\n )\n }\n // null or 'inactive' \u2014 the provider can register push but has not yet.\n // Only a hub-polled channel falls back to polling meanwhile; for a\n // push-driven one nothing is delivering inbound at all, so claiming\n // \"Polling only\" would repeat the defect this issue is about (#4980).\n return (\n <div className=\"flex items-center gap-2\">\n <span className=\"text-xs text-muted-foreground\">\n {row.original.supportsRealtimePush\n ? t('communication_channels.push.status.notRegistered', 'Push not registered')\n : t('communication_channels.push.status.inactive', 'Polling only')}\n </span>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={() => void onRegisterPush(row.original.id)}\n aria-label={t('communication_channels.push.button.reregister', 'Re-register push')}\n >\n {t('communication_channels.push.button.reregister', 'Re-register push')}\n </Button>\n </div>\n )\n },\n },\n {\n header: t('communication_channels.profile.columns.lastPolled', 'Last synced'),\n accessorKey: 'lastPolledAt',\n cell: ({ row }) =>\n row.original.lastPolledAt\n ? new Date(row.original.lastPolledAt).toLocaleString()\n : '\u2014',\n },\n {\n id: 'importHistory',\n header: t('communication_channels.profile.columns.importHistory', 'History'),\n cell: ({ row }) => {\n const eligible =\n row.original.isActive &&\n row.original.status === 'connected' &&\n row.original.channelType === 'email'\n const label = t('communication_channels.profile.actions.importHistory', 'Import history')\n return (\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={() => setImportChannel(row.original)}\n disabled={!eligible}\n aria-label={label}\n >\n {label}\n </Button>\n )\n },\n },\n {\n id: 'pollNow',\n header: t('communication_channels.profile.columns.pollNow', 'Sync'),\n cell: ({ row }) => {\n // Allowed from 'connected' AND 'error' \u2014 the latter lets the user\n // recover a stuck channel without disconnecting + reconnecting.\n // 'requires_reauth' and 'disconnected' are owned by other flows.\n // A push-driven channel is never polled by the worker, so offering the\n // action at all would promise a sync that cannot happen (#4980).\n const pushDriven = row.original.supportsRealtimePush\n const pollable =\n row.original.isActive &&\n !pushDriven &&\n (row.original.status === 'connected' || row.original.status === 'error')\n const label =\n row.original.status === 'error'\n ? t('communication_channels.profile.actions.retryPoll', 'Retry')\n : t('communication_channels.profile.actions.pollNow', 'Poll now')\n const disabledReason = pushDriven\n ? t(\n 'communication_channels.profile.actions.pollNowPushDriven',\n 'This channel is push-driven \u2014 inbound messages arrive over the provider connection, so polling does not apply.',\n )\n : undefined\n const button = (\n <Button\n type=\"button\"\n variant={row.original.status === 'error' ? 'default' : 'outline'}\n size=\"sm\"\n onClick={() => void onPollNow(row.original.id)}\n disabled={!pollable}\n aria-label={disabledReason ? `${label} \u2014 ${disabledReason}` : label}\n >\n {label}\n </Button>\n )\n // A disabled button does not receive hover events in every browser, so\n // the explanation lives on a wrapper the pointer can still reach.\n return disabledReason ? <span title={disabledReason}>{button}</span> : button\n },\n },\n {\n id: 'disconnect',\n header: t('communication_channels.profile.columns.disconnect', 'Connection'),\n cell: ({ row }) => {\n const label = t('communication_channels.profile.actions.disconnect', 'Disconnect')\n return (\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={() => setDisconnectChannel(row.original)}\n aria-label={label}\n >\n {label}\n </Button>\n )\n },\n },\n ],\n [onSetPrimary, onPollNow, onRegisterPush, t],\n )\n\n return (\n <Page>\n <PageBody>\n <header className=\"mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4\">\n <div className=\"min-w-0\">\n <h1 className=\"text-2xl font-semibold\">\n {t('communication_channels.profile.title', 'My communication channels')}\n </h1>\n <p className=\"text-sm text-muted-foreground\">\n {t(\n 'communication_channels.profile.subtitle',\n 'Connect your communication channels so outbound messages come from your own account and inbound messages land in your unified inbox.',\n )}\n </p>\n </div>\n {/* Provider connect entry points injected by each channel-* package\n (channel-gmail, channel-imap) via UMES. They stack inside one\n dropdown so the header does not widen per installed provider. */}\n <ConnectChannelMenu onConnected={reloadChannels} />\n </header>\n\n {reauthRows.length > 0 ? (\n <Alert status=\"warning\" className=\"mb-4\">\n <AlertDescription>\n {t(\n 'communication_channels.profile.alerts.requiresReauth',\n '{count} channel(s) need reconnection. Click \"Reconnect\" on the affected channel below.',\n { count: reauthRows.length },\n )}\n </AlertDescription>\n </Alert>\n ) : null}\n\n <DataTable<ChannelRow>\n title={t('communication_channels.profile.tableTitle', 'Your channels')}\n titleHeadingLevel={2}\n extensionTableId={extensionPoints.hosts.profileChannelsTable.tableId}\n columns={columns}\n data={rows}\n isLoading={isLoading}\n error={errorMessage}\n emptyState={t(\n 'communication_channels.profile.empty',\n 'You have no connected channels yet. Add one using the menu at the top of this page.',\n )}\n />\n <ImportHistoryDialog\n channel={importChannel}\n onClose={() => setImportChannel(null)}\n onQueued={() => {\n setImportChannel(null)\n router.refresh()\n }}\n />\n <DisconnectChannelDialog\n channel={disconnectChannel}\n onClose={() => setDisconnectChannel(null)}\n onDisconnected={() => {\n setDisconnectChannel(null)\n setReloadKey((k) => k + 1)\n }}\n />\n </PageBody>\n </Page>\n )\n}\n\ntype ImportHistoryDialogProps = {\n channel: ChannelRow | null\n onClose: () => void\n onQueued: () => void\n}\n\nfunction ImportHistoryDialog({ channel, onClose, onQueued }: ImportHistoryDialogProps): React.JSX.Element {\n const t = useT()\n const importLimits = React.useMemo(() => getImportHistoryLimits(channel?.providerKey), [channel?.providerKey])\n const [sinceDays, setSinceDays] = React.useState('30')\n const [contactEmails, setContactEmails] = React.useState('')\n const [maxMessages, setMaxMessages] = React.useState('500')\n const [fieldErrors, setFieldErrors] = React.useState<Record<string, string>>({})\n const [submitting, setSubmitting] = React.useState(false)\n const { runMutation, retryLastMutation } = useGuardedMutation<ChannelMutationContext>({\n contextId: IMPORT_HISTORY_MUTATION_CONTEXT_ID,\n blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),\n })\n\n React.useEffect(() => {\n if (channel) {\n setSinceDays('30')\n setContactEmails('')\n setMaxMessages('500')\n setFieldErrors({})\n setSubmitting(false)\n }\n }, [channel?.id])\n\n const handleSubmit = React.useCallback(async () => {\n if (!channel || submitting) return\n const sinceNum = Number.parseInt(sinceDays, 10)\n const maxNum = Number.parseInt(maxMessages, 10)\n const errors: Record<string, string> = {}\n if (!Number.isFinite(sinceNum) || sinceNum < 1 || sinceNum > importLimits.maxSinceDays) {\n errors.sinceDays = t(\n 'communication_channels.profile.importHistory.errors.sinceDays',\n 'Choose a number between 1 and {max} days.',\n { max: importLimits.maxSinceDays },\n )\n }\n if (!Number.isFinite(maxNum) || maxNum < 1 || maxNum > importLimits.maxMessages) {\n errors.maxMessages = t(\n 'communication_channels.profile.importHistory.errors.maxMessages',\n 'Choose a number between 1 and {max} messages.',\n { max: importLimits.maxMessages },\n )\n }\n const parsedEmails = contactEmails\n .split(/[\\s,;]+/)\n .map((s) => s.trim())\n .filter(Boolean)\n if (parsedEmails.length > 0 && parsedEmails.some((s) => !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(s))) {\n errors.contactEmails = t(\n 'communication_channels.profile.importHistory.errors.contactEmails',\n 'One or more entries is not a valid email address.',\n )\n }\n if (Object.keys(errors).length > 0) {\n setFieldErrors(errors)\n return\n }\n setFieldErrors({})\n setSubmitting(true)\n const mutationPayload = {\n sinceDays: sinceNum,\n maxMessages: maxNum,\n ...(parsedEmails.length > 0 ? { contactEmails: parsedEmails } : {}),\n }\n let response\n try {\n response = await runMutation({\n operation: () => apiCall<{ progressJobId?: string }>(\n `/api/communication_channels/channels/${encodeURIComponent(channel.id)}/import-history`,\n {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(mutationPayload),\n },\n ),\n context: {\n formId: IMPORT_HISTORY_MUTATION_CONTEXT_ID,\n resourceKind: 'communication_channels.channel',\n resourceId: channel.id,\n retryLastMutation,\n },\n mutationPayload,\n })\n } catch (err) {\n setSubmitting(false)\n flash(\n err instanceof Error\n ? err.message\n : t('communication_channels.profile.importHistory.flash.error', 'Failed to queue history import.'),\n 'error',\n )\n return\n }\n setSubmitting(false)\n if (!response.ok) {\n const body = response.result as { error?: string; fieldErrors?: Record<string, string> } | undefined\n if (body?.fieldErrors && Object.keys(body.fieldErrors).length > 0) {\n setFieldErrors(body.fieldErrors)\n return\n }\n flash(\n body?.error ??\n t(\n 'communication_channels.profile.importHistory.flash.error',\n 'Failed to queue history import.',\n ),\n 'error',\n )\n return\n }\n flash(\n t(\n 'communication_channels.profile.importHistory.flash.success',\n 'History import queued \u2014 track progress in the top bar.',\n ),\n 'success',\n )\n onQueued()\n }, [channel, sinceDays, maxMessages, contactEmails, submitting, t, onQueued, retryLastMutation, runMutation, importLimits])\n\n const handleKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {\n event.preventDefault()\n void handleSubmit()\n }\n },\n [handleSubmit],\n )\n\n return (\n <Dialog open={channel !== null} onOpenChange={(open) => { if (!open) onClose() }}>\n <DialogContent onKeyDown={handleKeyDown}>\n <DialogHeader>\n <DialogTitle>\n {t('communication_channels.profile.importHistory.title', 'Import channel history')}\n </DialogTitle>\n <DialogDescription>\n {t(\n 'communication_channels.profile.importHistory.description',\n 'Pull older messages this channel never observed at connect-time. Filters narrow the search server-side.',\n )}\n </DialogDescription>\n </DialogHeader>\n\n <div className=\"space-y-4\">\n <div className=\"space-y-1\">\n <Label htmlFor=\"import-history-since\">\n {t('communication_channels.profile.importHistory.fields.sinceDays', 'Look back (days)')}\n </Label>\n <Input\n id=\"import-history-since\"\n type=\"number\"\n min={1}\n max={importLimits.maxSinceDays}\n value={sinceDays}\n onChange={(e) => setSinceDays(e.target.value)}\n aria-invalid={Boolean(fieldErrors.sinceDays)}\n />\n {fieldErrors.sinceDays ? (\n <p className=\"text-xs text-status-error-text\">{fieldErrors.sinceDays}</p>\n ) : null}\n </div>\n\n <div className=\"space-y-1\">\n <Label htmlFor=\"import-history-emails\">\n {t(\n 'communication_channels.profile.importHistory.fields.contactEmails',\n 'Filter by sender (optional)',\n )}\n </Label>\n <Textarea\n id=\"import-history-emails\"\n rows={3}\n value={contactEmails}\n onChange={(e) => setContactEmails(e.target.value)}\n placeholder={t(\n 'communication_channels.profile.importHistory.fields.contactEmailsPlaceholder',\n 'alice@example.com, bob@example.com',\n )}\n aria-invalid={Boolean(fieldErrors.contactEmails)}\n />\n {fieldErrors.contactEmails ? (\n <p className=\"text-xs text-status-error-text\">{fieldErrors.contactEmails}</p>\n ) : (\n <p className=\"text-xs text-muted-foreground\">\n {t(\n 'communication_channels.profile.importHistory.fields.contactEmailsHint',\n 'Leave empty to scan all senders in the window.',\n )}\n </p>\n )}\n </div>\n\n <div className=\"space-y-1\">\n <Label htmlFor=\"import-history-max\">\n {t('communication_channels.profile.importHistory.fields.maxMessages', 'Maximum messages')}\n </Label>\n <Input\n id=\"import-history-max\"\n type=\"number\"\n min={1}\n max={importLimits.maxMessages}\n value={maxMessages}\n onChange={(e) => setMaxMessages(e.target.value)}\n aria-invalid={Boolean(fieldErrors.maxMessages)}\n />\n {fieldErrors.maxMessages ? (\n <p className=\"text-xs text-status-error-text\">{fieldErrors.maxMessages}</p>\n ) : null}\n </div>\n\n {fieldErrors.channelId ? (\n <Alert status=\"warning\">\n <AlertDescription>{fieldErrors.channelId}</AlertDescription>\n </Alert>\n ) : null}\n </div>\n\n <DialogFooter>\n <span className=\"mr-auto text-xs text-muted-foreground\">\n <KbdShortcut keys={['\u2318', 'Enter']} />\n </span>\n <Button type=\"button\" variant=\"outline\" onClick={onClose} disabled={submitting}>\n {t('communication_channels.profile.importHistory.cancel', 'Cancel')}\n </Button>\n <Button type=\"button\" onClick={() => void handleSubmit()} disabled={submitting}>\n {submitting\n ? t('communication_channels.profile.importHistory.submitting', 'Queueing\u2026')\n : t('communication_channels.profile.importHistory.submit', 'Start import')}\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n )\n}\n\ntype DisconnectChannelDialogProps = {\n channel: ChannelRow | null\n onClose: () => void\n onDisconnected: () => void\n}\n\nfunction DisconnectChannelDialog({\n channel,\n onClose,\n onDisconnected,\n}: DisconnectChannelDialogProps): React.JSX.Element {\n const t = useT()\n const [submitting, setSubmitting] = React.useState(false)\n const { runMutation, retryLastMutation } = useGuardedMutation<ChannelMutationContext>({\n contextId: DISCONNECT_MUTATION_CONTEXT_ID,\n blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),\n })\n\n React.useEffect(() => {\n if (channel) setSubmitting(false)\n }, [channel?.id])\n\n const handleConfirm = React.useCallback(async () => {\n if (!channel || submitting) return\n setSubmitting(true)\n let response\n try {\n response = await runMutation({\n // optimistic-lock-exempt: self-service connect/disconnect of the\n // signed-in operator's OWN communication channel (an integration link),\n // not a shared multi-editor record. Disconnect is a terminal action\n // keyed by channel id; there is no concurrent-edit lost-update window to\n // guard, and the row carries no client-surfaced `updatedAt` round-trip.\n operation: () => apiCall(\n `/api/communication_channels/channels/${encodeURIComponent(channel.id)}`,\n { method: 'DELETE' },\n ),\n context: {\n formId: DISCONNECT_MUTATION_CONTEXT_ID,\n resourceKind: 'communication_channels.channel',\n resourceId: channel.id,\n retryLastMutation,\n },\n mutationPayload: { action: 'disconnect' },\n })\n } catch (err) {\n setSubmitting(false)\n flash(\n err instanceof Error\n ? err.message\n : t('communication_channels.profile.actions.disconnectFailed', 'Failed to disconnect channel'),\n 'error',\n )\n return\n }\n setSubmitting(false)\n if (!response.ok) {\n const body = response.result as { error?: string } | undefined\n flash(\n body?.error ??\n t('communication_channels.profile.actions.disconnectFailed', 'Failed to disconnect channel'),\n 'error',\n )\n return\n }\n flash(\n t(\n 'communication_channels.profile.actions.disconnectSuccess',\n 'Channel disconnected. You can reconnect it anytime.',\n ),\n 'success',\n )\n onDisconnected()\n }, [channel, submitting, runMutation, retryLastMutation, t, onDisconnected])\n\n const handleKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {\n event.preventDefault()\n void handleConfirm()\n }\n },\n [handleConfirm],\n )\n\n return (\n <Dialog open={channel !== null} onOpenChange={(open) => { if (!open) onClose() }}>\n <DialogContent onKeyDown={handleKeyDown}>\n <DialogHeader>\n <DialogTitle>\n {t('communication_channels.profile.disconnect.title', 'Disconnect channel')}\n </DialogTitle>\n <DialogDescription>\n {t(\n 'communication_channels.profile.disconnect.description',\n 'This removes the connection and stops syncing. Emails already imported stay on your timelines. You can reconnect anytime.',\n )}\n </DialogDescription>\n </DialogHeader>\n\n {channel ? (\n <p className=\"text-sm font-medium\">{channel.externalIdentifier ?? channel.displayName}</p>\n ) : null}\n\n <DialogFooter>\n <span className=\"mr-auto text-xs text-muted-foreground\">\n <KbdShortcut keys={['\u2318', 'Enter']} />\n </span>\n <Button type=\"button\" variant=\"outline\" onClick={onClose} disabled={submitting}>\n {t('communication_channels.profile.disconnect.cancel', 'Cancel')}\n </Button>\n <Button type=\"button\" variant=\"destructive-solid\" onClick={() => void handleConfirm()} disabled={submitting}>\n {submitting\n ? t('communication_channels.profile.disconnect.submitting', 'Disconnecting\u2026')\n : t('communication_channels.profile.disconnect.confirm', 'Disconnect')}\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n )\n}\n\nfunction statusTag(\n status: ChannelRow['status'],\n t: (key: string, fallback?: string) => string,\n): React.ReactNode {\n switch (status) {\n case 'connected':\n return (\n <Tag variant=\"success\" dot>\n {t('communication_channels.status.connected', 'Connected')}\n </Tag>\n )\n case 'requires_reauth':\n return (\n <Tag variant=\"warning\" dot>\n {t('communication_channels.status.requiresReauth', 'Needs reconnection')}\n </Tag>\n )\n case 'error':\n return (\n <Tag variant=\"error\" dot>\n {t('communication_channels.status.error', 'Error')}\n </Tag>\n )\n case 'disconnected':\n return <Tag variant=\"neutral\">{t('communication_channels.status.disconnected', 'Disconnected')}</Tag>\n default:\n return <Tag variant=\"neutral\">{status}</Tag>\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -40,6 +40,7 @@ const connectCredentialChannelCommand = {
|
|
|
40
40
|
return { status: "wrong_scope_for_route", providerKey: input.providerKey };
|
|
41
41
|
}
|
|
42
42
|
const effectiveUserId = tenantScoped ? null : input.userId;
|
|
43
|
+
let adapterIdentifier = null;
|
|
43
44
|
if (typeof adapter.validateCredentials === "function") {
|
|
44
45
|
const validation = await adapter.validateCredentials({
|
|
45
46
|
providerKey: input.providerKey,
|
|
@@ -56,6 +57,9 @@ const connectCredentialChannelCommand = {
|
|
|
56
57
|
errorCodes: validation.errorCodes
|
|
57
58
|
};
|
|
58
59
|
}
|
|
60
|
+
if (typeof validation.externalIdentifier === "string" && validation.externalIdentifier.length > 0) {
|
|
61
|
+
adapterIdentifier = validation.externalIdentifier;
|
|
62
|
+
}
|
|
59
63
|
}
|
|
60
64
|
let credentialsService = null;
|
|
61
65
|
try {
|
|
@@ -105,7 +109,7 @@ const connectCredentialChannelCommand = {
|
|
|
105
109
|
}
|
|
106
110
|
}
|
|
107
111
|
const credBag = input.credentials;
|
|
108
|
-
const rawIdentifier = (typeof credBag.username === "string" && credBag.username.length > 0 ? credBag.username : null) ?? (typeof credBag.email === "string" && credBag.email.length > 0 ? credBag.email : null) ?? (typeof credBag.fromAddress === "string" && credBag.fromAddress.length > 0 ? credBag.fromAddress : null);
|
|
112
|
+
const rawIdentifier = adapterIdentifier ?? (typeof credBag.username === "string" && credBag.username.length > 0 ? credBag.username : null) ?? (typeof credBag.email === "string" && credBag.email.length > 0 ? credBag.email : null) ?? (typeof credBag.fromAddress === "string" && credBag.fromAddress.length > 0 ? credBag.fromAddress : null);
|
|
109
113
|
const externalIdentifier = rawIdentifier && rawIdentifier.includes("@") ? rawIdentifier.toLowerCase() : rawIdentifier;
|
|
110
114
|
const credentialsAvailable = credentialsRefId !== null;
|
|
111
115
|
let channel;
|
|
@@ -125,7 +129,8 @@ const connectCredentialChannelCommand = {
|
|
|
125
129
|
tenantId: input.scope.tenantId,
|
|
126
130
|
organizationId: tenantScoped ? null : input.scope.organizationId ?? null
|
|
127
131
|
},
|
|
128
|
-
pollIntervalSeconds: input.pollIntervalSeconds
|
|
132
|
+
pollIntervalSeconds: input.pollIntervalSeconds,
|
|
133
|
+
adoptUnidentifiedChannel: adapterIdentifier !== null
|
|
129
134
|
});
|
|
130
135
|
} catch (err) {
|
|
131
136
|
if (err instanceof MailboxAlreadyConnectedError) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/communication_channels/commands/connect-credential-channel.ts"],
|
|
4
|
-
"sourcesContent": ["import { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandHandler } from '@open-mercato/shared/lib/commands'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport type { ChannelAdapterRegistry } from '../lib/registry'\nimport { createConnectedChannelRow, MailboxAlreadyConnectedError } from '../lib/connect-channel'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('communication_channels').child({ component: 'connect-credential-channel' })\n\nconst connectCredentialChannelSchema = z.object({\n providerKey: z.string().min(1).max(64),\n displayName: z.string().min(1).max(255),\n /** Provider-specific credential fields \u2014 opaque to the hub; the adapter validates. */\n credentials: z.record(z.string(), z.unknown()),\n /** Optional polling interval (seconds) \u2014 set when adapter is polling-based. */\n pollIntervalSeconds: z.number().int().positive().max(86_400).optional(),\n /**\n * Connecting user. `null` for tenant-wide (admin-connected) channels whose\n * provider declares `channelScope: 'tenant'` (push: FCM/APNs/Expo) \u2014 the\n * resulting channel + credentials are stored with `user_id = NULL`.\n */\n userId: z.string().uuid().nullable(),\n scope: z.object({\n tenantId: z.string().uuid(),\n organizationId: z.string().uuid().nullable(),\n }),\n})\n\nexport type ConnectCredentialChannelInput = z.infer<typeof connectCredentialChannelSchema>\n\nexport type ConnectCredentialChannelResult =\n | { status: 'connected'; channelId: string; externalIdentifier: string | null }\n | { status: 'validation_failed'; errors: Record<string, string>; errorCodes?: Record<string, string> }\n | { status: 'no_adapter'; reason: string }\n | { status: 'duplicate_mailbox'; externalIdentifier: string; existingProviderKey: string }\n | { status: 'wrong_scope_for_route'; providerKey: string }\n\nexport const COMMUNICATION_CHANNELS_CONNECT_CREDENTIAL_CHANNEL_COMMAND_ID =\n 'communication_channels.channel.connect_credential'\n\ntype CredentialsServiceLike = {\n save?: (\n integrationId: string,\n credentials: Record<string, unknown>,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<string | void>\n}\n\n/**\n * Connect a credential-based channel (IMAP, push providers, and future basic-auth\n * providers).\n *\n * Flow:\n * 1. Resolve the adapter for `providerKey`.\n * 2. Decide scope: `adapter.channelScope === 'tenant'` \u2192 the channel + credentials\n * are stored with `user_id = NULL` (one shared channel per tenant, e.g. push\n * FCM/APNs/Expo); otherwise they are stored under the connecting `userId`.\n * 3. Call `adapter.validateCredentials?` \u2014 adapters that don't implement it\n * are accepted optimistically (the hub trusts the adapter to fail on first\n * use). Adapters with the method return field-level errors via Zod-like\n * `{ ok: false, errors: { fieldName: 'message' } }`; we forward those to\n * the caller for `createCrudFormError`.\n * 4. Persist the credentials encrypted via `integrationCredentialsService.save?` if available.\n * 5. Create the `CommunicationChannel` row with the effective `userId`,\n * `status = 'connected'`, and the adapter's declared capabilities snapshot.\n *\n * The per-user route binds `userId` from the authenticated session; the tenant\n * route passes `userId: null`. Either way the command re-derives the effective\n * scope from the adapter, so a tenant provider is never stamped per-user.\n */\nconst connectCredentialChannelCommand: CommandHandler<\n ConnectCredentialChannelInput,\n ConnectCredentialChannelResult\n> = {\n id: COMMUNICATION_CHANNELS_CONNECT_CREDENTIAL_CHANNEL_COMMAND_ID,\n async execute(rawInput, ctx) {\n const input = connectCredentialChannelSchema.parse(rawInput) as ConnectCredentialChannelInput\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const adapterRegistry = ctx.container.resolve('channelAdapterRegistry') as ChannelAdapterRegistry\n const adapter = adapterRegistry.get(input.providerKey)\n if (!adapter) {\n return {\n status: 'no_adapter',\n reason: `No adapter registered for provider '${input.providerKey}'`,\n }\n }\n\n // Tenant-wide providers (push: FCM/APNs/Expo) store one shared channel +\n // credential row per tenant with `user_id = NULL`; per-user providers\n // (Gmail/IMAP) keep the connecting user's id.\n //\n // SECURITY: a tenant-scoped provider must only be connected through the\n // admin-gated tenant route (which passes `userId: null`). If it arrives with\n // a non-null `userId` it came from the per-user route (feature\n // `connect_user_channel`, granted to manager/employee) \u2014 refuse rather than\n // silently minting a privileged tenant-wide channel that bypasses the\n // admin-only `connect_tenant_channel` gate. The tenant route passes\n // `userId: null`, so legitimate tenant connects still pass.\n const tenantScoped = adapter.channelScope === 'tenant'\n if (tenantScoped && input.userId !== null) {\n return { status: 'wrong_scope_for_route', providerKey: input.providerKey }\n }\n const effectiveUserId = tenantScoped ? null : input.userId\n\n // Optional credential validation.\n if (typeof adapter.validateCredentials === 'function') {\n const validation = await adapter.validateCredentials({\n providerKey: input.providerKey,\n credentials: input.credentials,\n scope: {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? input.scope.tenantId,\n },\n })\n if (!validation.ok) {\n return {\n status: 'validation_failed',\n errors: validation.errors ?? { _form: 'Credential validation failed' },\n errorCodes: validation.errorCodes,\n }\n }\n }\n\n // Persist credentials best-effort.\n let credentialsService: CredentialsServiceLike | null = null\n try {\n credentialsService = ctx.container.resolve('integrationCredentialsService') as CredentialsServiceLike\n } catch {\n credentialsService = null\n }\n // Arg order MUST be (integrationId, credentials, scope) \u2014 matches the real\n // CredentialsService signature. The legacy reversed order swapped the scope\n // object into the credentials field; see review C1 (2026-05-26).\n //\n // `scope.userId` is set so the credentials service writes a per-user row.\n // Without this, two users on the same tenant share one credentials row\n // (see review R2-C1 / N1, 2026-05-26). Tenant-wide providers pass `null`\n // here so the service writes/reads the shared `user_id = NULL` row (same\n // pattern as Stripe/Akeneo tenant-wide credentials).\n //\n // `organizationId` for tenant scope is pinned to `tenantId` (not the\n // connecting admin's selected org). The channel dedup/heal is org-agnostic\n // (index + heal key ignore org) and the channel row is stored with\n // `organization_id = NULL`, so push-delivery resolves credentials at\n // `channel.organizationId ?? tenantId` = `tenantId`. Pinning the write to the\n // same key keeps a cross-org reconnect (e.g. key rotation from another org)\n // overwriting the one credential row the delivery path actually reads, rather\n // than orphaning it under a per-org key.\n const credentialsScope = {\n tenantId: input.scope.tenantId,\n organizationId: tenantScoped\n ? input.scope.tenantId\n : input.scope.organizationId ?? input.scope.tenantId,\n userId: effectiveUserId,\n }\n let credentialsRefId: string | null = null\n let credentialsPersisted = false\n if (credentialsService?.save) {\n try {\n await credentialsService.save(\n `channel_${input.providerKey}`,\n { ...input.credentials, ...(effectiveUserId ? { userId: effectiveUserId } : {}) },\n credentialsScope,\n )\n credentialsPersisted = true\n } catch (err) {\n logger.warn('credentials persist failed for provider', { providerKey: input.providerKey, err })\n }\n // Resolve the saved row id so we can link channel.credentialsRef. Best-effort.\n if (credentialsPersisted) {\n try {\n const { IntegrationCredentials } = await import(\n '@open-mercato/core/modules/integrations/data/entities'\n )\n const { findOneWithDecryption } = await import(\n '@open-mercato/shared/lib/encryption/find'\n )\n const row = await findOneWithDecryption(\n em,\n IntegrationCredentials,\n {\n integrationId: `channel_${input.providerKey}`,\n tenantId: credentialsScope.tenantId,\n organizationId: credentialsScope.organizationId,\n userId: credentialsScope.userId,\n deletedAt: null,\n },\n undefined,\n credentialsScope,\n )\n credentialsRefId = (row as { id?: string } | null)?.id ?? null\n } catch {\n credentialsRefId = null\n }\n }\n }\n\n // Mailbox identity for this channel: the email address. IMAP/SMTP credentials\n // carry it as `fromAddress`; other credential providers may use `username` or\n // `email`. Normalize emails to lowercase so the cross-provider duplicate guard\n // and the per-(tenant,user,provider,mailbox) heal index match canonically.\n const credBag = input.credentials as Record<string, unknown>\n const rawIdentifier =\n (typeof credBag.username === 'string' && credBag.username.length > 0 ? credBag.username : null) ??\n (typeof credBag.email === 'string' && credBag.email.length > 0 ? credBag.email : null) ??\n (typeof credBag.fromAddress === 'string' && credBag.fromAddress.length > 0\n ? credBag.fromAddress\n : null)\n const externalIdentifier =\n rawIdentifier && rawIdentifier.includes('@') ? rawIdentifier.toLowerCase() : rawIdentifier\n\n // Fail-safe: if credentials persistence failed (no `credentialsRef`), the\n // channel is created in `requires_reauth` + isActive=false so workers\n // don't poll a channel that has no usable credentials. The user can\n // reconnect to recover (see review R2-H4 / F6, 2026-05-26).\n const credentialsAvailable = credentialsRefId !== null\n let channel\n try {\n channel = await createConnectedChannelRow({\n em,\n adapter,\n providerKey: input.providerKey,\n displayName: input.displayName,\n externalIdentifier,\n credentialsRefId,\n userId: effectiveUserId,\n // Tenant-wide channels store `organization_id = NULL` (truly tenant-scoped,\n // matching the entity's `user_id NULL` semantics) so the org-agnostic heal\n // key stays stable across orgs and delivery reads creds at `?? tenantId`.\n scope: {\n tenantId: input.scope.tenantId,\n organizationId: tenantScoped ? null : input.scope.organizationId ?? null,\n },\n pollIntervalSeconds: input.pollIntervalSeconds,\n })\n } catch (err) {\n // Same mailbox already connected via another provider \u2014 reject so we don't\n // create a second channel that double-ingests every message.\n if (err instanceof MailboxAlreadyConnectedError) {\n return {\n status: 'duplicate_mailbox',\n externalIdentifier: err.externalIdentifier,\n existingProviderKey: err.existingProviderKey,\n }\n }\n throw err\n }\n\n // Spec C \u00A7 Phase C5 \u2014 best-effort push registration for providers that\n // support it (Gmail). Failures persist as `pushStatus='failed'`\n // on the channel state but do NOT fail the connect \u2014 polling fallback\n // covers the channel until the operator clicks \"Re-register push\".\n // Imported lazily to avoid a circular module load (push-register reads\n // the channel adapter registry, which is initialised after this module).\n if (credentialsAvailable && input.providerKey === 'gmail' && input.userId) {\n const adapterSupportsPush =\n typeof adapter.registerPush === 'function' && typeof adapter.unregisterPush === 'function'\n const organizationId = input.scope.organizationId\n if (adapterSupportsPush && organizationId) {\n try {\n const { pushRegister } = await import('./push-register')\n await pushRegister({\n container: ctx.container,\n scope: {\n tenantId: input.scope.tenantId,\n organizationId,\n userId: input.userId,\n },\n input: { channelId: channel.id },\n })\n } catch (err) {\n // Never fail the connect on push errors \u2014 operator can manually\n // re-register via the dedicated route. Log + continue.\n logger.warn('best-effort pushRegister failed for channel', { channelId: channel.id, err })\n }\n }\n }\n\n return { status: 'connected', channelId: channel.id, externalIdentifier }\n },\n}\n\nregisterCommand(connectCredentialChannelCommand)\n\nexport default connectCredentialChannelCommand\n"],
|
|
5
|
-
"mappings": "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
|
|
4
|
+
"sourcesContent": ["import { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandHandler } from '@open-mercato/shared/lib/commands'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport type { ChannelAdapterRegistry } from '../lib/registry'\nimport { createConnectedChannelRow, MailboxAlreadyConnectedError } from '../lib/connect-channel'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('communication_channels').child({ component: 'connect-credential-channel' })\n\nconst connectCredentialChannelSchema = z.object({\n providerKey: z.string().min(1).max(64),\n displayName: z.string().min(1).max(255),\n /** Provider-specific credential fields \u2014 opaque to the hub; the adapter validates. */\n credentials: z.record(z.string(), z.unknown()),\n /** Optional polling interval (seconds) \u2014 set when adapter is polling-based. */\n pollIntervalSeconds: z.number().int().positive().max(86_400).optional(),\n /**\n * Connecting user. `null` for tenant-wide (admin-connected) channels whose\n * provider declares `channelScope: 'tenant'` (push: FCM/APNs/Expo) \u2014 the\n * resulting channel + credentials are stored with `user_id = NULL`.\n */\n userId: z.string().uuid().nullable(),\n scope: z.object({\n tenantId: z.string().uuid(),\n organizationId: z.string().uuid().nullable(),\n }),\n})\n\nexport type ConnectCredentialChannelInput = z.infer<typeof connectCredentialChannelSchema>\n\nexport type ConnectCredentialChannelResult =\n | { status: 'connected'; channelId: string; externalIdentifier: string | null }\n | { status: 'validation_failed'; errors: Record<string, string>; errorCodes?: Record<string, string> }\n | { status: 'no_adapter'; reason: string }\n | { status: 'duplicate_mailbox'; externalIdentifier: string; existingProviderKey: string }\n | { status: 'wrong_scope_for_route'; providerKey: string }\n\nexport const COMMUNICATION_CHANNELS_CONNECT_CREDENTIAL_CHANNEL_COMMAND_ID =\n 'communication_channels.channel.connect_credential'\n\ntype CredentialsServiceLike = {\n save?: (\n integrationId: string,\n credentials: Record<string, unknown>,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<string | void>\n}\n\n/**\n * Connect a credential-based channel (IMAP, push providers, and future basic-auth\n * providers).\n *\n * Flow:\n * 1. Resolve the adapter for `providerKey`.\n * 2. Decide scope: `adapter.channelScope === 'tenant'` \u2192 the channel + credentials\n * are stored with `user_id = NULL` (one shared channel per tenant, e.g. push\n * FCM/APNs/Expo); otherwise they are stored under the connecting `userId`.\n * 3. Call `adapter.validateCredentials?` \u2014 adapters that don't implement it\n * are accepted optimistically (the hub trusts the adapter to fail on first\n * use). Adapters with the method return field-level errors via Zod-like\n * `{ ok: false, errors: { fieldName: 'message' } }`; we forward those to\n * the caller for `createCrudFormError`.\n * 4. Persist the credentials encrypted via `integrationCredentialsService.save?` if available.\n * 5. Create the `CommunicationChannel` row with the effective `userId`,\n * `status = 'connected'`, and the adapter's declared capabilities snapshot.\n *\n * The per-user route binds `userId` from the authenticated session; the tenant\n * route passes `userId: null`. Either way the command re-derives the effective\n * scope from the adapter, so a tenant provider is never stamped per-user.\n */\nconst connectCredentialChannelCommand: CommandHandler<\n ConnectCredentialChannelInput,\n ConnectCredentialChannelResult\n> = {\n id: COMMUNICATION_CHANNELS_CONNECT_CREDENTIAL_CHANNEL_COMMAND_ID,\n async execute(rawInput, ctx) {\n const input = connectCredentialChannelSchema.parse(rawInput) as ConnectCredentialChannelInput\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const adapterRegistry = ctx.container.resolve('channelAdapterRegistry') as ChannelAdapterRegistry\n const adapter = adapterRegistry.get(input.providerKey)\n if (!adapter) {\n return {\n status: 'no_adapter',\n reason: `No adapter registered for provider '${input.providerKey}'`,\n }\n }\n\n // Tenant-wide providers (push: FCM/APNs/Expo) store one shared channel +\n // credential row per tenant with `user_id = NULL`; per-user providers\n // (Gmail/IMAP) keep the connecting user's id.\n //\n // SECURITY: a tenant-scoped provider must only be connected through the\n // admin-gated tenant route (which passes `userId: null`). If it arrives with\n // a non-null `userId` it came from the per-user route (feature\n // `connect_user_channel`, granted to manager/employee) \u2014 refuse rather than\n // silently minting a privileged tenant-wide channel that bypasses the\n // admin-only `connect_tenant_channel` gate. The tenant route passes\n // `userId: null`, so legitimate tenant connects still pass.\n const tenantScoped = adapter.channelScope === 'tenant'\n if (tenantScoped && input.userId !== null) {\n return { status: 'wrong_scope_for_route', providerKey: input.providerKey }\n }\n const effectiveUserId = tenantScoped ? null : input.userId\n\n // Optional credential validation.\n let adapterIdentifier: string | null = null\n if (typeof adapter.validateCredentials === 'function') {\n const validation = await adapter.validateCredentials({\n providerKey: input.providerKey,\n credentials: input.credentials,\n scope: {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? input.scope.tenantId,\n },\n })\n if (!validation.ok) {\n return {\n status: 'validation_failed',\n errors: validation.errors ?? { _form: 'Credential validation failed' },\n errorCodes: validation.errorCodes,\n }\n }\n if (typeof validation.externalIdentifier === 'string' && validation.externalIdentifier.length > 0) {\n adapterIdentifier = validation.externalIdentifier\n }\n }\n\n // Persist credentials best-effort.\n let credentialsService: CredentialsServiceLike | null = null\n try {\n credentialsService = ctx.container.resolve('integrationCredentialsService') as CredentialsServiceLike\n } catch {\n credentialsService = null\n }\n // Arg order MUST be (integrationId, credentials, scope) \u2014 matches the real\n // CredentialsService signature. The legacy reversed order swapped the scope\n // object into the credentials field; see review C1 (2026-05-26).\n //\n // `scope.userId` is set so the credentials service writes a per-user row.\n // Without this, two users on the same tenant share one credentials row\n // (see review R2-C1 / N1, 2026-05-26). Tenant-wide providers pass `null`\n // here so the service writes/reads the shared `user_id = NULL` row (same\n // pattern as Stripe/Akeneo tenant-wide credentials).\n //\n // `organizationId` for tenant scope is pinned to `tenantId` (not the\n // connecting admin's selected org). The channel dedup/heal is org-agnostic\n // (index + heal key ignore org) and the channel row is stored with\n // `organization_id = NULL`, so push-delivery resolves credentials at\n // `channel.organizationId ?? tenantId` = `tenantId`. Pinning the write to the\n // same key keeps a cross-org reconnect (e.g. key rotation from another org)\n // overwriting the one credential row the delivery path actually reads, rather\n // than orphaning it under a per-org key.\n const credentialsScope = {\n tenantId: input.scope.tenantId,\n organizationId: tenantScoped\n ? input.scope.tenantId\n : input.scope.organizationId ?? input.scope.tenantId,\n userId: effectiveUserId,\n }\n let credentialsRefId: string | null = null\n let credentialsPersisted = false\n if (credentialsService?.save) {\n try {\n await credentialsService.save(\n `channel_${input.providerKey}`,\n { ...input.credentials, ...(effectiveUserId ? { userId: effectiveUserId } : {}) },\n credentialsScope,\n )\n credentialsPersisted = true\n } catch (err) {\n logger.warn('credentials persist failed for provider', { providerKey: input.providerKey, err })\n }\n // Resolve the saved row id so we can link channel.credentialsRef. Best-effort.\n if (credentialsPersisted) {\n try {\n const { IntegrationCredentials } = await import(\n '@open-mercato/core/modules/integrations/data/entities'\n )\n const { findOneWithDecryption } = await import(\n '@open-mercato/shared/lib/encryption/find'\n )\n const row = await findOneWithDecryption(\n em,\n IntegrationCredentials,\n {\n integrationId: `channel_${input.providerKey}`,\n tenantId: credentialsScope.tenantId,\n organizationId: credentialsScope.organizationId,\n userId: credentialsScope.userId,\n deletedAt: null,\n },\n undefined,\n credentialsScope,\n )\n credentialsRefId = (row as { id?: string } | null)?.id ?? null\n } catch {\n credentialsRefId = null\n }\n }\n }\n\n // Mailbox identity for this channel: the email address. IMAP/SMTP credentials\n // carry it as `fromAddress`; other credential providers may use `username` or\n // `email`. Normalize emails to lowercase so the cross-provider duplicate guard\n // and the per-(tenant,user,provider,mailbox) heal index match canonically.\n // An identity the adapter reported during validation wins: providers such as\n // Discord have no email-shaped key, and without it every reconnect inserted a\n // duplicate row instead of healing the existing one (#4977).\n const credBag = input.credentials as Record<string, unknown>\n const rawIdentifier =\n adapterIdentifier ??\n (typeof credBag.username === 'string' && credBag.username.length > 0 ? credBag.username : null) ??\n (typeof credBag.email === 'string' && credBag.email.length > 0 ? credBag.email : null) ??\n (typeof credBag.fromAddress === 'string' && credBag.fromAddress.length > 0\n ? credBag.fromAddress\n : null)\n const externalIdentifier =\n rawIdentifier && rawIdentifier.includes('@') ? rawIdentifier.toLowerCase() : rawIdentifier\n\n // Fail-safe: if credentials persistence failed (no `credentialsRef`), the\n // channel is created in `requires_reauth` + isActive=false so workers\n // don't poll a channel that has no usable credentials. The user can\n // reconnect to recover (see review R2-H4 / F6, 2026-05-26).\n const credentialsAvailable = credentialsRefId !== null\n let channel\n try {\n channel = await createConnectedChannelRow({\n em,\n adapter,\n providerKey: input.providerKey,\n displayName: input.displayName,\n externalIdentifier,\n credentialsRefId,\n userId: effectiveUserId,\n // Tenant-wide channels store `organization_id = NULL` (truly tenant-scoped,\n // matching the entity's `user_id NULL` semantics) so the org-agnostic heal\n // key stays stable across orgs and delivery reads creds at `?? tenantId`.\n scope: {\n tenantId: input.scope.tenantId,\n organizationId: tenantScoped ? null : input.scope.organizationId ?? null,\n },\n pollIntervalSeconds: input.pollIntervalSeconds,\n adoptUnidentifiedChannel: adapterIdentifier !== null,\n })\n } catch (err) {\n // Same mailbox already connected via another provider \u2014 reject so we don't\n // create a second channel that double-ingests every message.\n if (err instanceof MailboxAlreadyConnectedError) {\n return {\n status: 'duplicate_mailbox',\n externalIdentifier: err.externalIdentifier,\n existingProviderKey: err.existingProviderKey,\n }\n }\n throw err\n }\n\n // Spec C \u00A7 Phase C5 \u2014 best-effort push registration for providers that\n // support it (Gmail). Failures persist as `pushStatus='failed'`\n // on the channel state but do NOT fail the connect \u2014 polling fallback\n // covers the channel until the operator clicks \"Re-register push\".\n // Imported lazily to avoid a circular module load (push-register reads\n // the channel adapter registry, which is initialised after this module).\n if (credentialsAvailable && input.providerKey === 'gmail' && input.userId) {\n const adapterSupportsPush =\n typeof adapter.registerPush === 'function' && typeof adapter.unregisterPush === 'function'\n const organizationId = input.scope.organizationId\n if (adapterSupportsPush && organizationId) {\n try {\n const { pushRegister } = await import('./push-register')\n await pushRegister({\n container: ctx.container,\n scope: {\n tenantId: input.scope.tenantId,\n organizationId,\n userId: input.userId,\n },\n input: { channelId: channel.id },\n })\n } catch (err) {\n // Never fail the connect on push errors \u2014 operator can manually\n // re-register via the dedicated route. Log + continue.\n logger.warn('best-effort pushRegister failed for channel', { channelId: channel.id, err })\n }\n }\n }\n\n return { status: 'connected', channelId: channel.id, externalIdentifier }\n },\n}\n\nregisterCommand(connectCredentialChannelCommand)\n\nexport default connectCredentialChannelCommand\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,SAAS;AAGlB,SAAS,uBAAuB;AAEhC,SAAS,2BAA2B,oCAAoC;AACxE,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,wBAAwB,EAAE,MAAM,EAAE,WAAW,6BAA6B,CAAC;AAEvG,MAAM,iCAAiC,EAAE,OAAO;AAAA,EAC9C,aAAa,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,EAAE;AAAA,EACrC,aAAa,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG;AAAA;AAAA,EAEtC,aAAa,EAAE,OAAO,EAAE,OAAO,GAAG,EAAE,QAAQ,CAAC;AAAA;AAAA,EAE7C,qBAAqB,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,KAAM,EAAE,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMtE,QAAQ,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS;AAAA,EACnC,OAAO,EAAE,OAAO;AAAA,IACd,UAAU,EAAE,OAAO,EAAE,KAAK;AAAA,IAC1B,gBAAgB,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS;AAAA,EAC7C,CAAC;AACH,CAAC;AAWM,MAAM,+DACX;AAgCF,MAAM,kCAGF;AAAA,EACF,IAAI;AAAA,EACJ,MAAM,QAAQ,UAAU,KAAK;AAC3B,UAAM,QAAQ,+BAA+B,MAAM,QAAQ;AAC3D,UAAM,KAAM,IAAI,UAAU,QAAQ,IAAI,EAAoB,KAAK;AAC/D,UAAM,kBAAkB,IAAI,UAAU,QAAQ,wBAAwB;AACtE,UAAM,UAAU,gBAAgB,IAAI,MAAM,WAAW;AACrD,QAAI,CAAC,SAAS;AACZ,aAAO;AAAA,QACL,QAAQ;AAAA,QACR,QAAQ,uCAAuC,MAAM,WAAW;AAAA,MAClE;AAAA,IACF;AAaA,UAAM,eAAe,QAAQ,iBAAiB;AAC9C,QAAI,gBAAgB,MAAM,WAAW,MAAM;AACzC,aAAO,EAAE,QAAQ,yBAAyB,aAAa,MAAM,YAAY;AAAA,IAC3E;AACA,UAAM,kBAAkB,eAAe,OAAO,MAAM;AAGpD,QAAI,oBAAmC;AACvC,QAAI,OAAO,QAAQ,wBAAwB,YAAY;AACrD,YAAM,aAAa,MAAM,QAAQ,oBAAoB;AAAA,QACnD,aAAa,MAAM;AAAA,QACnB,aAAa,MAAM;AAAA,QACnB,OAAO;AAAA,UACL,UAAU,MAAM,MAAM;AAAA,UACtB,gBAAgB,MAAM,MAAM,kBAAkB,MAAM,MAAM;AAAA,QAC5D;AAAA,MACF,CAAC;AACD,UAAI,CAAC,WAAW,IAAI;AAClB,eAAO;AAAA,UACL,QAAQ;AAAA,UACR,QAAQ,WAAW,UAAU,EAAE,OAAO,+BAA+B;AAAA,UACrE,YAAY,WAAW;AAAA,QACzB;AAAA,MACF;AACA,UAAI,OAAO,WAAW,uBAAuB,YAAY,WAAW,mBAAmB,SAAS,GAAG;AACjG,4BAAoB,WAAW;AAAA,MACjC;AAAA,IACF;AAGA,QAAI,qBAAoD;AACxD,QAAI;AACF,2BAAqB,IAAI,UAAU,QAAQ,+BAA+B;AAAA,IAC5E,QAAQ;AACN,2BAAqB;AAAA,IACvB;AAmBA,UAAM,mBAAmB;AAAA,MACvB,UAAU,MAAM,MAAM;AAAA,MACtB,gBAAgB,eACZ,MAAM,MAAM,WACZ,MAAM,MAAM,kBAAkB,MAAM,MAAM;AAAA,MAC9C,QAAQ;AAAA,IACV;AACA,QAAI,mBAAkC;AACtC,QAAI,uBAAuB;AAC3B,QAAI,oBAAoB,MAAM;AAC5B,UAAI;AACF,cAAM,mBAAmB;AAAA,UACvB,WAAW,MAAM,WAAW;AAAA,UAC5B,EAAE,GAAG,MAAM,aAAa,GAAI,kBAAkB,EAAE,QAAQ,gBAAgB,IAAI,CAAC,EAAG;AAAA,UAChF;AAAA,QACF;AACA,+BAAuB;AAAA,MACzB,SAAS,KAAK;AACZ,eAAO,KAAK,2CAA2C,EAAE,aAAa,MAAM,aAAa,IAAI,CAAC;AAAA,MAChG;AAEA,UAAI,sBAAsB;AACxB,YAAI;AACF,gBAAM,EAAE,uBAAuB,IAAI,MAAM,OACvC,uDACF;AACA,gBAAM,EAAE,sBAAsB,IAAI,MAAM,OACtC,0CACF;AACA,gBAAM,MAAM,MAAM;AAAA,YAChB;AAAA,YACA;AAAA,YACA;AAAA,cACE,eAAe,WAAW,MAAM,WAAW;AAAA,cAC3C,UAAU,iBAAiB;AAAA,cAC3B,gBAAgB,iBAAiB;AAAA,cACjC,QAAQ,iBAAiB;AAAA,cACzB,WAAW;AAAA,YACb;AAAA,YACA;AAAA,YACA;AAAA,UACF;AACA,6BAAoB,KAAgC,MAAM;AAAA,QAC5D,QAAQ;AACN,6BAAmB;AAAA,QACrB;AAAA,MACF;AAAA,IACF;AASA,UAAM,UAAU,MAAM;AACtB,UAAM,gBACJ,sBACC,OAAO,QAAQ,aAAa,YAAY,QAAQ,SAAS,SAAS,IAAI,QAAQ,WAAW,UACzF,OAAO,QAAQ,UAAU,YAAY,QAAQ,MAAM,SAAS,IAAI,QAAQ,QAAQ,UAChF,OAAO,QAAQ,gBAAgB,YAAY,QAAQ,YAAY,SAAS,IACrE,QAAQ,cACR;AACN,UAAM,qBACJ,iBAAiB,cAAc,SAAS,GAAG,IAAI,cAAc,YAAY,IAAI;AAM/E,UAAM,uBAAuB,qBAAqB;AAClD,QAAI;AACJ,QAAI;AACF,gBAAU,MAAM,0BAA0B;AAAA,QACxC;AAAA,QACA;AAAA,QACA,aAAa,MAAM;AAAA,QACnB,aAAa,MAAM;AAAA,QACnB;AAAA,QACA;AAAA,QACA,QAAQ;AAAA;AAAA;AAAA;AAAA,QAIR,OAAO;AAAA,UACL,UAAU,MAAM,MAAM;AAAA,UACtB,gBAAgB,eAAe,OAAO,MAAM,MAAM,kBAAkB;AAAA,QACtE;AAAA,QACA,qBAAqB,MAAM;AAAA,QAC3B,0BAA0B,sBAAsB;AAAA,MAClD,CAAC;AAAA,IACH,SAAS,KAAK;AAGZ,UAAI,eAAe,8BAA8B;AAC/C,eAAO;AAAA,UACL,QAAQ;AAAA,UACR,oBAAoB,IAAI;AAAA,UACxB,qBAAqB,IAAI;AAAA,QAC3B;AAAA,MACF;AACA,YAAM;AAAA,IACR;AAQA,QAAI,wBAAwB,MAAM,gBAAgB,WAAW,MAAM,QAAQ;AACzE,YAAM,sBACJ,OAAO,QAAQ,iBAAiB,cAAc,OAAO,QAAQ,mBAAmB;AAClF,YAAM,iBAAiB,MAAM,MAAM;AACnC,UAAI,uBAAuB,gBAAgB;AACzC,YAAI;AACF,gBAAM,EAAE,aAAa,IAAI,MAAM,OAAO,iBAAiB;AACvD,gBAAM,aAAa;AAAA,YACjB,WAAW,IAAI;AAAA,YACf,OAAO;AAAA,cACL,UAAU,MAAM,MAAM;AAAA,cACtB;AAAA,cACA,QAAQ,MAAM;AAAA,YAChB;AAAA,YACA,OAAO,EAAE,WAAW,QAAQ,GAAG;AAAA,UACjC,CAAC;AAAA,QACH,SAAS,KAAK;AAGZ,iBAAO,KAAK,+CAA+C,EAAE,WAAW,QAAQ,IAAI,IAAI,CAAC;AAAA,QAC3F;AAAA,MACF;AAAA,IACF;AAEA,WAAO,EAAE,QAAQ,aAAa,WAAW,QAAQ,IAAI,mBAAmB;AAAA,EAC1E;AACF;AAEA,gBAAgB,+BAA+B;AAE/C,IAAO,qCAAQ;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -146,7 +146,8 @@ const ingestInboundMessageCommand = {
|
|
|
146
146
|
subject: m.subject ?? null,
|
|
147
147
|
tenantId: input.scope.tenantId,
|
|
148
148
|
organizationId: input.scope.organizationId ?? null,
|
|
149
|
-
lastMessageAt: m.timestamp ?? /* @__PURE__ */ new Date()
|
|
149
|
+
lastMessageAt: m.timestamp ?? /* @__PURE__ */ new Date(),
|
|
150
|
+
assignedUserId: channel.userId ?? null
|
|
150
151
|
});
|
|
151
152
|
em.persist(conversation);
|
|
152
153
|
conversationCreated = true;
|
|
@@ -228,6 +229,7 @@ const ingestInboundMessageCommand = {
|
|
|
228
229
|
const rawBody = m.bodyFormat === "html" ? plainAlternative || convertHtmlBody() : sourceBody;
|
|
229
230
|
const truncatedBody = rawBody.length > MAX_COMPOSE_BODY ? rawBody.slice(0, MAX_COMPOSE_BODY - TRUNCATE_MARKER.length) + TRUNCATE_MARKER : rawBody;
|
|
230
231
|
const safeSubject = (m.subject ?? "").trim() || "(no subject)";
|
|
232
|
+
const routedAssigneeId = mapping?.assignedUserId ?? channel.userId ?? null;
|
|
231
233
|
const composeInput = {
|
|
232
234
|
type: `channel.${input.providerKey}`,
|
|
233
235
|
visibility: "public",
|
|
@@ -240,7 +242,13 @@ const ingestInboundMessageCommand = {
|
|
|
240
242
|
// senders have no address (Discord, Slack, SMS…) are validated without it.
|
|
241
243
|
// The messages validator fails closed on any type it does not recognize.
|
|
242
244
|
sourceChannelType: input.channelType,
|
|
243
|
-
|
|
245
|
+
// #6093: this message came in from the channel, so the recipient below
|
|
246
|
+
// is internal routing (the conversation's assignee), not an external
|
|
247
|
+
// addressee. Without the flag the hub's "no recipients on a public
|
|
248
|
+
// message" rule rejected every message in an assigned conversation and
|
|
249
|
+
// the worker dropped it as a permanent failure.
|
|
250
|
+
inboundFromChannel: true,
|
|
251
|
+
recipients: routedAssigneeId ? [{ userId: routedAssigneeId, type: "to" }] : [],
|
|
244
252
|
subject: safeSubject,
|
|
245
253
|
body: truncatedBody,
|
|
246
254
|
bodyFormat: m.bodyFormat === "html" ? "text" : m.bodyFormat,
|
|
@@ -283,6 +291,7 @@ const ingestInboundMessageCommand = {
|
|
|
283
291
|
channelId: input.channelId,
|
|
284
292
|
providerKey: input.providerKey,
|
|
285
293
|
externalThreadRef: m.externalConversationId,
|
|
294
|
+
assignedUserId: channel.userId ?? null,
|
|
286
295
|
tenantId: input.scope.tenantId,
|
|
287
296
|
organizationId: input.scope.organizationId ?? null
|
|
288
297
|
});
|