@open-mercato/core 0.7.1-develop.7112.1.d486e6f1b2 → 0.7.1-develop.7120.1.a0154ac412
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/helpers/integration/crmFixtures.js.map +1 -1
- package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js +9 -2
- package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js.map +2 -2
- package/dist/modules/communication_channels/commands/deliver-outbound-message.js +34 -2
- package/dist/modules/communication_channels/commands/deliver-outbound-message.js.map +2 -2
- package/dist/modules/communication_channels/lib/outbound-recipient.js +5 -1
- package/dist/modules/communication_channels/lib/outbound-recipient.js.map +2 -2
- package/dist/modules/communication_channels/lib/outbound-reply-ref.js +49 -0
- package/dist/modules/communication_channels/lib/outbound-reply-ref.js.map +7 -0
- package/dist/modules/communication_channels/lib/test-seed.js +5 -0
- package/dist/modules/communication_channels/lib/test-seed.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.js +1 -1
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.js.map +1 -1
- package/dist/modules/customers/backend/customers/deals/page.js +4 -3
- package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js +1 -1
- package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js.map +1 -1
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js +1 -1
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
- package/dist/modules/customers/cli.js +5 -5
- package/dist/modules/customers/cli.js.map +2 -2
- package/dist/modules/customers/commands/deals.js.map +2 -2
- package/dist/modules/customers/components/detail/pipelineStageUtils.js +1 -0
- package/dist/modules/customers/components/detail/pipelineStageUtils.js.map +2 -2
- package/dist/modules/customers/components/linking/adapters/dealAdapter.js +2 -2
- package/dist/modules/customers/components/linking/adapters/dealAdapter.js.map +2 -2
- package/dist/modules/customers/lib/closureStage.js.map +2 -2
- package/dist/modules/customers/lib/dealStatus.js +6 -4
- package/dist/modules/customers/lib/dealStatus.js.map +2 -2
- package/dist/modules/customers/lib/dealsSummaryQueries.js +3 -3
- package/dist/modules/customers/lib/dealsSummaryQueries.js.map +1 -1
- package/dist/modules/customers/migrations/Migration20260824180000_deal_status_lost.js +30 -0
- package/dist/modules/customers/migrations/Migration20260824180000_deal_status_lost.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/config.js.map +1 -1
- package/dist/modules/eudr/notifications.js +5 -0
- package/dist/modules/eudr/notifications.js.map +2 -2
- package/dist/modules/query_index/lib/search-tokens.js +59 -28
- package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +120 -37
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/warranty_claims/notifications.js +5 -0
- package/dist/modules/warranty_claims/notifications.js.map +2 -2
- package/dist/modules/wms/notifications.js +2 -0
- package/dist/modules/wms/notifications.js.map +2 -2
- package/package.json +7 -7
- package/src/helpers/integration/crmFixtures.ts +1 -1
- package/src/modules/communication_channels/api/post/channels/[id]/test-send/route.ts +21 -9
- package/src/modules/communication_channels/commands/deliver-outbound-message.ts +45 -1
- package/src/modules/communication_channels/lib/adapter.ts +11 -0
- package/src/modules/communication_channels/lib/outbound-recipient.ts +14 -1
- package/src/modules/communication_channels/lib/outbound-reply-ref.ts +138 -0
- package/src/modules/communication_channels/lib/test-seed.ts +19 -0
- package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.ts +1 -1
- package/src/modules/customers/backend/customers/deals/page.tsx +4 -3
- package/src/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.tsx +5 -5
- package/src/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.tsx +3 -3
- package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +2 -2
- package/src/modules/customers/cli.ts +5 -5
- package/src/modules/customers/commands/deals.ts +4 -3
- package/src/modules/customers/components/detail/pipelineStageUtils.ts +1 -0
- package/src/modules/customers/components/linking/adapters/dealAdapter.tsx +2 -2
- package/src/modules/customers/i18n/de.json +1 -1
- package/src/modules/customers/i18n/en.json +1 -1
- package/src/modules/customers/i18n/es.json +1 -1
- package/src/modules/customers/i18n/ko.json +1 -1
- package/src/modules/customers/i18n/pl.json +1 -1
- package/src/modules/customers/lib/closureStage.ts +2 -1
- package/src/modules/customers/lib/dealStatus.ts +23 -12
- package/src/modules/customers/lib/dealsSummaryQueries.ts +3 -3
- package/src/modules/customers/migrations/Migration20260824180000_deal_status_lost.ts +55 -0
- package/src/modules/dashboards/widgets/dashboard/pipeline-summary/config.ts +1 -1
- package/src/modules/eudr/notifications.ts +5 -0
- package/src/modules/query_index/lib/search-tokens.ts +100 -43
- package/src/modules/sales/commands/documents.ts +153 -39
- package/src/modules/warranty_claims/notifications.ts +5 -0
- package/src/modules/wms/notifications.ts +2 -0
|
@@ -2,6 +2,7 @@ const notificationTypes = [
|
|
|
2
2
|
{
|
|
3
3
|
type: "warranty_claims.claim.submitted",
|
|
4
4
|
module: "warranty_claims",
|
|
5
|
+
channels: ["in_app", "email"],
|
|
5
6
|
titleKey: "warranty_claims.notifications.submitted.title",
|
|
6
7
|
bodyKey: "warranty_claims.notifications.submitted.body",
|
|
7
8
|
icon: "clipboard-check",
|
|
@@ -21,6 +22,7 @@ const notificationTypes = [
|
|
|
21
22
|
{
|
|
22
23
|
type: "warranty_claims.claim.assigned",
|
|
23
24
|
module: "warranty_claims",
|
|
25
|
+
channels: ["in_app", "email"],
|
|
24
26
|
titleKey: "warranty_claims.notifications.assigned.title",
|
|
25
27
|
bodyKey: "warranty_claims.notifications.assigned.body",
|
|
26
28
|
icon: "user-check",
|
|
@@ -40,6 +42,7 @@ const notificationTypes = [
|
|
|
40
42
|
{
|
|
41
43
|
type: "warranty_claims.claim.status_changed",
|
|
42
44
|
module: "warranty_claims",
|
|
45
|
+
channels: ["in_app", "email"],
|
|
43
46
|
titleKey: "warranty_claims.notifications.statusChanged.title",
|
|
44
47
|
bodyKey: "warranty_claims.notifications.statusChanged.body",
|
|
45
48
|
icon: "refresh-cw",
|
|
@@ -59,6 +62,7 @@ const notificationTypes = [
|
|
|
59
62
|
{
|
|
60
63
|
type: "warranty_claims.claim.escalated",
|
|
61
64
|
module: "warranty_claims",
|
|
65
|
+
channels: ["in_app", "email"],
|
|
62
66
|
titleKey: "warranty_claims.notifications.escalated.title",
|
|
63
67
|
bodyKey: "warranty_claims.notifications.escalated.body",
|
|
64
68
|
icon: "alarm-clock",
|
|
@@ -78,6 +82,7 @@ const notificationTypes = [
|
|
|
78
82
|
{
|
|
79
83
|
type: "warranty_claims.claim.customer_replied",
|
|
80
84
|
module: "warranty_claims",
|
|
85
|
+
channels: ["in_app", "email"],
|
|
81
86
|
titleKey: "warranty_claims.notifications.customerReplied.title",
|
|
82
87
|
bodyKey: "warranty_claims.notifications.customerReplied.body",
|
|
83
88
|
icon: "message-square-reply",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/modules/warranty_claims/notifications.ts"],
|
|
4
|
-
"sourcesContent": ["import type { NotificationTypeDefinition } from '@open-mercato/shared/modules/notifications/types'\n\nexport const notificationTypes: NotificationTypeDefinition[] = [\n {\n type: 'warranty_claims.claim.submitted',\n module: 'warranty_claims',\n titleKey: 'warranty_claims.notifications.submitted.title',\n bodyKey: 'warranty_claims.notifications.submitted.body',\n icon: 'clipboard-check',\n severity: 'info',\n actions: [\n {\n id: 'view',\n labelKey: 'common.view',\n variant: 'outline',\n href: '/backend/warranty_claims/{sourceEntityId}',\n icon: 'external-link',\n },\n ],\n linkHref: '/backend/warranty_claims/{sourceEntityId}',\n expiresAfterHours: 168,\n },\n {\n type: 'warranty_claims.claim.assigned',\n module: 'warranty_claims',\n titleKey: 'warranty_claims.notifications.assigned.title',\n bodyKey: 'warranty_claims.notifications.assigned.body',\n icon: 'user-check',\n severity: 'info',\n actions: [\n {\n id: 'view',\n labelKey: 'common.view',\n variant: 'outline',\n href: '/backend/warranty_claims/{sourceEntityId}',\n icon: 'external-link',\n },\n ],\n linkHref: '/backend/warranty_claims/{sourceEntityId}',\n expiresAfterHours: 168,\n },\n {\n type: 'warranty_claims.claim.status_changed',\n module: 'warranty_claims',\n titleKey: 'warranty_claims.notifications.statusChanged.title',\n bodyKey: 'warranty_claims.notifications.statusChanged.body',\n icon: 'refresh-cw',\n severity: 'info',\n actions: [\n {\n id: 'view',\n labelKey: 'common.view',\n variant: 'outline',\n href: '/backend/warranty_claims/{sourceEntityId}',\n icon: 'external-link',\n },\n ],\n linkHref: '/backend/warranty_claims/{sourceEntityId}',\n expiresAfterHours: 168,\n },\n {\n type: 'warranty_claims.claim.escalated',\n module: 'warranty_claims',\n titleKey: 'warranty_claims.notifications.escalated.title',\n bodyKey: 'warranty_claims.notifications.escalated.body',\n icon: 'alarm-clock',\n severity: 'warning',\n actions: [\n {\n id: 'view',\n labelKey: 'common.view',\n variant: 'outline',\n href: '/backend/warranty_claims/{sourceEntityId}',\n icon: 'external-link',\n },\n ],\n linkHref: '/backend/warranty_claims/{sourceEntityId}',\n expiresAfterHours: 168,\n },\n {\n type: 'warranty_claims.claim.customer_replied',\n module: 'warranty_claims',\n titleKey: 'warranty_claims.notifications.customerReplied.title',\n bodyKey: 'warranty_claims.notifications.customerReplied.body',\n icon: 'message-square-reply',\n severity: 'info',\n actions: [\n {\n id: 'view',\n labelKey: 'common.view',\n variant: 'outline',\n href: '/backend/warranty_claims/{sourceEntityId}',\n icon: 'external-link',\n },\n ],\n linkHref: '/backend/warranty_claims/{sourceEntityId}',\n expiresAfterHours: 168,\n },\n]\n\nexport default notificationTypes\n"],
|
|
5
|
-
"mappings": "AAEO,MAAM,oBAAkD;AAAA,EAC7D;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS;AAAA,MACP;AAAA,QACE,IAAI;AAAA,QACJ,UAAU;AAAA,QACV,SAAS;AAAA,QACT,MAAM;AAAA,QACN,MAAM;AAAA,MACR;AAAA,IACF;AAAA,IACA,UAAU;AAAA,IACV,mBAAmB;AAAA,EACrB;AAAA,EACA;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS;AAAA,MACP;AAAA,QACE,IAAI;AAAA,QACJ,UAAU;AAAA,QACV,SAAS;AAAA,QACT,MAAM;AAAA,QACN,MAAM;AAAA,MACR;AAAA,IACF;AAAA,IACA,UAAU;AAAA,IACV,mBAAmB;AAAA,EACrB;AAAA,EACA;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS;AAAA,MACP;AAAA,QACE,IAAI;AAAA,QACJ,UAAU;AAAA,QACV,SAAS;AAAA,QACT,MAAM;AAAA,QACN,MAAM;AAAA,MACR;AAAA,IACF;AAAA,IACA,UAAU;AAAA,IACV,mBAAmB;AAAA,EACrB;AAAA,EACA;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS;AAAA,MACP;AAAA,QACE,IAAI;AAAA,QACJ,UAAU;AAAA,QACV,SAAS;AAAA,QACT,MAAM;AAAA,QACN,MAAM;AAAA,MACR;AAAA,IACF;AAAA,IACA,UAAU;AAAA,IACV,mBAAmB;AAAA,EACrB;AAAA,EACA;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS;AAAA,MACP;AAAA,QACE,IAAI;AAAA,QACJ,UAAU;AAAA,QACV,SAAS;AAAA,QACT,MAAM;AAAA,QACN,MAAM;AAAA,MACR;AAAA,IACF;AAAA,IACA,UAAU;AAAA,IACV,mBAAmB;AAAA,EACrB;AACF;AAEA,IAAO,wBAAQ;",
|
|
4
|
+
"sourcesContent": ["import type { NotificationTypeDefinition } from '@open-mercato/shared/modules/notifications/types'\n\nexport const notificationTypes: NotificationTypeDefinition[] = [\n {\n type: 'warranty_claims.claim.submitted',\n module: 'warranty_claims',\n channels: ['in_app', 'email'],\n titleKey: 'warranty_claims.notifications.submitted.title',\n bodyKey: 'warranty_claims.notifications.submitted.body',\n icon: 'clipboard-check',\n severity: 'info',\n actions: [\n {\n id: 'view',\n labelKey: 'common.view',\n variant: 'outline',\n href: '/backend/warranty_claims/{sourceEntityId}',\n icon: 'external-link',\n },\n ],\n linkHref: '/backend/warranty_claims/{sourceEntityId}',\n expiresAfterHours: 168,\n },\n {\n type: 'warranty_claims.claim.assigned',\n module: 'warranty_claims',\n channels: ['in_app', 'email'],\n titleKey: 'warranty_claims.notifications.assigned.title',\n bodyKey: 'warranty_claims.notifications.assigned.body',\n icon: 'user-check',\n severity: 'info',\n actions: [\n {\n id: 'view',\n labelKey: 'common.view',\n variant: 'outline',\n href: '/backend/warranty_claims/{sourceEntityId}',\n icon: 'external-link',\n },\n ],\n linkHref: '/backend/warranty_claims/{sourceEntityId}',\n expiresAfterHours: 168,\n },\n {\n type: 'warranty_claims.claim.status_changed',\n module: 'warranty_claims',\n channels: ['in_app', 'email'],\n titleKey: 'warranty_claims.notifications.statusChanged.title',\n bodyKey: 'warranty_claims.notifications.statusChanged.body',\n icon: 'refresh-cw',\n severity: 'info',\n actions: [\n {\n id: 'view',\n labelKey: 'common.view',\n variant: 'outline',\n href: '/backend/warranty_claims/{sourceEntityId}',\n icon: 'external-link',\n },\n ],\n linkHref: '/backend/warranty_claims/{sourceEntityId}',\n expiresAfterHours: 168,\n },\n {\n type: 'warranty_claims.claim.escalated',\n module: 'warranty_claims',\n channels: ['in_app', 'email'],\n titleKey: 'warranty_claims.notifications.escalated.title',\n bodyKey: 'warranty_claims.notifications.escalated.body',\n icon: 'alarm-clock',\n severity: 'warning',\n actions: [\n {\n id: 'view',\n labelKey: 'common.view',\n variant: 'outline',\n href: '/backend/warranty_claims/{sourceEntityId}',\n icon: 'external-link',\n },\n ],\n linkHref: '/backend/warranty_claims/{sourceEntityId}',\n expiresAfterHours: 168,\n },\n {\n type: 'warranty_claims.claim.customer_replied',\n module: 'warranty_claims',\n channels: ['in_app', 'email'],\n titleKey: 'warranty_claims.notifications.customerReplied.title',\n bodyKey: 'warranty_claims.notifications.customerReplied.body',\n icon: 'message-square-reply',\n severity: 'info',\n actions: [\n {\n id: 'view',\n labelKey: 'common.view',\n variant: 'outline',\n href: '/backend/warranty_claims/{sourceEntityId}',\n icon: 'external-link',\n },\n ],\n linkHref: '/backend/warranty_claims/{sourceEntityId}',\n expiresAfterHours: 168,\n },\n]\n\nexport default notificationTypes\n"],
|
|
5
|
+
"mappings": "AAEO,MAAM,oBAAkD;AAAA,EAC7D;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU,CAAC,UAAU,OAAO;AAAA,IAC5B,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS;AAAA,MACP;AAAA,QACE,IAAI;AAAA,QACJ,UAAU;AAAA,QACV,SAAS;AAAA,QACT,MAAM;AAAA,QACN,MAAM;AAAA,MACR;AAAA,IACF;AAAA,IACA,UAAU;AAAA,IACV,mBAAmB;AAAA,EACrB;AAAA,EACA;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU,CAAC,UAAU,OAAO;AAAA,IAC5B,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS;AAAA,MACP;AAAA,QACE,IAAI;AAAA,QACJ,UAAU;AAAA,QACV,SAAS;AAAA,QACT,MAAM;AAAA,QACN,MAAM;AAAA,MACR;AAAA,IACF;AAAA,IACA,UAAU;AAAA,IACV,mBAAmB;AAAA,EACrB;AAAA,EACA;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU,CAAC,UAAU,OAAO;AAAA,IAC5B,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS;AAAA,MACP;AAAA,QACE,IAAI;AAAA,QACJ,UAAU;AAAA,QACV,SAAS;AAAA,QACT,MAAM;AAAA,QACN,MAAM;AAAA,MACR;AAAA,IACF;AAAA,IACA,UAAU;AAAA,IACV,mBAAmB;AAAA,EACrB;AAAA,EACA;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU,CAAC,UAAU,OAAO;AAAA,IAC5B,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS;AAAA,MACP;AAAA,QACE,IAAI;AAAA,QACJ,UAAU;AAAA,QACV,SAAS;AAAA,QACT,MAAM;AAAA,QACN,MAAM;AAAA,MACR;AAAA,IACF;AAAA,IACA,UAAU;AAAA,IACV,mBAAmB;AAAA,EACrB;AAAA,EACA;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU,CAAC,UAAU,OAAO;AAAA,IAC5B,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS;AAAA,MACP;AAAA,QACE,IAAI;AAAA,QACJ,UAAU;AAAA,QACV,SAAS;AAAA,QACT,MAAM;AAAA,QACN,MAAM;AAAA,MACR;AAAA,IACF;AAAA,IACA,UAAU;AAAA,IACV,mBAAmB;AAAA,EACrB;AACF;AAEA,IAAO,wBAAQ;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -2,6 +2,7 @@ const notificationTypes = [
|
|
|
2
2
|
{
|
|
3
3
|
type: "wms.inventory.low_stock",
|
|
4
4
|
module: "wms",
|
|
5
|
+
channels: ["in_app", "email"],
|
|
5
6
|
titleKey: "wms.notifications.lowStock.title",
|
|
6
7
|
bodyKey: "wms.notifications.lowStock.body",
|
|
7
8
|
icon: "alert-triangle",
|
|
@@ -21,6 +22,7 @@ const notificationTypes = [
|
|
|
21
22
|
{
|
|
22
23
|
type: "wms.inventory.reservation_shortfall",
|
|
23
24
|
module: "wms",
|
|
25
|
+
channels: ["in_app", "email"],
|
|
24
26
|
titleKey: "wms.notifications.reservationShortfall.title",
|
|
25
27
|
bodyKey: "wms.notifications.reservationShortfall.body",
|
|
26
28
|
icon: "alert-triangle",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/modules/wms/notifications.ts"],
|
|
4
|
-
"sourcesContent": ["import type { NotificationTypeDefinition } from '@open-mercato/shared/modules/notifications/types'\n\nexport const notificationTypes: NotificationTypeDefinition[] = [\n {\n type: 'wms.inventory.low_stock',\n module: 'wms',\n titleKey: 'wms.notifications.lowStock.title',\n bodyKey: 'wms.notifications.lowStock.body',\n icon: 'alert-triangle',\n severity: 'warning',\n actions: [\n {\n id: 'view',\n labelKey: 'wms.notifications.lowStock.renderer.viewInventory',\n variant: 'outline',\n href: '/backend/wms/inventory',\n icon: 'external-link',\n },\n ],\n linkHref: '/backend/wms/inventory',\n expiresAfterHours: 72,\n },\n {\n type: 'wms.inventory.reservation_shortfall',\n module: 'wms',\n titleKey: 'wms.notifications.reservationShortfall.title',\n bodyKey: 'wms.notifications.reservationShortfall.body',\n icon: 'alert-triangle',\n severity: 'warning',\n actions: [\n {\n id: 'view-order',\n labelKey: 'wms.notifications.reservationShortfall.renderer.viewOrder',\n variant: 'outline',\n href: '/backend/sales/orders/{sourceEntityId}',\n icon: 'external-link',\n },\n {\n id: 'view-inventory',\n labelKey: 'wms.notifications.reservationShortfall.renderer.viewInventory',\n variant: 'outline',\n href: '/backend/wms/inventory',\n icon: 'external-link',\n },\n ],\n linkHref: '/backend/sales/orders/{sourceEntityId}',\n expiresAfterHours: 72,\n },\n]\n\nexport default notificationTypes\n"],
|
|
5
|
-
"mappings": "AAEO,MAAM,oBAAkD;AAAA,EAC7D;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS;AAAA,MACP;AAAA,QACE,IAAI;AAAA,QACJ,UAAU;AAAA,QACV,SAAS;AAAA,QACT,MAAM;AAAA,QACN,MAAM;AAAA,MACR;AAAA,IACF;AAAA,IACA,UAAU;AAAA,IACV,mBAAmB;AAAA,EACrB;AAAA,EACA;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS;AAAA,MACP;AAAA,QACE,IAAI;AAAA,QACJ,UAAU;AAAA,QACV,SAAS;AAAA,QACT,MAAM;AAAA,QACN,MAAM;AAAA,MACR;AAAA,MACA;AAAA,QACE,IAAI;AAAA,QACJ,UAAU;AAAA,QACV,SAAS;AAAA,QACT,MAAM;AAAA,QACN,MAAM;AAAA,MACR;AAAA,IACF;AAAA,IACA,UAAU;AAAA,IACV,mBAAmB;AAAA,EACrB;AACF;AAEA,IAAO,wBAAQ;",
|
|
4
|
+
"sourcesContent": ["import type { NotificationTypeDefinition } from '@open-mercato/shared/modules/notifications/types'\n\nexport const notificationTypes: NotificationTypeDefinition[] = [\n {\n type: 'wms.inventory.low_stock',\n module: 'wms',\n channels: ['in_app', 'email'],\n titleKey: 'wms.notifications.lowStock.title',\n bodyKey: 'wms.notifications.lowStock.body',\n icon: 'alert-triangle',\n severity: 'warning',\n actions: [\n {\n id: 'view',\n labelKey: 'wms.notifications.lowStock.renderer.viewInventory',\n variant: 'outline',\n href: '/backend/wms/inventory',\n icon: 'external-link',\n },\n ],\n linkHref: '/backend/wms/inventory',\n expiresAfterHours: 72,\n },\n {\n type: 'wms.inventory.reservation_shortfall',\n module: 'wms',\n channels: ['in_app', 'email'],\n titleKey: 'wms.notifications.reservationShortfall.title',\n bodyKey: 'wms.notifications.reservationShortfall.body',\n icon: 'alert-triangle',\n severity: 'warning',\n actions: [\n {\n id: 'view-order',\n labelKey: 'wms.notifications.reservationShortfall.renderer.viewOrder',\n variant: 'outline',\n href: '/backend/sales/orders/{sourceEntityId}',\n icon: 'external-link',\n },\n {\n id: 'view-inventory',\n labelKey: 'wms.notifications.reservationShortfall.renderer.viewInventory',\n variant: 'outline',\n href: '/backend/wms/inventory',\n icon: 'external-link',\n },\n ],\n linkHref: '/backend/sales/orders/{sourceEntityId}',\n expiresAfterHours: 72,\n },\n]\n\nexport default notificationTypes\n"],
|
|
5
|
+
"mappings": "AAEO,MAAM,oBAAkD;AAAA,EAC7D;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU,CAAC,UAAU,OAAO;AAAA,IAC5B,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS;AAAA,MACP;AAAA,QACE,IAAI;AAAA,QACJ,UAAU;AAAA,QACV,SAAS;AAAA,QACT,MAAM;AAAA,QACN,MAAM;AAAA,MACR;AAAA,IACF;AAAA,IACA,UAAU;AAAA,IACV,mBAAmB;AAAA,EACrB;AAAA,EACA;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU,CAAC,UAAU,OAAO;AAAA,IAC5B,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS;AAAA,MACP;AAAA,QACE,IAAI;AAAA,QACJ,UAAU;AAAA,QACV,SAAS;AAAA,QACT,MAAM;AAAA,QACN,MAAM;AAAA,MACR;AAAA,MACA;AAAA,QACE,IAAI;AAAA,QACJ,UAAU;AAAA,QACV,SAAS;AAAA,QACT,MAAM;AAAA,QACN,MAAM;AAAA,MACR;AAAA,IACF;AAAA,IACA,UAAU;AAAA,IACV,mBAAmB;AAAA,EACrB;AACF;AAEA,IAAO,wBAAQ;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.7.1-develop.
|
|
3
|
+
"version": "0.7.1-develop.7120.1.a0154ac412",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -252,16 +252,16 @@
|
|
|
252
252
|
"zod": "^4.4.3"
|
|
253
253
|
},
|
|
254
254
|
"peerDependencies": {
|
|
255
|
-
"@open-mercato/ai-assistant": "0.7.1-develop.
|
|
256
|
-
"@open-mercato/shared": "0.7.1-develop.
|
|
257
|
-
"@open-mercato/ui": "0.7.1-develop.
|
|
255
|
+
"@open-mercato/ai-assistant": "0.7.1-develop.7120.1.a0154ac412",
|
|
256
|
+
"@open-mercato/shared": "0.7.1-develop.7120.1.a0154ac412",
|
|
257
|
+
"@open-mercato/ui": "0.7.1-develop.7120.1.a0154ac412",
|
|
258
258
|
"react": "^19.0.0",
|
|
259
259
|
"react-dom": "^19.0.0"
|
|
260
260
|
},
|
|
261
261
|
"devDependencies": {
|
|
262
|
-
"@open-mercato/ai-assistant": "0.7.1-develop.
|
|
263
|
-
"@open-mercato/shared": "0.7.1-develop.
|
|
264
|
-
"@open-mercato/ui": "0.7.1-develop.
|
|
262
|
+
"@open-mercato/ai-assistant": "0.7.1-develop.7120.1.a0154ac412",
|
|
263
|
+
"@open-mercato/shared": "0.7.1-develop.7120.1.a0154ac412",
|
|
264
|
+
"@open-mercato/ui": "0.7.1-develop.7120.1.a0154ac412",
|
|
265
265
|
"@testing-library/dom": "^10.4.1",
|
|
266
266
|
"@testing-library/jest-dom": "^7.0.0",
|
|
267
267
|
"@testing-library/react": "^16.3.1",
|
|
@@ -81,7 +81,7 @@ export async function createDealFixture(
|
|
|
81
81
|
valueCurrency?: string;
|
|
82
82
|
// Optional deal lifecycle fields — all valid on `dealCreateSchema`. Forwarded so KPI/summary
|
|
83
83
|
// tests can seed won/lost/overdue/owned deals across quarters (see TC-CRM-082). `status` is a
|
|
84
|
-
// free-form dictionary value (e.g. 'open', 'in_progress', 'win', '
|
|
84
|
+
// free-form dictionary value (e.g. 'open', 'in_progress', 'win', 'lost'); `expectedCloseAt`
|
|
85
85
|
// accepts an ISO string (the schema coerces it to a Date); `closureOutcome` is 'won' | 'lost'.
|
|
86
86
|
status?: string;
|
|
87
87
|
expectedCloseAt?: string;
|
|
@@ -37,15 +37,20 @@ export const metadata = {
|
|
|
37
37
|
},
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
// `to` is deliberately NOT typed as an email here
|
|
41
|
-
// on the provider (an email address for Gmail/IMAP,
|
|
42
|
-
// Discord), and the adapter is only known once the
|
|
43
|
-
// schema accepts any non-empty single-line string and
|
|
44
|
-
// applies the provider-appropriate rules below,
|
|
45
|
-
//
|
|
46
|
-
//
|
|
40
|
+
// `to` is deliberately NOT typed as an email here, and deliberately optional.
|
|
41
|
+
// The recipient shape depends on the provider (an email address for Gmail/IMAP,
|
|
42
|
+
// a channel snowflake for Discord), and the adapter is only known once the
|
|
43
|
+
// channel is loaded — so the schema accepts any non-empty single-line string and
|
|
44
|
+
// `validateOutboundRecipient` applies the provider-appropriate rules below,
|
|
45
|
+
// defaulting to email. Whether the recipient may be left out is provider-derived
|
|
46
|
+
// too: an adapter that carries its own outbound target (Discord's
|
|
47
|
+
// `defaultChannelId`) accepts a body with no `to` and posts there, which is the
|
|
48
|
+
// smoke test the Discord spec documents; an email provider has no such default
|
|
49
|
+
// and `validateOutboundRecipient` still refuses the request. The CR/LF rejection
|
|
50
|
+
// stays at the schema level so a header-injection attempt is refused before the
|
|
51
|
+
// request reaches the channel lookup or the mutation guard.
|
|
47
52
|
const bodySchema = z.object({
|
|
48
|
-
to: z.string().min(1).max(MAX_OUTBOUND_RECIPIENT_LENGTH).regex(/^[^\r\n]*$/),
|
|
53
|
+
to: z.string().min(1).max(MAX_OUTBOUND_RECIPIENT_LENGTH).regex(/^[^\r\n]*$/).optional(),
|
|
49
54
|
subject: z.string().min(1).max(500).optional(),
|
|
50
55
|
body: z.string().max(50_000).optional(),
|
|
51
56
|
})
|
|
@@ -232,7 +237,14 @@ export async function POST(req: Request, context: RouteContext): Promise<Respons
|
|
|
232
237
|
tenantId: auth.tenantId as string,
|
|
233
238
|
organizationId: channelCredentialsOrg,
|
|
234
239
|
},
|
|
235
|
-
|
|
240
|
+
// `to` is omitted from the metadata rather than passed as `undefined` when
|
|
241
|
+
// the caller left it out, so an adapter reading `metadata.to` sees no key
|
|
242
|
+
// at all and falls through to its own configured target.
|
|
243
|
+
metadata: {
|
|
244
|
+
...(body.to !== undefined ? { to: body.to } : {}),
|
|
245
|
+
subject: body.subject ?? 'Test send',
|
|
246
|
+
testSend: true,
|
|
247
|
+
},
|
|
236
248
|
})
|
|
237
249
|
await guard.afterSuccess()
|
|
238
250
|
return NextResponse.json({
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
getOrCreateThreadToken,
|
|
14
14
|
} from '../lib/thread-token'
|
|
15
15
|
import { stringOrUndefined, stripBrackets } from '../lib/email-mime'
|
|
16
|
+
import { resolveOutboundReplyExternalId, stripCallerReplyTargeting } from '../lib/outbound-reply-ref'
|
|
16
17
|
import type { ChannelAdapterRegistry } from '../lib/registry'
|
|
17
18
|
import { isUniqueViolation } from '../lib/pg-errors'
|
|
18
19
|
import { Message } from '../../messages/data/entities'
|
|
@@ -348,6 +349,41 @@ const deliverOutboundMessageCommand: CommandHandler<
|
|
|
348
349
|
moduleLogger.warn('thread token unavailable, proceeding without it', { err: tokenErr })
|
|
349
350
|
}
|
|
350
351
|
|
|
352
|
+
// (4c) Chat-provider reply threading — resolve the provider-native id of the
|
|
353
|
+
// message this one answers so a threading adapter can attach it (Discord's
|
|
354
|
+
// `message_reference`). Email adapters ignore this and keep threading on the
|
|
355
|
+
// `inReplyTo` / `references` headers built above. Best-effort like the thread
|
|
356
|
+
// token: an unresolvable parent sends an unthreaded reply rather than failing
|
|
357
|
+
// a delivery.
|
|
358
|
+
let replyToExternalId: string | null = null
|
|
359
|
+
try {
|
|
360
|
+
replyToExternalId = await resolveOutboundReplyExternalId(em, {
|
|
361
|
+
parentMessageId: message.parentMessageId ?? null,
|
|
362
|
+
externalConversationId: mapping.externalConversationId,
|
|
363
|
+
capabilities: adapter.capabilities,
|
|
364
|
+
scope: {
|
|
365
|
+
tenantId: input.scope.tenantId,
|
|
366
|
+
organizationId: input.scope.organizationId ?? null,
|
|
367
|
+
},
|
|
368
|
+
})
|
|
369
|
+
} catch (replyRefErr) {
|
|
370
|
+
moduleLogger.warn('outbound reply reference unavailable, sending unthreaded', {
|
|
371
|
+
err: replyRefErr,
|
|
372
|
+
})
|
|
373
|
+
}
|
|
374
|
+
if (!replyToExternalId && message.parentMessageId && adapter.capabilities?.threading === true) {
|
|
375
|
+
// The provider threads and the caller asked for a reply, yet nothing
|
|
376
|
+
// resolved — the parent never reached this channel, or it belongs to
|
|
377
|
+
// another conversation. Delivery is unaffected, but say so: an unthreaded
|
|
378
|
+
// reply is otherwise indistinguishable from a non-reply, which is exactly
|
|
379
|
+
// the silent-unreachability shape #5541 was filed about.
|
|
380
|
+
moduleLogger.debug('reply parent has no external id on this channel, sending unthreaded', {
|
|
381
|
+
messageId: message.id,
|
|
382
|
+
parentMessageId: message.parentMessageId,
|
|
383
|
+
conversationId: mapping.externalConversationId,
|
|
384
|
+
})
|
|
385
|
+
}
|
|
386
|
+
|
|
351
387
|
// (5) + (6) Convert + send.
|
|
352
388
|
try {
|
|
353
389
|
const outboundPayload = (link.channelPayload as Record<string, unknown> | null) ?? {}
|
|
@@ -392,9 +428,17 @@ const deliverOutboundMessageCommand: CommandHandler<
|
|
|
392
428
|
bodyFormat: outboundBodyFormat,
|
|
393
429
|
channelMetadata: {
|
|
394
430
|
thread_id: mapping.externalThreadRef,
|
|
395
|
-
|
|
431
|
+
// Reply targeting is hub-resolved only. `send-as-user` merges
|
|
432
|
+
// caller-supplied metadata onto the link, so every reply-targeting key
|
|
433
|
+
// is stripped off the stored metadata here rather than merely
|
|
434
|
+
// out-ranked below: merge order settles the contest only when the hub
|
|
435
|
+
// resolved a parent, and the uncontested case — no `parentMessageId`,
|
|
436
|
+
// or a parent that does not resolve — is exactly the one a caller
|
|
437
|
+
// controls.
|
|
438
|
+
...stripCallerReplyTargeting(baseMetadata),
|
|
396
439
|
references: mergedReferences,
|
|
397
440
|
...(threadToken ? { omThreadToken: threadToken } : {}),
|
|
441
|
+
...(replyToExternalId ? { replyToExternalId } : {}),
|
|
398
442
|
},
|
|
399
443
|
})
|
|
400
444
|
|
|
@@ -60,6 +60,17 @@ export interface ChannelCapabilities {
|
|
|
60
60
|
* applies transport-safety checks only (see `validateOutboundRecipient`) and
|
|
61
61
|
* the adapter owns the provider-specific format — it MUST treat the value as
|
|
62
62
|
* untrusted input.
|
|
63
|
+
*
|
|
64
|
+
* **`'provider-native'` also opts the provider into presence-optionality, not
|
|
65
|
+
* only format.** The hub accepts an outbound request that names no recipient
|
|
66
|
+
* at all and forwards it with no `metadata.to` key, on the understanding that
|
|
67
|
+
* the adapter resolves its own configured target (Discord: `defaultChannelId`).
|
|
68
|
+
* An adapter declaring this therefore MUST resolve such a target, and MUST
|
|
69
|
+
* fail legibly — an operator-readable `SendMessageResult.error`, not a throw —
|
|
70
|
+
* when it has none, because that message is surfaced verbatim to whoever ran
|
|
71
|
+
* the send. Declare `'email'` (or omit the field) if your provider has no
|
|
72
|
+
* default destination: the hub then keeps answering `Recipient is required`,
|
|
73
|
+
* which is the correct outcome for a request with nowhere to go.
|
|
63
74
|
*/
|
|
64
75
|
recipientFormat?: 'email' | 'provider-native'
|
|
65
76
|
}
|
|
@@ -37,11 +37,24 @@ export type OutboundRecipientCheck = { ok: true } | { ok: false; error: string }
|
|
|
37
37
|
* path to send at all (#4976). Validation now follows the adapter's
|
|
38
38
|
* `capabilities.recipientFormat`, defaulting to `'email'` so every existing
|
|
39
39
|
* provider keeps byte-identical behavior.
|
|
40
|
+
*
|
|
41
|
+
* A `'provider-native'` provider may also be addressed by **omitting** the
|
|
42
|
+
* recipient entirely: those adapters are configured with their own outbound
|
|
43
|
+
* target (Discord's `defaultChannelId`) and fall back to it when the message
|
|
44
|
+
* names no channel, which is the smoke test the Discord spec documents in
|
|
45
|
+
* § 6 "Test it". An email provider has no such default, so `'email'` — the
|
|
46
|
+
* format every existing provider resolves to — still requires a recipient.
|
|
47
|
+
* Omission means `undefined` only: `null` or `''` is a caller that meant to
|
|
48
|
+
* supply an address and got it wrong, and stays a 422 on every provider.
|
|
40
49
|
*/
|
|
41
50
|
export function validateOutboundRecipient(
|
|
42
51
|
recipient: unknown,
|
|
43
52
|
capabilities: Pick<ChannelCapabilities, 'recipientFormat'> | null | undefined,
|
|
44
53
|
): OutboundRecipientCheck {
|
|
54
|
+
const isProviderNative = capabilities?.recipientFormat === 'provider-native'
|
|
55
|
+
if (recipient === undefined && isProviderNative) {
|
|
56
|
+
return { ok: true }
|
|
57
|
+
}
|
|
45
58
|
if (typeof recipient !== 'string' || recipient.length === 0) {
|
|
46
59
|
return { ok: false, error: 'Recipient is required' }
|
|
47
60
|
}
|
|
@@ -51,7 +64,7 @@ export function validateOutboundRecipient(
|
|
|
51
64
|
error: `Recipient must be at most ${MAX_OUTBOUND_RECIPIENT_LENGTH} characters`,
|
|
52
65
|
}
|
|
53
66
|
}
|
|
54
|
-
if (
|
|
67
|
+
if (!isProviderNative) {
|
|
55
68
|
return emailRecipientSchema.safeParse(recipient).success
|
|
56
69
|
? { ok: true }
|
|
57
70
|
: { ok: false, error: 'Recipient must be a valid email address' }
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
3
|
+
import type { ChannelCapabilities } from './adapter'
|
|
4
|
+
import { ExternalMessage, MessageChannelLink } from '../data/entities'
|
|
5
|
+
|
|
6
|
+
export interface OutboundReplyRefInput {
|
|
7
|
+
/** `Message.parentMessageId` of the message being delivered. */
|
|
8
|
+
parentMessageId: string | null | undefined
|
|
9
|
+
/** `ChannelThreadMapping.externalConversationId` the message is being sent to. */
|
|
10
|
+
externalConversationId: string
|
|
11
|
+
capabilities: Pick<ChannelCapabilities, 'threading'> | null | undefined
|
|
12
|
+
scope: { tenantId: string; organizationId: string | null }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve the provider-native id of the message an outbound reply answers, so
|
|
17
|
+
* chat adapters can attach it (Discord `message_reference`, and any provider
|
|
18
|
+
* that threads by message id rather than by RFC 5322 headers).
|
|
19
|
+
*
|
|
20
|
+
* Email providers thread through `inReplyTo` / `references`, which the hub has
|
|
21
|
+
* always produced. Chat providers had no equivalent: `capabilities.threading`
|
|
22
|
+
* described a reply-attachment the hub could never ask for, because nothing on
|
|
23
|
+
* the outbound path wrote the parent's external id into channel metadata
|
|
24
|
+
* (#5541 — the flag had to be declared `false` to stay honest). This is that
|
|
25
|
+
* missing producer.
|
|
26
|
+
*
|
|
27
|
+
* The hub stores everything needed already: the parent hub message owns a
|
|
28
|
+
* `MessageChannelLink` (unique per `message_id`) whose `external_message_id`
|
|
29
|
+
* points at the `ExternalMessage` row carrying the provider's own id — written
|
|
30
|
+
* by both the inbound ingest and outbound delivery paths, so a reply can answer
|
|
31
|
+
* an inbound message or one of our own sends.
|
|
32
|
+
*
|
|
33
|
+
* Returns `null` — never throws a routing decision at the caller — when the
|
|
34
|
+
* provider does not thread, the message is not a reply, the parent never
|
|
35
|
+
* reached this channel, or the parent belongs to a different conversation.
|
|
36
|
+
* Threading is an enhancement to a delivery, never a precondition for one.
|
|
37
|
+
*/
|
|
38
|
+
export async function resolveOutboundReplyExternalId(
|
|
39
|
+
em: EntityManager,
|
|
40
|
+
input: OutboundReplyRefInput,
|
|
41
|
+
): Promise<string | null> {
|
|
42
|
+
// A non-reply can never produce a reference, so check that before anything
|
|
43
|
+
// that costs a query.
|
|
44
|
+
if (!input.parentMessageId) return null
|
|
45
|
+
// Gate on the adapter's own declaration: handing a non-threading provider a key
|
|
46
|
+
// it silently drops is exactly the mismatch `ChannelCapabilities` exists to
|
|
47
|
+
// prevent. Note this gate does NOT spare header-threading providers — the
|
|
48
|
+
// shared email profile declares `threading: true` (`email-capabilities.ts`), so
|
|
49
|
+
// an email reply pays for both lookups below and its converter then ignores the
|
|
50
|
+
// result in favor of `inReplyTo` / `references`. Correct but wasteful;
|
|
51
|
+
// distinguishing id-threading from header-threading needs a capability the
|
|
52
|
+
// contract does not have yet (#5691).
|
|
53
|
+
if (input.capabilities?.threading !== true) return null
|
|
54
|
+
|
|
55
|
+
const dscope = {
|
|
56
|
+
tenantId: input.scope.tenantId,
|
|
57
|
+
organizationId: input.scope.organizationId,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const parentLink = await findOneWithDecryption(
|
|
61
|
+
em,
|
|
62
|
+
MessageChannelLink,
|
|
63
|
+
{
|
|
64
|
+
messageId: input.parentMessageId,
|
|
65
|
+
tenantId: input.scope.tenantId,
|
|
66
|
+
organizationId: input.scope.organizationId,
|
|
67
|
+
},
|
|
68
|
+
undefined,
|
|
69
|
+
dscope,
|
|
70
|
+
)
|
|
71
|
+
if (!parentLink?.externalMessageId) return null
|
|
72
|
+
|
|
73
|
+
// A parent that lives in another conversation would make the provider reject
|
|
74
|
+
// the send (Discord answers `400 Unknown message` for a cross-channel
|
|
75
|
+
// reference). Dropping the reference degrades to an unthreaded reply, which
|
|
76
|
+
// is strictly better than a failed delivery.
|
|
77
|
+
if (parentLink.externalConversationId !== input.externalConversationId) return null
|
|
78
|
+
|
|
79
|
+
const parentExternal = await findOneWithDecryption(
|
|
80
|
+
em,
|
|
81
|
+
ExternalMessage,
|
|
82
|
+
{
|
|
83
|
+
id: parentLink.externalMessageId,
|
|
84
|
+
tenantId: input.scope.tenantId,
|
|
85
|
+
organizationId: input.scope.organizationId,
|
|
86
|
+
},
|
|
87
|
+
undefined,
|
|
88
|
+
dscope,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if (!parentExternal) return null
|
|
92
|
+
// Re-assert the conversation invariant on the row that actually carries the
|
|
93
|
+
// provider id. Both producers write the link and the external message together,
|
|
94
|
+
// so these cannot disagree today — checking here keeps the guard correct even if
|
|
95
|
+
// that coupling is ever relaxed, rather than trusting a second table's field.
|
|
96
|
+
if (parentExternal.conversationId !== input.externalConversationId) return null
|
|
97
|
+
|
|
98
|
+
const externalId = parentExternal.externalMessageId
|
|
99
|
+
return typeof externalId === 'string' && externalId.length > 0 ? externalId : null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Outbound `channelMetadata` keys that decide which provider message a reply
|
|
104
|
+
* attaches to.
|
|
105
|
+
*
|
|
106
|
+
* - `replyToExternalId` — the hub's own key, produced by
|
|
107
|
+
* `resolveOutboundReplyExternalId` above.
|
|
108
|
+
* - `messageReferenceId` — Discord's already-converted equivalent. Its converter
|
|
109
|
+
* has to accept that key so the value survives the hub's convert→send double
|
|
110
|
+
* conversion (#5541), and the converter cannot tell the two passes apart, so
|
|
111
|
+
* the key is equally accepted on the first, caller-controlled pass.
|
|
112
|
+
*/
|
|
113
|
+
const REPLY_TARGETING_METADATA_KEYS = ['replyToExternalId', 'messageReferenceId'] as const
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Drop every reply-targeting key from stored link metadata, so reply targeting
|
|
117
|
+
* on the outbound path is hub-resolved only.
|
|
118
|
+
*
|
|
119
|
+
* `send-as-user` merges caller-supplied `channelMetadata` onto the
|
|
120
|
+
* `MessageChannelLink`, and `deliver-outbound-message` reads that row back as
|
|
121
|
+
* the converter's base metadata. Merge order alone only settles the contest when
|
|
122
|
+
* the hub actually resolved a parent; the uncontested case — a message with no
|
|
123
|
+
* `parentMessageId`, or one whose parent legitimately fails to resolve — is
|
|
124
|
+
* exactly the one a caller controls, and there nothing would overwrite a
|
|
125
|
+
* smuggled key. Without this, a `communication_channels.manage` caller could aim
|
|
126
|
+
* a reply at an arbitrary provider message id and have the bot's message render
|
|
127
|
+
* as a reply to a message the hub never resolved and never validated.
|
|
128
|
+
*
|
|
129
|
+
* Stripping is safe on a retry: the reference is re-resolved from
|
|
130
|
+
* `parentMessageId` on every delivery attempt, never read back from the link.
|
|
131
|
+
*/
|
|
132
|
+
export function stripCallerReplyTargeting(
|
|
133
|
+
metadata: Record<string, unknown>,
|
|
134
|
+
): Record<string, unknown> {
|
|
135
|
+
const cleaned = { ...metadata }
|
|
136
|
+
for (const key of REPLY_TARGETING_METADATA_KEYS) delete cleaned[key]
|
|
137
|
+
return cleaned
|
|
138
|
+
}
|
|
@@ -136,6 +136,24 @@ class TestSeedChannelAdapter implements ChannelAdapter {
|
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Capabilities for the chat stub. Identical to the email stub's except for the
|
|
141
|
+
* recipient shape: a chat channel is addressed by a provider-issued identifier
|
|
142
|
+
* (a Discord snowflake), not an email address.
|
|
143
|
+
*
|
|
144
|
+
* Without this override the chat stub inherited `baseEmailCapabilities`, so it
|
|
145
|
+
* claimed `channelType: 'discord'` while validating recipients as email
|
|
146
|
+
* addresses — a channel that could not be addressed the way the provider it
|
|
147
|
+
* imitates actually is. That left the hub's `'provider-native'` branch
|
|
148
|
+
* (#4976: per-provider recipient validation, and the omitted recipient that
|
|
149
|
+
* falls back to the adapter's own target) reachable only with a live Discord
|
|
150
|
+
* bot, so no integration test could cover it and CI never exercised it.
|
|
151
|
+
*/
|
|
152
|
+
const testSeedChatCapabilities: ChannelCapabilities = {
|
|
153
|
+
...testSeedCapabilities,
|
|
154
|
+
recipientFormat: 'provider-native',
|
|
155
|
+
}
|
|
156
|
+
|
|
139
157
|
/**
|
|
140
158
|
* Chat-flavoured twin of {@link TestSeedChannelAdapter}: same network-free
|
|
141
159
|
* behaviour, but it declares a non-email `channelType`, so a channel connected
|
|
@@ -145,6 +163,7 @@ class TestSeedChannelAdapter implements ChannelAdapter {
|
|
|
145
163
|
class TestSeedChatChannelAdapter extends TestSeedChannelAdapter {
|
|
146
164
|
readonly providerKey: string = TEST_SEED_CHAT_PROVIDER_KEY
|
|
147
165
|
readonly channelType: string = 'discord'
|
|
166
|
+
readonly capabilities = testSeedChatCapabilities
|
|
148
167
|
|
|
149
168
|
async normalizeInbound(raw: InboundMessage): Promise<NormalizedInboundMessage> {
|
|
150
169
|
// Unlike the email stub, this one is reachable: the test-seed ingest action
|
|
@@ -28,6 +28,7 @@ import { Avatar, AvatarStack } from '@open-mercato/ui/primitives/avatar'
|
|
|
28
28
|
import { Tag } from '@open-mercato/ui/primitives/tag'
|
|
29
29
|
import { SimpleTooltip } from '@open-mercato/ui/primitives/tooltip'
|
|
30
30
|
import { Briefcase, AlertTriangle, X } from 'lucide-react'
|
|
31
|
+
import { isLostDealStatus, isWonDealStatus } from '../../../lib/dealStatus'
|
|
31
32
|
import { formatRelativeTime } from '@open-mercato/shared/lib/time'
|
|
32
33
|
import { ViewTabsRow } from './pipeline/components/ViewTabsRow'
|
|
33
34
|
import { DealsKpiStrip } from '../../../components/DealsKpiStrip'
|
|
@@ -86,7 +87,7 @@ function makeDealsPresets(): FilterPreset[] {
|
|
|
86
87
|
},
|
|
87
88
|
},
|
|
88
89
|
// The Deal entity has no dedicated "at risk" or health-score field — `customer_deals`
|
|
89
|
-
// exposes only `status` (open/win/
|
|
90
|
+
// exposes only `status` (open/win/lost/closed/in_progress, dictionary-driven) and
|
|
90
91
|
// `closure_outcome`. Rather than fabricate a mapping, the "At risk" preset is omitted
|
|
91
92
|
// until the data model exposes a first-class signal.
|
|
92
93
|
{
|
|
@@ -855,11 +856,11 @@ export default function CustomersDealsPage() {
|
|
|
855
856
|
subtitle = (
|
|
856
857
|
<span className="text-xs text-status-error-text">{t('customers.deals.list.close.overdue')}</span>
|
|
857
858
|
)
|
|
858
|
-
} else if (row.original.status
|
|
859
|
+
} else if (isWonDealStatus(row.original.status)) {
|
|
859
860
|
subtitle = (
|
|
860
861
|
<span className="text-xs text-muted-foreground">{t('customers.deals.list.close.won')}</span>
|
|
861
862
|
)
|
|
862
|
-
} else if (row.original.status
|
|
863
|
+
} else if (isLostDealStatus(row.original.status)) {
|
|
863
864
|
subtitle = (
|
|
864
865
|
<span className="text-xs text-muted-foreground">{t('customers.deals.list.close.lost')}</span>
|
|
865
866
|
)
|
package/src/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.tsx
CHANGED
|
@@ -21,11 +21,11 @@ import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
|
|
|
21
21
|
import { registerComponent } from '@open-mercato/shared/modules/widgets/component-registry'
|
|
22
22
|
|
|
23
23
|
// Deal status values written by the kanban flow. These intentionally do NOT include 'closed'
|
|
24
|
-
// because the rest of the app only persists '
|
|
24
|
+
// because the rest of the app only persists 'lost' for lost deals (see StatusFilterPopover
|
|
25
25
|
// notes). 'in_progress' is kept for backwards compatibility with quick-deals created from
|
|
26
|
-
// the previous version of this dialog.
|
|
27
|
-
//
|
|
28
|
-
const STATUS_OPTIONS = ['open', 'in_progress', 'win', '
|
|
26
|
+
// the previous version of this dialog. Deals written before 0.7.1 carry the misspelled
|
|
27
|
+
// 'loose'; readers still match it through `lib/dealStatus.ts`, but no writer produces it.
|
|
28
|
+
const STATUS_OPTIONS = ['open', 'in_progress', 'win', 'lost'] as const
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* Tenant currency option. The page resolves these from the currencies module (see
|
|
@@ -266,7 +266,7 @@ export function QuickDealDialog({
|
|
|
266
266
|
id: 'status',
|
|
267
267
|
label: translateWithFallback(t, 'customers.deals.kanban.quickDeal.status', 'Status'),
|
|
268
268
|
type: 'select',
|
|
269
|
-
// Storage value is preserved (`
|
|
269
|
+
// Storage value is preserved (`lost` etc.), only the displayed label is
|
|
270
270
|
// translated, so we never write a localised string back to the DB.
|
|
271
271
|
options: STATUS_OPTIONS.map((statusValue) => ({
|
|
272
272
|
value: statusValue,
|
package/src/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.tsx
CHANGED
|
@@ -23,13 +23,13 @@ import { FilterPopoverShell } from './FilterPopoverShell'
|
|
|
23
23
|
* Filter options exposed to the operator.
|
|
24
24
|
*
|
|
25
25
|
* The deal `status` column is dictionary-driven (`deal-statuses`); the seeded dictionary
|
|
26
|
-
* carries five values (`open`, `closed`, `win`, `
|
|
26
|
+
* carries five values (`open`, `closed`, `win`, `lost`, `in_progress`) plus any
|
|
27
27
|
* tenant-custom entries. We render every dictionary entry de-duplicated by canonical
|
|
28
28
|
* spelling so the kanban Status pill stays aligned with the list page's advanced filter
|
|
29
29
|
* (which also uses the dictionary). A hard-coded fallback keeps the popover usable while
|
|
30
30
|
* the dictionary is loading or when a tenant has no entries.
|
|
31
31
|
*
|
|
32
|
-
* `won` / `
|
|
32
|
+
* `won` / `loose` are accepted as aliases for `win` / `lost` at the API layer (see
|
|
33
33
|
* `lib/dealStatus.ts:expandDealStatusAliases`, shared by the deals list route and the
|
|
34
34
|
* kanban aggregate route). The UI only exposes the canonical values to avoid duplicate
|
|
35
35
|
* pills, but `canonicalDealStatus` normalizes any alias passed in through `values` so
|
|
@@ -57,7 +57,7 @@ const FALLBACK_STATUS_OPTIONS: Array<{
|
|
|
57
57
|
dotClass: 'bg-status-success-icon',
|
|
58
58
|
},
|
|
59
59
|
{
|
|
60
|
-
value: '
|
|
60
|
+
value: 'lost',
|
|
61
61
|
labelKey: 'customers.deals.kanban.filter.status.lost',
|
|
62
62
|
labelFallback: 'Lost',
|
|
63
63
|
dotClass: 'bg-status-error-icon',
|
|
@@ -1766,7 +1766,7 @@ export default function DealsKanbanPage(): React.ReactElement {
|
|
|
1766
1766
|
}, [invalidateKanbanData, scopeVersion, selectedPipelineId])
|
|
1767
1767
|
|
|
1768
1768
|
const updateDealStatus = React.useCallback(
|
|
1769
|
-
async (dealId: string, status: 'win' | '
|
|
1769
|
+
async (dealId: string, status: 'win' | 'lost') => {
|
|
1770
1770
|
const dealVersion = deals.find((deal) => deal.id === dealId)?.updatedAt ?? null
|
|
1771
1771
|
setPendingDealId(dealId)
|
|
1772
1772
|
try {
|
|
@@ -2348,7 +2348,7 @@ export default function DealsKanbanPage(): React.ReactElement {
|
|
|
2348
2348
|
{
|
|
2349
2349
|
id: 'mark-lost',
|
|
2350
2350
|
label: translateWithFallback(t, 'customers.deals.kanban.menu.markLost', 'Mark as Lost'),
|
|
2351
|
-
onSelect: () => void updateDealStatus(deal.id, '
|
|
2351
|
+
onSelect: () => void updateDealStatus(deal.id, 'lost'),
|
|
2352
2352
|
},
|
|
2353
2353
|
{
|
|
2354
2354
|
id: 'delete',
|