@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.
Files changed (79) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/helpers/integration/crmFixtures.js.map +1 -1
  3. package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js +9 -2
  4. package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js.map +2 -2
  5. package/dist/modules/communication_channels/commands/deliver-outbound-message.js +34 -2
  6. package/dist/modules/communication_channels/commands/deliver-outbound-message.js.map +2 -2
  7. package/dist/modules/communication_channels/lib/outbound-recipient.js +5 -1
  8. package/dist/modules/communication_channels/lib/outbound-recipient.js.map +2 -2
  9. package/dist/modules/communication_channels/lib/outbound-reply-ref.js +49 -0
  10. package/dist/modules/communication_channels/lib/outbound-reply-ref.js.map +7 -0
  11. package/dist/modules/communication_channels/lib/test-seed.js +5 -0
  12. package/dist/modules/communication_channels/lib/test-seed.js.map +2 -2
  13. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.js +1 -1
  14. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.js.map +1 -1
  15. package/dist/modules/customers/backend/customers/deals/page.js +4 -3
  16. package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
  17. package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js +2 -2
  18. package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js.map +2 -2
  19. package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js +1 -1
  20. package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js.map +1 -1
  21. package/dist/modules/customers/backend/customers/deals/pipeline/page.js +1 -1
  22. package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
  23. package/dist/modules/customers/cli.js +5 -5
  24. package/dist/modules/customers/cli.js.map +2 -2
  25. package/dist/modules/customers/commands/deals.js.map +2 -2
  26. package/dist/modules/customers/components/detail/pipelineStageUtils.js +1 -0
  27. package/dist/modules/customers/components/detail/pipelineStageUtils.js.map +2 -2
  28. package/dist/modules/customers/components/linking/adapters/dealAdapter.js +2 -2
  29. package/dist/modules/customers/components/linking/adapters/dealAdapter.js.map +2 -2
  30. package/dist/modules/customers/lib/closureStage.js.map +2 -2
  31. package/dist/modules/customers/lib/dealStatus.js +6 -4
  32. package/dist/modules/customers/lib/dealStatus.js.map +2 -2
  33. package/dist/modules/customers/lib/dealsSummaryQueries.js +3 -3
  34. package/dist/modules/customers/lib/dealsSummaryQueries.js.map +1 -1
  35. package/dist/modules/customers/migrations/Migration20260824180000_deal_status_lost.js +30 -0
  36. package/dist/modules/customers/migrations/Migration20260824180000_deal_status_lost.js.map +7 -0
  37. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/config.js.map +1 -1
  38. package/dist/modules/eudr/notifications.js +5 -0
  39. package/dist/modules/eudr/notifications.js.map +2 -2
  40. package/dist/modules/query_index/lib/search-tokens.js +59 -28
  41. package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
  42. package/dist/modules/sales/commands/documents.js +120 -37
  43. package/dist/modules/sales/commands/documents.js.map +2 -2
  44. package/dist/modules/warranty_claims/notifications.js +5 -0
  45. package/dist/modules/warranty_claims/notifications.js.map +2 -2
  46. package/dist/modules/wms/notifications.js +2 -0
  47. package/dist/modules/wms/notifications.js.map +2 -2
  48. package/package.json +7 -7
  49. package/src/helpers/integration/crmFixtures.ts +1 -1
  50. package/src/modules/communication_channels/api/post/channels/[id]/test-send/route.ts +21 -9
  51. package/src/modules/communication_channels/commands/deliver-outbound-message.ts +45 -1
  52. package/src/modules/communication_channels/lib/adapter.ts +11 -0
  53. package/src/modules/communication_channels/lib/outbound-recipient.ts +14 -1
  54. package/src/modules/communication_channels/lib/outbound-reply-ref.ts +138 -0
  55. package/src/modules/communication_channels/lib/test-seed.ts +19 -0
  56. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.ts +1 -1
  57. package/src/modules/customers/backend/customers/deals/page.tsx +4 -3
  58. package/src/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.tsx +5 -5
  59. package/src/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.tsx +3 -3
  60. package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +2 -2
  61. package/src/modules/customers/cli.ts +5 -5
  62. package/src/modules/customers/commands/deals.ts +4 -3
  63. package/src/modules/customers/components/detail/pipelineStageUtils.ts +1 -0
  64. package/src/modules/customers/components/linking/adapters/dealAdapter.tsx +2 -2
  65. package/src/modules/customers/i18n/de.json +1 -1
  66. package/src/modules/customers/i18n/en.json +1 -1
  67. package/src/modules/customers/i18n/es.json +1 -1
  68. package/src/modules/customers/i18n/ko.json +1 -1
  69. package/src/modules/customers/i18n/pl.json +1 -1
  70. package/src/modules/customers/lib/closureStage.ts +2 -1
  71. package/src/modules/customers/lib/dealStatus.ts +23 -12
  72. package/src/modules/customers/lib/dealsSummaryQueries.ts +3 -3
  73. package/src/modules/customers/migrations/Migration20260824180000_deal_status_lost.ts +55 -0
  74. package/src/modules/dashboards/widgets/dashboard/pipeline-summary/config.ts +1 -1
  75. package/src/modules/eudr/notifications.ts +5 -0
  76. package/src/modules/query_index/lib/search-tokens.ts +100 -43
  77. package/src/modules/sales/commands/documents.ts +153 -39
  78. package/src/modules/warranty_claims/notifications.ts +5 -0
  79. 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.7112.1.d486e6f1b2",
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.7112.1.d486e6f1b2",
256
- "@open-mercato/shared": "0.7.1-develop.7112.1.d486e6f1b2",
257
- "@open-mercato/ui": "0.7.1-develop.7112.1.d486e6f1b2",
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.7112.1.d486e6f1b2",
263
- "@open-mercato/shared": "0.7.1-develop.7112.1.d486e6f1b2",
264
- "@open-mercato/ui": "0.7.1-develop.7112.1.d486e6f1b2",
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', 'loose'); `expectedCloseAt`
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. The recipient shape depends
41
- // on the provider (an email address for Gmail/IMAP, a channel snowflake for
42
- // Discord), and the adapter is only known once the channel is loaded — so the
43
- // schema accepts any non-empty single-line string and `validateOutboundRecipient`
44
- // applies the provider-appropriate rules below, defaulting to email. The CR/LF
45
- // rejection stays at the schema level so a header-injection attempt is refused
46
- // before the request reaches the channel lookup or the mutation guard.
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
- metadata: { to: body.to, subject: body.subject ?? 'Test send', testSend: true },
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
- ...baseMetadata,
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 (capabilities?.recipientFormat !== 'provider-native') {
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
@@ -88,7 +88,7 @@ export function useDealClosure({
88
88
  const lossPayload = {
89
89
  id: currentDealId,
90
90
  closureOutcome: 'lost' as const,
91
- status: 'loose',
91
+ status: 'lost',
92
92
  lossReasonId: input.lossReasonId,
93
93
  ...(input.lossNotes ? { lossNotes: input.lossNotes } : {}),
94
94
  }
@@ -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/loose/closed/in_progress, dictionary-driven) and
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 === 'win') {
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 === 'loose') {
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
  )
@@ -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 'loose' for lost deals (see StatusFilterPopover
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. The labels are looked up via i18n at render time so
27
- // the historic typo 'loose' doesn't leak to operators — the storage value stays unchanged.
28
- const STATUS_OPTIONS = ['open', 'in_progress', 'win', 'loose'] as const
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 (`loose` etc.) — only the displayed label is
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,
@@ -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`, `loose`, `in_progress`) plus any
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` / `lost` are accepted as aliases for `win` / `loose` at the API layer (see
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: 'loose',
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' | 'loose') => {
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, 'loose'),
2351
+ onSelect: () => void updateDealStatus(deal.id, 'lost'),
2352
2352
  },
2353
2353
  {
2354
2354
  id: 'delete',