@open-mercato/core 0.4.5-develop-636d33c995 → 0.4.5-develop-3d8e759e45

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 (211) hide show
  1. package/dist/modules/catalog/backend/catalog/categories/[id]/edit/page.js +17 -2
  2. package/dist/modules/catalog/backend/catalog/categories/[id]/edit/page.js.map +2 -2
  3. package/dist/modules/catalog/backend/catalog/products/[id]/page.js +15 -0
  4. package/dist/modules/catalog/backend/catalog/products/[id]/page.js.map +2 -2
  5. package/dist/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.js +30 -0
  6. package/dist/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.js.map +2 -2
  7. package/dist/modules/catalog/inbox-actions.js +51 -0
  8. package/dist/modules/catalog/inbox-actions.js.map +7 -0
  9. package/dist/modules/catalog/lib/messageObjectPreviews.js +146 -0
  10. package/dist/modules/catalog/lib/messageObjectPreviews.js.map +7 -0
  11. package/dist/modules/catalog/message-objects.js +95 -0
  12. package/dist/modules/catalog/message-objects.js.map +7 -0
  13. package/dist/modules/currencies/backend/currencies/[id]/page.js +21 -0
  14. package/dist/modules/currencies/backend/currencies/[id]/page.js.map +2 -2
  15. package/dist/modules/currencies/lib/messageObjectPreviews.js +51 -0
  16. package/dist/modules/currencies/lib/messageObjectPreviews.js.map +7 -0
  17. package/dist/modules/currencies/message-objects.js +41 -0
  18. package/dist/modules/currencies/message-objects.js.map +7 -0
  19. package/dist/modules/customers/backend/customers/companies/[id]/page.js +20 -0
  20. package/dist/modules/customers/backend/customers/companies/[id]/page.js.map +2 -2
  21. package/dist/modules/customers/backend/customers/deals/[id]/page.js +12 -1
  22. package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
  23. package/dist/modules/customers/backend/customers/people/[id]/page.js +20 -0
  24. package/dist/modules/customers/backend/customers/people/[id]/page.js.map +2 -2
  25. package/dist/modules/customers/components/detail/CompanyHighlights.js +18 -14
  26. package/dist/modules/customers/components/detail/CompanyHighlights.js.map +2 -2
  27. package/dist/modules/customers/components/detail/PersonHighlights.js +18 -14
  28. package/dist/modules/customers/components/detail/PersonHighlights.js.map +2 -2
  29. package/dist/modules/customers/inbox-actions.js +230 -0
  30. package/dist/modules/customers/inbox-actions.js.map +7 -0
  31. package/dist/modules/customers/lib/messageObjectPreviews.js +41 -5
  32. package/dist/modules/customers/lib/messageObjectPreviews.js.map +2 -2
  33. package/dist/modules/customers/message-objects.js +31 -11
  34. package/dist/modules/customers/message-objects.js.map +2 -2
  35. package/dist/modules/inbox_ops/api/emails/[id]/route.js +40 -1
  36. package/dist/modules/inbox_ops/api/emails/[id]/route.js.map +2 -2
  37. package/dist/modules/inbox_ops/api/extract/route.js +87 -0
  38. package/dist/modules/inbox_ops/api/extract/route.js.map +7 -0
  39. package/dist/modules/inbox_ops/api/proposals/[id]/translate/route.js +6 -1
  40. package/dist/modules/inbox_ops/api/proposals/[id]/translate/route.js.map +2 -2
  41. package/dist/modules/inbox_ops/api/proposals/counts/route.js.map +2 -2
  42. package/dist/modules/inbox_ops/backend/inbox-ops/log/page.js +40 -14
  43. package/dist/modules/inbox_ops/backend/inbox-ops/log/page.js.map +2 -2
  44. package/dist/modules/inbox_ops/backend/inbox-ops/log/page.meta.js +2 -2
  45. package/dist/modules/inbox_ops/backend/inbox-ops/log/page.meta.js.map +2 -2
  46. package/dist/modules/inbox_ops/backend/inbox-ops/page.js +161 -79
  47. package/dist/modules/inbox_ops/backend/inbox-ops/page.js.map +2 -2
  48. package/dist/modules/inbox_ops/backend/inbox-ops/page.meta.js +2 -2
  49. package/dist/modules/inbox_ops/backend/inbox-ops/page.meta.js.map +2 -2
  50. package/dist/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.js +109 -62
  51. package/dist/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.js.map +3 -3
  52. package/dist/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.meta.js +2 -2
  53. package/dist/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.meta.js.map +2 -2
  54. package/dist/modules/inbox_ops/backend/inbox-ops/settings/page.js +36 -14
  55. package/dist/modules/inbox_ops/backend/inbox-ops/settings/page.js.map +2 -2
  56. package/dist/modules/inbox_ops/backend/inbox-ops/settings/page.meta.js +2 -2
  57. package/dist/modules/inbox_ops/backend/inbox-ops/settings/page.meta.js.map +2 -2
  58. package/dist/modules/inbox_ops/components/proposals/ActionCard.js +65 -10
  59. package/dist/modules/inbox_ops/components/proposals/ActionCard.js.map +2 -2
  60. package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js +58 -10
  61. package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js.map +2 -2
  62. package/dist/modules/inbox_ops/lib/constants.js.map +2 -2
  63. package/dist/modules/inbox_ops/lib/contactValidation.js +40 -0
  64. package/dist/modules/inbox_ops/lib/contactValidation.js.map +7 -0
  65. package/dist/modules/inbox_ops/lib/executionEngine.js +31 -826
  66. package/dist/modules/inbox_ops/lib/executionEngine.js.map +3 -3
  67. package/dist/modules/inbox_ops/lib/executionHelpers.js +368 -0
  68. package/dist/modules/inbox_ops/lib/executionHelpers.js.map +7 -0
  69. package/dist/modules/inbox_ops/lib/extractionPrompt.js +28 -35
  70. package/dist/modules/inbox_ops/lib/extractionPrompt.js.map +3 -3
  71. package/dist/modules/inbox_ops/lib/inbox-actions-generated.d.js +1 -0
  72. package/dist/modules/inbox_ops/lib/inbox-actions-generated.d.js.map +7 -0
  73. package/dist/modules/inbox_ops/lib/translationProvider.js +15 -10
  74. package/dist/modules/inbox_ops/lib/translationProvider.js.map +2 -2
  75. package/dist/modules/inbox_ops/subscribers/extractionWorker.js +16 -16
  76. package/dist/modules/inbox_ops/subscribers/extractionWorker.js.map +2 -2
  77. package/dist/modules/messages/commands/messages.js +3 -0
  78. package/dist/modules/messages/commands/messages.js.map +2 -2
  79. package/dist/modules/messages/components/message-detail/panels/objects-panel.js +6 -1
  80. package/dist/modules/messages/components/message-detail/panels/objects-panel.js.map +2 -2
  81. package/dist/modules/messages/components/message-detail/panels/thread-panel.js +4 -1
  82. package/dist/modules/messages/components/message-detail/panels/thread-panel.js.map +2 -2
  83. package/dist/modules/messages/frontend/messages/view/[token]/page.js +1 -0
  84. package/dist/modules/messages/frontend/messages/view/[token]/page.js.map +2 -2
  85. package/dist/modules/resources/backend/resources/resources/[id]/page.js +24 -7
  86. package/dist/modules/resources/backend/resources/resources/[id]/page.js.map +2 -2
  87. package/dist/modules/resources/lib/messageObjectPreviews.js +43 -0
  88. package/dist/modules/resources/lib/messageObjectPreviews.js.map +7 -0
  89. package/dist/modules/resources/message-objects.js +37 -0
  90. package/dist/modules/resources/message-objects.js.map +7 -0
  91. package/dist/modules/sales/backend/sales/channels/[channelId]/edit/page.js +19 -0
  92. package/dist/modules/sales/backend/sales/channels/[channelId]/edit/page.js.map +2 -2
  93. package/dist/modules/sales/backend/sales/documents/[id]/page.js +23 -2
  94. package/dist/modules/sales/backend/sales/documents/[id]/page.js.map +2 -2
  95. package/dist/modules/sales/backend/sales/quotes/[id]/page.js +1 -1
  96. package/dist/modules/sales/backend/sales/quotes/[id]/page.js.map +2 -2
  97. package/dist/modules/sales/inbox-actions.js +278 -0
  98. package/dist/modules/sales/inbox-actions.js.map +7 -0
  99. package/dist/modules/sales/lib/messageObjectPreviews.js +49 -4
  100. package/dist/modules/sales/lib/messageObjectPreviews.js.map +2 -2
  101. package/dist/modules/sales/message-objects.js +44 -2
  102. package/dist/modules/sales/message-objects.js.map +2 -2
  103. package/dist/modules/sales/widgets/messages/SalesDocumentMessageDetail.js +59 -30
  104. package/dist/modules/sales/widgets/messages/SalesDocumentMessageDetail.js.map +2 -2
  105. package/dist/modules/sales/widgets/messages/SalesDocumentMessagePreview.js +1 -1
  106. package/dist/modules/sales/widgets/messages/SalesDocumentMessagePreview.js.map +1 -1
  107. package/dist/modules/staff/backend/staff/leave-requests/[id]/page.js +8 -30
  108. package/dist/modules/staff/backend/staff/leave-requests/[id]/page.js.map +2 -2
  109. package/dist/modules/staff/backend/staff/my-availability/page.js +13 -0
  110. package/dist/modules/staff/backend/staff/my-availability/page.js.map +2 -2
  111. package/dist/modules/staff/backend/staff/my-leave-requests/[id]/page.js +8 -31
  112. package/dist/modules/staff/backend/staff/my-leave-requests/[id]/page.js.map +2 -2
  113. package/dist/modules/staff/backend/staff/team-members/[id]/page.js +32 -10
  114. package/dist/modules/staff/backend/staff/team-members/[id]/page.js.map +2 -2
  115. package/dist/modules/staff/backend/staff/team-roles/[id]/edit/page.js +14 -1
  116. package/dist/modules/staff/backend/staff/team-roles/[id]/edit/page.js.map +2 -2
  117. package/dist/modules/staff/backend/staff/teams/[id]/edit/page.js +14 -1
  118. package/dist/modules/staff/backend/staff/teams/[id]/edit/page.js.map +2 -2
  119. package/dist/modules/staff/components/TeamForm.js +4 -2
  120. package/dist/modules/staff/components/TeamForm.js.map +2 -2
  121. package/dist/modules/staff/components/TeamRoleForm.js +4 -2
  122. package/dist/modules/staff/components/TeamRoleForm.js.map +2 -2
  123. package/dist/modules/staff/lib/messageObjectPreviews.js +111 -2
  124. package/dist/modules/staff/lib/messageObjectPreviews.js.map +2 -2
  125. package/dist/modules/staff/message-objects.js +79 -8
  126. package/dist/modules/staff/message-objects.js.map +2 -2
  127. package/jest.config.cjs +1 -0
  128. package/jest.mocks/inbox-actions.generated.js +5 -0
  129. package/package.json +2 -2
  130. package/src/modules/catalog/backend/catalog/categories/[id]/edit/page.tsx +19 -5
  131. package/src/modules/catalog/backend/catalog/products/[id]/page.tsx +14 -0
  132. package/src/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.tsx +40 -0
  133. package/src/modules/catalog/inbox-actions.ts +60 -0
  134. package/src/modules/catalog/lib/messageObjectPreviews.ts +176 -0
  135. package/src/modules/catalog/message-objects.ts +102 -0
  136. package/src/modules/currencies/backend/currencies/[id]/page.tsx +20 -0
  137. package/src/modules/currencies/lib/messageObjectPreviews.ts +65 -0
  138. package/src/modules/currencies/message-objects.ts +40 -0
  139. package/src/modules/customers/backend/customers/companies/[id]/page.tsx +19 -0
  140. package/src/modules/customers/backend/customers/deals/[id]/page.tsx +13 -0
  141. package/src/modules/customers/backend/customers/people/[id]/page.tsx +19 -0
  142. package/src/modules/customers/components/detail/CompanyHighlights.tsx +14 -9
  143. package/src/modules/customers/components/detail/PersonHighlights.tsx +14 -9
  144. package/src/modules/customers/inbox-actions.ts +285 -0
  145. package/src/modules/customers/lib/messageObjectPreviews.ts +43 -3
  146. package/src/modules/customers/message-objects.ts +31 -11
  147. package/src/modules/inbox_ops/api/emails/[id]/route.ts +44 -0
  148. package/src/modules/inbox_ops/api/extract/route.ts +94 -0
  149. package/src/modules/inbox_ops/api/proposals/[id]/translate/route.ts +6 -1
  150. package/src/modules/inbox_ops/api/proposals/counts/route.ts +2 -0
  151. package/src/modules/inbox_ops/backend/inbox-ops/log/page.meta.ts +2 -2
  152. package/src/modules/inbox_ops/backend/inbox-ops/log/page.tsx +43 -13
  153. package/src/modules/inbox_ops/backend/inbox-ops/page.meta.ts +2 -2
  154. package/src/modules/inbox_ops/backend/inbox-ops/page.tsx +176 -81
  155. package/src/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.meta.ts +2 -2
  156. package/src/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.tsx +122 -68
  157. package/src/modules/inbox_ops/backend/inbox-ops/settings/page.meta.ts +2 -2
  158. package/src/modules/inbox_ops/backend/inbox-ops/settings/page.tsx +36 -14
  159. package/src/modules/inbox_ops/components/proposals/ActionCard.tsx +91 -7
  160. package/src/modules/inbox_ops/components/proposals/EditActionDialog.tsx +64 -12
  161. package/src/modules/inbox_ops/lib/constants.ts +9 -0
  162. package/src/modules/inbox_ops/lib/contactValidation.ts +54 -0
  163. package/src/modules/inbox_ops/lib/executionEngine.ts +47 -1060
  164. package/src/modules/inbox_ops/lib/executionHelpers.ts +527 -0
  165. package/src/modules/inbox_ops/lib/extractionPrompt.ts +45 -34
  166. package/src/modules/inbox_ops/lib/inbox-actions-generated.d.ts +11 -0
  167. package/src/modules/inbox_ops/lib/translationProvider.ts +16 -10
  168. package/src/modules/inbox_ops/subscribers/extractionWorker.ts +16 -18
  169. package/src/modules/messages/commands/messages.ts +4 -0
  170. package/src/modules/messages/components/message-detail/panels/objects-panel.tsx +8 -1
  171. package/src/modules/messages/components/message-detail/panels/thread-panel.tsx +3 -0
  172. package/src/modules/messages/frontend/messages/view/[token]/page.tsx +1 -0
  173. package/src/modules/resources/backend/resources/resources/[id]/page.tsx +20 -4
  174. package/src/modules/resources/lib/messageObjectPreviews.ts +55 -0
  175. package/src/modules/resources/message-objects.ts +36 -0
  176. package/src/modules/sales/backend/sales/channels/[channelId]/edit/page.tsx +18 -0
  177. package/src/modules/sales/backend/sales/documents/[id]/page.tsx +23 -0
  178. package/src/modules/sales/backend/sales/quotes/[id]/page.tsx +1 -1
  179. package/src/modules/sales/inbox-actions.ts +359 -0
  180. package/src/modules/sales/lib/messageObjectPreviews.ts +54 -4
  181. package/src/modules/sales/message-objects.ts +44 -2
  182. package/src/modules/sales/widgets/messages/SalesDocumentMessageDetail.tsx +72 -34
  183. package/src/modules/sales/widgets/messages/SalesDocumentMessagePreview.tsx +1 -1
  184. package/src/modules/staff/backend/staff/leave-requests/[id]/page.tsx +7 -29
  185. package/src/modules/staff/backend/staff/my-availability/page.tsx +14 -0
  186. package/src/modules/staff/backend/staff/my-leave-requests/[id]/page.tsx +8 -30
  187. package/src/modules/staff/backend/staff/team-members/[id]/page.tsx +28 -7
  188. package/src/modules/staff/backend/staff/team-roles/[id]/edit/page.tsx +12 -0
  189. package/src/modules/staff/backend/staff/teams/[id]/edit/page.tsx +12 -0
  190. package/src/modules/staff/components/TeamForm.tsx +3 -0
  191. package/src/modules/staff/components/TeamRoleForm.tsx +3 -0
  192. package/src/modules/staff/lib/messageObjectPreviews.ts +133 -2
  193. package/src/modules/staff/message-objects.ts +79 -8
  194. package/dist/modules/customers/widgets/messages/CustomerMessageObjectDetail.js +0 -51
  195. package/dist/modules/customers/widgets/messages/CustomerMessageObjectDetail.js.map +0 -7
  196. package/dist/modules/customers/widgets/messages/CustomerMessageObjectPreview.js +0 -35
  197. package/dist/modules/customers/widgets/messages/CustomerMessageObjectPreview.js.map +0 -7
  198. package/dist/modules/customers/widgets/messages/index.js +0 -7
  199. package/dist/modules/customers/widgets/messages/index.js.map +0 -7
  200. package/dist/modules/staff/widgets/messages/StaffMessageObjectDetail.js +0 -51
  201. package/dist/modules/staff/widgets/messages/StaffMessageObjectDetail.js.map +0 -7
  202. package/dist/modules/staff/widgets/messages/StaffMessageObjectPreview.js +0 -34
  203. package/dist/modules/staff/widgets/messages/StaffMessageObjectPreview.js.map +0 -7
  204. package/dist/modules/staff/widgets/messages/index.js +0 -7
  205. package/dist/modules/staff/widgets/messages/index.js.map +0 -7
  206. package/src/modules/customers/widgets/messages/CustomerMessageObjectDetail.tsx +0 -57
  207. package/src/modules/customers/widgets/messages/CustomerMessageObjectPreview.tsx +0 -49
  208. package/src/modules/customers/widgets/messages/index.ts +0 -2
  209. package/src/modules/staff/widgets/messages/StaffMessageObjectDetail.tsx +0 -57
  210. package/src/modules/staff/widgets/messages/StaffMessageObjectPreview.tsx +0 -44
  211. package/src/modules/staff/widgets/messages/index.ts +0 -2
@@ -3,30 +3,13 @@ import type { EntityClass } from '@mikro-orm/core'
3
3
  import type { AwilixContainer } from 'awilix'
4
4
  import type { EventBus } from '@open-mercato/events/types'
5
5
  import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
6
- import type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
7
6
  import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
7
+ import type { InboxActionExecutionContext } from '@open-mercato/shared/modules/inbox-actions'
8
8
  import { InboxProposal, InboxProposalAction, InboxDiscrepancy } from '../data/entities'
9
9
  import type { InboxActionStatus, InboxActionType, InboxProposalStatus } from '../data/entities'
10
- import {
11
- createContactPayloadSchema,
12
- createProductPayloadSchema,
13
- draftReplyPayloadSchema,
14
- linkContactPayloadSchema,
15
- logActivityPayloadSchema,
16
- orderPayloadSchema,
17
- updateOrderPayloadSchema,
18
- updateShipmentPayloadSchema,
19
- type CreateContactPayload,
20
- type CreateProductPayload,
21
- type DraftReplyPayload,
22
- type LinkContactPayload,
23
- type LogActivityPayload,
24
- type OrderPayload,
25
- type UpdateOrderPayload,
26
- type UpdateShipmentPayload,
27
- } from '../data/validators'
28
10
  import { REQUIRED_FEATURES_MAP } from './constants'
29
11
  import { formatZodErrors } from './validation'
12
+ import { ExecutionError, executeCommand } from './executionHelpers'
30
13
 
31
14
  interface CommonEntityFields {
32
15
  tenantId?: string
@@ -70,16 +53,6 @@ interface TypeExecutionResult {
70
53
  matchedEntityType?: string | null
71
54
  }
72
55
 
73
- class ExecutionError extends Error {
74
- statusCode: number
75
-
76
- constructor(message: string, statusCode = 400) {
77
- super(message)
78
- this.statusCode = statusCode
79
- }
80
- }
81
-
82
- const SALES_SHIPMENT_STATUS_DICTIONARY_KEY = 'sales.shipment_status'
83
56
  const ACTION_EXECUTABLE_STATUSES: InboxActionStatus[] = ['pending', 'failed']
84
57
 
85
58
  export async function executeAction(
@@ -332,16 +305,12 @@ export async function acceptAllActions(
332
305
  }
333
306
 
334
307
  /**
335
- * Normalize LLM-generated payloads before validation.
336
- * Fixes common issues: case-sensitive enums, missing fields that can be resolved,
337
- * and alternate field names the LLM might use.
308
+ * Normalize common LLM payload issues before action-specific normalization.
338
309
  */
339
- async function normalizePayload(
340
- action: InboxProposalAction,
341
- ctx: ExecutionContext,
342
- ): Promise<Record<string, unknown>> {
343
- const payload = { ...(action.payload as Record<string, unknown>) }
344
-
310
+ function normalizeCommonPayloadFields(
311
+ payload: Record<string, unknown>,
312
+ actionType: string,
313
+ ): Record<string, unknown> {
345
314
  // Lowercase contact type fields (LLM often outputs "Person" / "Company")
346
315
  if (typeof payload.type === 'string') {
347
316
  payload.type = payload.type.toLowerCase()
@@ -350,9 +319,8 @@ async function normalizePayload(
350
319
  payload.contactType = payload.contactType.toLowerCase()
351
320
  }
352
321
 
353
- // Normalize link_contact field names (LLM may use various alternatives for
354
- // emailAddress/contactId/contactType/contactName e.g. from the pre-matched contacts format)
355
- if (action.actionType === 'link_contact') {
322
+ // Normalize link_contact field names (LLM may use various alternatives)
323
+ if (actionType === 'link_contact') {
356
324
  if (!payload.emailAddress) {
357
325
  const alt = payload.email ?? payload.contactEmail
358
326
  if (typeof alt === 'string') payload.emailAddress = alt
@@ -371,447 +339,57 @@ async function normalizePayload(
371
339
  }
372
340
  }
373
341
 
374
- // Resolve missing currencyCode for order/quote payloads from channel
375
- if (action.actionType === 'create_order' || action.actionType === 'create_quote') {
376
- if (!payload.currencyCode) {
377
- const channelId = typeof payload.channelId === 'string' ? payload.channelId : null
378
- const resolved = await resolveChannelCurrency(ctx, channelId)
379
- if (resolved) payload.currencyCode = resolved
380
- }
381
- }
382
-
383
342
  return payload
384
343
  }
385
344
 
386
- async function executeByType(
387
- action: InboxProposalAction,
388
- ctx: ExecutionContext,
389
- ): Promise<TypeExecutionResult> {
390
- const payload = await normalizePayload(action, ctx)
391
-
392
- switch (action.actionType) {
393
- case 'create_order': {
394
- const parsed = orderPayloadSchema.safeParse(payload)
395
- if (!parsed.success) throw new ExecutionError(`Invalid create_order payload: ${formatZodErrors(parsed.error)}`, 400)
396
- return executeCreateDocumentAction(action, parsed.data, ctx, 'order')
397
- }
398
- case 'create_quote': {
399
- const parsed = orderPayloadSchema.safeParse(payload)
400
- if (!parsed.success) throw new ExecutionError(`Invalid create_quote payload: ${formatZodErrors(parsed.error)}`, 400)
401
- return executeCreateDocumentAction(action, parsed.data, ctx, 'quote')
402
- }
403
- case 'update_order': {
404
- const parsed = updateOrderPayloadSchema.safeParse(payload)
405
- if (!parsed.success) throw new ExecutionError(`Invalid update_order payload: ${formatZodErrors(parsed.error)}`, 400)
406
- return executeUpdateOrderAction(parsed.data, ctx)
407
- }
408
- case 'update_shipment': {
409
- const parsed = updateShipmentPayloadSchema.safeParse(payload)
410
- if (!parsed.success) throw new ExecutionError(`Invalid update_shipment payload: ${formatZodErrors(parsed.error)}`, 400)
411
- return executeUpdateShipmentAction(parsed.data, ctx)
412
- }
413
- case 'create_contact': {
414
- const parsed = createContactPayloadSchema.safeParse(payload)
415
- if (!parsed.success) throw new ExecutionError(`Invalid create_contact payload: ${formatZodErrors(parsed.error)}`, 400)
416
- return executeCreateContactAction(parsed.data, ctx)
417
- }
418
- case 'create_product': {
419
- const parsed = createProductPayloadSchema.safeParse(payload)
420
- if (!parsed.success) throw new ExecutionError(`Invalid create_product payload: ${formatZodErrors(parsed.error)}`, 400)
421
- return executeCreateProductAction(action, parsed.data, ctx)
422
- }
423
- case 'link_contact': {
424
- const parsed = linkContactPayloadSchema.safeParse(payload)
425
- if (!parsed.success) throw new ExecutionError(`Invalid link_contact payload: ${formatZodErrors(parsed.error)}`, 400)
426
- return executeLinkContactAction(parsed.data)
427
- }
428
- case 'log_activity': {
429
- const parsed = logActivityPayloadSchema.safeParse(payload)
430
- if (!parsed.success) throw new ExecutionError(`Invalid log_activity payload: ${formatZodErrors(parsed.error)}`, 400)
431
- return executeLogActivityAction(parsed.data, ctx)
432
- }
433
- case 'draft_reply': {
434
- const parsed = draftReplyPayloadSchema.safeParse(payload)
435
- if (!parsed.success) throw new ExecutionError(`Invalid draft_reply payload: ${formatZodErrors(parsed.error)}`, 400)
436
- return executeDraftReplyAction(action, parsed.data, ctx)
437
- }
438
- default:
439
- throw new ExecutionError(`Unknown action type: ${action.actionType}`, 400)
440
- }
441
- }
442
-
443
- async function executeCreateDocumentAction(
444
- action: InboxProposalAction,
445
- payload: OrderPayload,
446
- ctx: ExecutionContext,
447
- kind: 'order' | 'quote',
448
- ): Promise<TypeExecutionResult> {
449
- // Resolve channelId if not provided
450
- let resolvedChannelId: string | undefined = payload.channelId
451
- if (!resolvedChannelId) {
452
- resolvedChannelId = (await resolveFirstChannelId(ctx)) ?? undefined
453
- if (!resolvedChannelId) {
454
- throw new ExecutionError('No sales channel available. Create a channel first or set channelId in the payload.', 400)
455
- }
456
- }
457
-
458
- const currencyCode = payload.currencyCode.trim().toUpperCase()
459
- const lines = payload.lineItems.map((line, index) => {
460
- const quantity = parseNumberToken(line.quantity, `lineItems[${index}].quantity`)
461
- const unitPrice = line.unitPrice
462
- ? parseNumberToken(line.unitPrice, `lineItems[${index}].unitPrice`)
463
- : undefined
464
-
465
- const mappedLine: Record<string, unknown> = {
466
- lineNumber: index + 1,
467
- kind: line.kind ?? (line.productId ? 'product' : 'service'),
468
- name: line.productName,
469
- description: line.description,
470
- quantity,
471
- currencyCode,
472
- }
473
-
474
- if (line.productId) mappedLine.productId = line.productId
475
- if (line.variantId) mappedLine.productVariantId = line.variantId
476
- if (unitPrice !== undefined) mappedLine.unitPriceNet = unitPrice
477
- if (line.sku || line.catalogPrice) {
478
- mappedLine.catalogSnapshot = {
479
- sku: line.sku ?? null,
480
- catalogPrice: line.catalogPrice ?? null,
481
- }
482
- }
483
-
484
- return mappedLine
485
- })
486
-
487
- const metadata = buildSourceMetadata(action.id, action.proposalId)
488
-
489
- // Resolve customerEntityId: use explicit ID, or look up by email (contact may
490
- // have been created by a prior action in the same proposal batch)
491
- let resolvedCustomerEntityId = payload.customerEntityId
492
- if (!resolvedCustomerEntityId && payload.customerEmail) {
493
- resolvedCustomerEntityId = (await resolveCustomerEntityIdByEmail(ctx, payload.customerEmail)) ?? undefined
494
- }
495
-
496
- const createInput: Record<string, unknown> = {
497
- organizationId: ctx.organizationId,
498
- tenantId: ctx.tenantId,
499
- customerEntityId: resolvedCustomerEntityId,
500
- customerReference: payload.customerReference,
501
- channelId: resolvedChannelId,
502
- currencyCode,
503
- taxRateId: payload.taxRateId,
504
- comments: payload.notes,
505
- metadata,
506
- lines,
507
- }
508
-
509
- // Only provide a manual customerSnapshot when no entity could be resolved.
510
- // When customerEntityId is set, the sales command builds the proper nested
511
- // snapshot ({ customer: {...}, contact: {...} }) from the entity itself.
512
- if (!resolvedCustomerEntityId) {
513
- createInput.customerSnapshot = {
514
- displayName: payload.customerName,
515
- ...(payload.customerEmail && { primaryEmail: payload.customerEmail }),
516
- }
517
- }
518
-
519
- // Address resolution: explicit address from email > addressId from CRM enrichment
520
- const normalizedBilling = payload.billingAddress
521
- ? normalizeAddressSnapshot(payload.billingAddress)
522
- : undefined
523
- const normalizedShipping = payload.shippingAddress
524
- ? normalizeAddressSnapshot(payload.shippingAddress)
525
- : undefined
526
-
527
- if (normalizedShipping || normalizedBilling) {
528
- createInput.shippingAddressSnapshot = normalizedShipping ?? normalizedBilling
529
- createInput.billingAddressSnapshot = normalizedBilling ?? normalizedShipping
530
- } else if (payload.billingAddressId || payload.shippingAddressId) {
531
- createInput.billingAddressId = payload.billingAddressId ?? payload.shippingAddressId
532
- createInput.shippingAddressId = payload.shippingAddressId ?? payload.billingAddressId
533
- }
534
-
535
- const requestedDeliveryAt = parseDateToken(payload.requestedDeliveryDate ?? undefined)
536
- if (requestedDeliveryAt) {
537
- createInput.expectedDeliveryAt = requestedDeliveryAt
538
- }
539
-
540
- const effectiveKind = kind === 'order'
541
- ? await resolveEffectiveDocumentKind(ctx, resolvedChannelId)
542
- : kind
543
-
544
- if (effectiveKind === 'order') {
545
- const result = await executeCommand<Record<string, unknown>, { orderId?: string }>(
546
- ctx,
547
- 'sales.orders.create',
548
- createInput,
549
- )
550
- if (!result.orderId) {
551
- throw new ExecutionError('Order creation did not return an order ID', 500)
552
- }
553
- return {
554
- createdEntityId: result.orderId,
555
- createdEntityType: 'sales_order',
556
- }
557
- }
558
-
559
- const result = await executeCommand<Record<string, unknown>, { quoteId?: string }>(
560
- ctx,
561
- 'sales.quotes.create',
562
- createInput,
563
- )
564
- if (!result.quoteId) {
565
- throw new ExecutionError('Quote creation did not return a quote ID', 500)
566
- }
345
+ /**
346
+ * Adapt the internal ExecutionContext to the shared InboxActionExecutionContext
347
+ * for use by registered action handlers.
348
+ */
349
+ function adaptContext(ctx: ExecutionContext): InboxActionExecutionContext {
567
350
  return {
568
- createdEntityId: result.quoteId,
569
- createdEntityType: 'sales_quote',
351
+ ...ctx,
352
+ executeCommand: <TInput, TResult>(commandId: string, input: TInput) =>
353
+ executeCommand<TInput, TResult>(ctx, commandId, input),
354
+ resolveEntityClass: <T>(key: string) =>
355
+ resolveEntityClassInternal(ctx, key) as (new (...args: unknown[]) => T) | null,
570
356
  }
571
357
  }
572
358
 
573
- async function executeUpdateOrderAction(
574
- payload: UpdateOrderPayload,
359
+ async function executeByType(
360
+ action: InboxProposalAction,
575
361
  ctx: ExecutionContext,
576
362
  ): Promise<TypeExecutionResult> {
577
- const order = await resolveOrderByReference(
578
- ctx,
579
- payload.orderId,
580
- payload.orderNumber,
581
- )
582
-
583
- const updateInput: Record<string, unknown> = {
584
- id: order.id,
585
- organizationId: ctx.organizationId,
586
- tenantId: ctx.tenantId,
587
- }
588
-
589
- const newDeliveryDate = parseDateToken(payload.deliveryDateChange?.newDate)
590
- if (newDeliveryDate) {
591
- updateInput.expectedDeliveryAt = newDeliveryDate
592
- }
593
-
594
- const noteLines = payload.noteAdditions?.map((note) => note.trim()).filter((note) => note.length > 0) ?? []
595
- if (noteLines.length > 0) {
596
- const mergedNotes = [order.comments ?? null, ...noteLines].filter(Boolean).join('\n')
597
- updateInput.comments = mergedNotes
598
- }
599
-
600
- if (Object.keys(updateInput).length > 3) {
601
- await executeCommand<Record<string, unknown>, { orderId?: string }>(
602
- ctx,
603
- 'sales.orders.update',
604
- updateInput,
605
- )
606
- }
607
-
608
- const quantityChanges = payload.quantityChanges ?? []
609
- const orderLines = quantityChanges.length > 0 && quantityChanges.some((qc) => !qc.lineItemId)
610
- ? await loadOrderLineItems(ctx, order.id)
611
- : []
612
-
613
- for (const quantityChange of quantityChanges) {
614
- let lineItemId = quantityChange.lineItemId
615
- if (!lineItemId) {
616
- const matched = matchLineItemByName(orderLines, quantityChange.lineItemName)
617
- if (matched) {
618
- lineItemId = matched
619
- } else {
620
- const availableNames = orderLines.map((l) => l.name).filter(Boolean).join(', ')
621
- throw new ExecutionError(
622
- `Cannot resolve line item "${quantityChange.lineItemName}". Available line items: ${availableNames || 'none'}`,
623
- 400,
624
- )
625
- }
626
- }
627
-
628
- await executeCommand<{ body: Record<string, unknown> }, { orderId?: string; lineId?: string }>(
629
- ctx,
630
- 'sales.orders.lines.upsert',
631
- {
632
- body: {
633
- id: lineItemId,
634
- orderId: order.id,
635
- organizationId: ctx.organizationId,
636
- tenantId: ctx.tenantId,
637
- quantity: parseNumberToken(quantityChange.newQuantity, 'quantityChanges.newQuantity'),
638
- currencyCode: order.currencyCode,
639
- },
640
- },
641
- )
363
+ // Lazy-load the generated registry to avoid circular imports at module load time
364
+ const { getInboxAction } = await import('@/.mercato/generated/inbox-actions.generated')
365
+ const definition = getInboxAction(action.actionType)
366
+ if (!definition) {
367
+ throw new ExecutionError(`Unknown action type: ${action.actionType}`, 400)
642
368
  }
643
369
 
644
- return {
645
- createdEntityId: order.id,
646
- createdEntityType: 'sales_order',
647
- }
648
- }
370
+ let payload = { ...(action.payload as Record<string, unknown>) }
649
371
 
650
- async function executeUpdateShipmentAction(
651
- payload: UpdateShipmentPayload,
652
- ctx: ExecutionContext,
653
- ): Promise<TypeExecutionResult> {
654
- const order = await resolveOrderByReference(
655
- ctx,
656
- payload.orderId,
657
- payload.orderNumber,
658
- )
372
+ // Common normalization (lowercase enums, field aliases)
373
+ payload = normalizeCommonPayloadFields(payload, action.actionType)
659
374
 
660
- const SalesShipmentClass = resolveEntityClass(ctx, 'SalesShipment')
661
- if (!SalesShipmentClass) {
662
- throw new ExecutionError('Sales module entities not available', 503)
375
+ // Action-specific normalization from the registered handler
376
+ const actionCtx = adaptContext(ctx)
377
+ if (definition.normalizePayload) {
378
+ payload = await definition.normalizePayload(payload, actionCtx)
663
379
  }
664
380
 
665
- const shipment = await findOneWithDecryption(
666
- ctx.em,
667
- SalesShipmentClass,
668
- {
669
- order: order.id,
670
- tenantId: ctx.tenantId,
671
- organizationId: ctx.organizationId,
672
- deletedAt: null,
673
- },
674
- { orderBy: { createdAt: 'DESC' } },
675
- { tenantId: ctx.tenantId, organizationId: ctx.organizationId },
676
- )
677
-
678
- if (!shipment) {
679
- throw new ExecutionError('No shipment found for the referenced order', 404)
680
- }
681
-
682
- const statusEntryId = await resolveShipmentStatusEntryId(
683
- ctx,
684
- payload.statusLabel,
685
- )
686
- if (!statusEntryId) {
687
- throw new ExecutionError(`Shipment status "${payload.statusLabel}" not found`, 400)
688
- }
689
-
690
- const updateInput: Record<string, unknown> = {
691
- id: shipment.id,
692
- orderId: order.id,
693
- organizationId: ctx.organizationId,
694
- tenantId: ctx.tenantId,
695
- statusEntryId,
696
- }
697
-
698
- if (payload.trackingNumbers) updateInput.trackingNumbers = payload.trackingNumbers
699
- if (payload.carrierName) updateInput.carrierName = payload.carrierName
700
- if (payload.notes) updateInput.notes = payload.notes
701
-
702
- const shippedAt = parseDateToken(payload.shippedAt)
703
- const deliveredAt = parseDateToken(payload.deliveredAt)
704
- if (shippedAt) updateInput.shippedAt = shippedAt
705
- if (deliveredAt) updateInput.deliveredAt = deliveredAt
706
-
707
- await executeCommand<Record<string, unknown>, { shipmentId?: string }>(
708
- ctx,
709
- 'sales.shipments.update',
710
- updateInput,
711
- )
712
-
713
- return {
714
- createdEntityId: shipment.id,
715
- createdEntityType: 'sales_shipment',
716
- }
717
- }
718
-
719
- async function executeCreateContactAction(
720
- payload: CreateContactPayload,
721
- ctx: ExecutionContext,
722
- ): Promise<TypeExecutionResult> {
723
- const CustomerEntityClass = resolveEntityClass(ctx, 'CustomerEntity')
724
- if (payload.email && CustomerEntityClass) {
725
- const emailLower = payload.email.trim().toLowerCase()
726
- // Try direct DB lookup first (works when primaryEmail is not encrypted)
727
- let existingContact = await findOneWithDecryption(
728
- ctx.em,
729
- CustomerEntityClass,
730
- {
731
- primaryEmail: emailLower,
732
- tenantId: ctx.tenantId,
733
- organizationId: ctx.organizationId,
734
- deletedAt: null,
735
- },
736
- undefined,
737
- { tenantId: ctx.tenantId, organizationId: ctx.organizationId },
738
- )
739
- // Fallback: in-memory email check for encrypted primaryEmail fields
740
- if (!existingContact) {
741
- const candidates = await findWithDecryption(
742
- ctx.em,
743
- CustomerEntityClass,
744
- {
745
- tenantId: ctx.tenantId,
746
- organizationId: ctx.organizationId,
747
- deletedAt: null,
748
- },
749
- { limit: 100, orderBy: { createdAt: 'DESC' } },
750
- { tenantId: ctx.tenantId, organizationId: ctx.organizationId },
751
- )
752
- existingContact = candidates.find(
753
- (e) => e.primaryEmail && e.primaryEmail.toLowerCase() === emailLower,
754
- ) ?? null
755
- }
756
- if (existingContact) {
757
- const isCompany = existingContact.kind === 'company'
758
- return {
759
- createdEntityId: existingContact.id,
760
- createdEntityType: isCompany ? 'customer_company' : 'customer_person',
761
- matchedEntityId: existingContact.id,
762
- matchedEntityType: isCompany ? 'company' : 'person',
763
- }
764
- }
765
- }
766
-
767
- if (payload.type === 'company') {
768
- const result = await executeCommand<Record<string, unknown>, { entityId?: string }>(
769
- ctx,
770
- 'customers.companies.create',
771
- {
772
- organizationId: ctx.organizationId,
773
- tenantId: ctx.tenantId,
774
- displayName: payload.name,
775
- legalName: payload.companyName ?? payload.name,
776
- primaryEmail: payload.email,
777
- primaryPhone: payload.phone,
778
- source: payload.source,
779
- },
381
+ const parsed = definition.payloadSchema.safeParse(payload)
382
+ if (!parsed.success) {
383
+ throw new ExecutionError(
384
+ `Invalid ${action.actionType} payload: ${formatZodErrors(parsed.error)}`,
385
+ 400,
780
386
  )
781
- if (!result.entityId) {
782
- throw new ExecutionError('Company creation did not return an entity ID', 500)
783
- }
784
- return {
785
- createdEntityId: result.entityId,
786
- createdEntityType: 'customer_company',
787
- }
788
387
  }
789
388
 
790
- const { firstName, lastName } = splitPersonName(payload.name)
791
- const result = await executeCommand<Record<string, unknown>, { entityId?: string }>(
792
- ctx,
793
- 'customers.people.create',
794
- {
795
- organizationId: ctx.organizationId,
796
- tenantId: ctx.tenantId,
797
- displayName: payload.name,
798
- firstName,
799
- lastName,
800
- primaryEmail: payload.email,
801
- primaryPhone: payload.phone,
802
- jobTitle: payload.role,
803
- source: payload.source,
804
- },
389
+ return definition.execute(
390
+ { id: action.id, proposalId: action.proposalId, payload: parsed.data },
391
+ actionCtx,
805
392
  )
806
-
807
- if (!result.entityId) {
808
- throw new ExecutionError('Person creation did not return an entity ID', 500)
809
- }
810
-
811
- return {
812
- createdEntityId: result.entityId,
813
- createdEntityType: 'customer_person',
814
- }
815
393
  }
816
394
 
817
395
  async function resolveUnknownContactDiscrepanciesInProposal(
@@ -851,232 +429,6 @@ async function resolveUnknownContactDiscrepanciesInProposal(
851
429
  }
852
430
  }
853
431
 
854
- async function executeCreateProductAction(
855
- action: InboxProposalAction,
856
- payload: CreateProductPayload,
857
- ctx: ExecutionContext,
858
- ): Promise<TypeExecutionResult> {
859
- const createInput: Record<string, unknown> = {
860
- organizationId: ctx.organizationId,
861
- tenantId: ctx.tenantId,
862
- title: payload.title,
863
- productType: 'simple',
864
- isActive: true,
865
- }
866
-
867
- if (payload.sku) createInput.sku = payload.sku
868
- if (payload.description) createInput.description = payload.description
869
- if (payload.currencyCode) createInput.primaryCurrencyCode = payload.currencyCode
870
-
871
- const result = await executeCommand<Record<string, unknown>, { productId?: string }>(
872
- ctx,
873
- 'catalog.products.create',
874
- createInput,
875
- )
876
-
877
- if (!result.productId) {
878
- throw new ExecutionError('Product creation did not return a product ID', 500)
879
- }
880
-
881
- await resolveProductDiscrepanciesInProposal(ctx.em, action.proposalId, payload.title, result.productId, {
882
- tenantId: ctx.tenantId,
883
- organizationId: ctx.organizationId,
884
- })
885
-
886
- return {
887
- createdEntityId: result.productId,
888
- createdEntityType: 'catalog_product',
889
- }
890
- }
891
-
892
- async function resolveProductDiscrepanciesInProposal(
893
- em: EntityManager,
894
- proposalId: string,
895
- productTitle: string,
896
- productId: string,
897
- scope: { tenantId: string; organizationId: string },
898
- ): Promise<void> {
899
- const discrepancies = await findWithDecryption(
900
- em,
901
- InboxDiscrepancy,
902
- {
903
- proposalId,
904
- type: 'product_not_found',
905
- resolved: false,
906
- tenantId: scope.tenantId,
907
- organizationId: scope.organizationId,
908
- },
909
- undefined,
910
- scope,
911
- )
912
-
913
- const normalizedTitle = productTitle.toLowerCase().trim()
914
- const matchingDiscrepancies = discrepancies.filter((d) => {
915
- const foundValue = (d.foundValue || '').toLowerCase().trim()
916
- return foundValue === normalizedTitle
917
- })
918
-
919
- if (matchingDiscrepancies.length === 0) return
920
-
921
- // Phase 1: flush scalar mutations before any queries to avoid UoW tracking loss (SPEC-018)
922
- for (const discrepancy of matchingDiscrepancies) {
923
- discrepancy.resolved = true
924
- }
925
- await em.flush()
926
-
927
- // Phase 2: update line item product IDs (involves findOneWithDecryption queries)
928
- const actionIds = matchingDiscrepancies
929
- .map((d) => d.actionId)
930
- .filter((id): id is string => !!id)
931
-
932
- for (const actionId of actionIds) {
933
- await updateLineItemProductId(em, actionId, normalizedTitle, productId, scope)
934
- }
935
-
936
- if (actionIds.length > 0) {
937
- await em.flush()
938
- }
939
- }
940
-
941
- async function updateLineItemProductId(
942
- em: EntityManager,
943
- actionId: string,
944
- productName: string,
945
- productId: string,
946
- scope: { tenantId: string; organizationId: string },
947
- ): Promise<void> {
948
- const action = await findOneWithDecryption(
949
- em,
950
- InboxProposalAction,
951
- { id: actionId, deletedAt: null },
952
- undefined,
953
- scope,
954
- )
955
- if (!action) return
956
-
957
- const payload = action.payload as Record<string, unknown>
958
- const lineItems = Array.isArray(payload?.lineItems)
959
- ? (payload.lineItems as Record<string, unknown>[])
960
- : []
961
-
962
- let updated = false
963
- for (const item of lineItems) {
964
- if (item.productId) continue
965
- const itemName = (typeof item.productName === 'string' ? item.productName : '').toLowerCase().trim()
966
- if (itemName === productName) {
967
- item.productId = productId
968
- updated = true
969
- break
970
- }
971
- }
972
-
973
- if (updated) {
974
- action.payload = { ...payload, lineItems }
975
- }
976
- }
977
-
978
- function executeLinkContactAction(payload: LinkContactPayload): TypeExecutionResult {
979
- return {
980
- createdEntityId: payload.contactId,
981
- createdEntityType: payload.contactType === 'company' ? 'customer_company' : 'customer_person',
982
- matchedEntityId: payload.contactId,
983
- matchedEntityType: payload.contactType,
984
- }
985
- }
986
-
987
- async function executeLogActivityAction(
988
- payload: LogActivityPayload,
989
- ctx: ExecutionContext,
990
- ): Promise<TypeExecutionResult> {
991
- if (!payload.contactId) {
992
- const resolved = await resolveContactIdByNameAndType(ctx, payload.contactName, payload.contactType)
993
- if (resolved) {
994
- payload = { ...payload, contactId: resolved }
995
- } else {
996
- throw new ExecutionError(
997
- `log_activity requires contactId — could not resolve contact "${payload.contactName}" (${payload.contactType})`,
998
- 400,
999
- )
1000
- }
1001
- }
1002
-
1003
- const result = await executeCommand<Record<string, unknown>, { activityId?: string }>(
1004
- ctx,
1005
- 'customers.activities.create',
1006
- {
1007
- organizationId: ctx.organizationId,
1008
- tenantId: ctx.tenantId,
1009
- entityId: payload.contactId,
1010
- activityType: payload.activityType,
1011
- subject: payload.subject,
1012
- body: payload.body,
1013
- authorUserId: ctx.userId,
1014
- },
1015
- )
1016
-
1017
- if (!result.activityId) {
1018
- throw new ExecutionError('Activity creation did not return an activity ID', 500)
1019
- }
1020
-
1021
- return {
1022
- createdEntityId: result.activityId,
1023
- createdEntityType: 'customer_activity',
1024
- }
1025
- }
1026
-
1027
- async function executeDraftReplyAction(
1028
- action: InboxProposalAction,
1029
- payload: DraftReplyPayload,
1030
- ctx: ExecutionContext,
1031
- ): Promise<TypeExecutionResult> {
1032
- const payloadRecord = action.payload as Record<string, unknown>
1033
- const explicitContactId = typeof payloadRecord.contactId === 'string' ? payloadRecord.contactId : null
1034
- const contactId = explicitContactId ?? (await resolveCustomerEntityIdByEmail(ctx, payload.to))
1035
-
1036
- if (!contactId) {
1037
- throw new ExecutionError(
1038
- `No matching contact found for "${payload.to}". Create the contact first or link an existing one.`,
1039
- 400,
1040
- )
1041
- }
1042
-
1043
- const details = [
1044
- payload.body.trim(),
1045
- '',
1046
- '---',
1047
- `Draft reply target: ${payload.to}`,
1048
- `Subject: ${payload.subject}`,
1049
- payload.context ? `Context: ${payload.context}` : null,
1050
- `InboxOps Proposal: ${action.proposalId}`,
1051
- `InboxOps Action: ${action.id}`,
1052
- ]
1053
- .filter((line) => typeof line === 'string' && line.length > 0)
1054
- .join('\n')
1055
-
1056
- const result = await executeCommand<Record<string, unknown>, { activityId?: string }>(
1057
- ctx,
1058
- 'customers.activities.create',
1059
- {
1060
- organizationId: ctx.organizationId,
1061
- tenantId: ctx.tenantId,
1062
- entityId: contactId,
1063
- activityType: 'email',
1064
- subject: payload.subject,
1065
- body: details,
1066
- authorUserId: ctx.userId,
1067
- },
1068
- )
1069
-
1070
- if (!result.activityId) {
1071
- throw new ExecutionError('Draft reply activity did not return an activity ID', 500)
1072
- }
1073
-
1074
- return {
1075
- createdEntityId: result.activityId,
1076
- createdEntityType: 'customer_activity',
1077
- }
1078
- }
1079
-
1080
432
  async function ensureUserCanExecuteAction(action: InboxProposalAction, ctx: ExecutionContext): Promise<void> {
1081
433
  const requiredFeature = getRequiredFeatureForAction(action)
1082
434
  if (!requiredFeature) return
@@ -1104,382 +456,17 @@ async function ensureUserCanExecuteAction(action: InboxProposalAction, ctx: Exec
1104
456
  }
1105
457
  }
1106
458
 
1107
- async function executeCommand<TInput, TResult>(
459
+ function resolveEntityClassInternal(
1108
460
  ctx: ExecutionContext,
1109
- commandId: string,
1110
- input: TInput,
1111
- ): Promise<TResult> {
1112
- const commandBus = ctx.container.resolve('commandBus') as CommandBus
1113
- if (!commandBus || typeof commandBus.execute !== 'function') {
1114
- throw new ExecutionError('Command bus is not available', 503)
1115
- }
1116
-
1117
- const auth =
1118
- ctx.auth ??
1119
- ({
1120
- sub: ctx.userId,
1121
- userId: ctx.userId,
1122
- tenantId: ctx.tenantId,
1123
- orgId: ctx.organizationId,
1124
- isSuperAdmin: false,
1125
- } satisfies Exclude<AuthContext, null>)
1126
-
1127
- const commandContext: CommandRuntimeContext = {
1128
- container: ctx.container,
1129
- auth,
1130
- organizationScope: null,
1131
- selectedOrganizationId: ctx.organizationId,
1132
- organizationIds: [ctx.organizationId],
1133
- }
1134
-
1135
- const { result } = await commandBus.execute<TInput, TResult>(commandId, {
1136
- input,
1137
- ctx: commandContext,
1138
- })
1139
-
1140
- return result
1141
- }
1142
-
1143
- function buildSourceMetadata(actionId: string, proposalId: string): Record<string, unknown> {
1144
- return {
1145
- source: 'inbox_ops',
1146
- inboxOpsActionId: actionId,
1147
- inboxOpsProposalId: proposalId,
1148
- }
1149
- }
1150
-
1151
- async function resolveOrderByReference(
1152
- ctx: ExecutionContext,
1153
- orderId?: string,
1154
- orderNumber?: string,
1155
- ): Promise<{ id: string; orderNumber: string; currencyCode: string; comments?: string | null }> {
1156
- const SalesOrderClass = resolveEntityClass(ctx, 'SalesOrder')
1157
- if (!SalesOrderClass) {
1158
- throw new ExecutionError('Sales module entities not available', 503)
1159
- }
1160
-
1161
- const where: Record<string, unknown> = {
1162
- tenantId: ctx.tenantId,
1163
- organizationId: ctx.organizationId,
1164
- deletedAt: null,
1165
- }
1166
- if (orderId) {
1167
- where.id = orderId
1168
- } else if (orderNumber && orderNumber.trim().length > 0) {
1169
- where.orderNumber = orderNumber.trim()
1170
- } else {
1171
- throw new ExecutionError('Order reference is required', 400)
1172
- }
1173
-
1174
- const order = await findOneWithDecryption(
1175
- ctx.em,
1176
- SalesOrderClass,
1177
- where,
1178
- undefined,
1179
- { tenantId: ctx.tenantId, organizationId: ctx.organizationId },
1180
- )
1181
- if (!order) {
1182
- throw new ExecutionError('Referenced order not found', 404)
1183
- }
1184
- return order
1185
- }
1186
-
1187
- async function resolveShipmentStatusEntryId(
1188
- ctx: ExecutionContext,
1189
- statusLabel: string,
1190
- ): Promise<string | null> {
1191
- const DictionaryClass = resolveEntityClass(ctx, 'Dictionary')
1192
- const DictionaryEntryClass = resolveEntityClass(ctx, 'DictionaryEntry')
1193
- if (!DictionaryClass || !DictionaryEntryClass) return null
1194
-
1195
- const encryptionScope = { tenantId: ctx.tenantId, organizationId: ctx.organizationId }
1196
-
1197
- const dictionary = await findOneWithDecryption(
1198
- ctx.em,
1199
- DictionaryClass,
1200
- {
1201
- key: SALES_SHIPMENT_STATUS_DICTIONARY_KEY,
1202
- tenantId: ctx.tenantId,
1203
- organizationId: ctx.organizationId,
1204
- deletedAt: null,
1205
- },
1206
- undefined,
1207
- encryptionScope,
1208
- )
1209
- if (!dictionary) return null
1210
-
1211
- const entries = await findWithDecryption(
1212
- ctx.em,
1213
- DictionaryEntryClass,
1214
- {
1215
- dictionary: dictionary.id,
1216
- tenantId: ctx.tenantId,
1217
- organizationId: ctx.organizationId,
1218
- },
1219
- undefined,
1220
- encryptionScope,
1221
- )
1222
- if (!entries.length) return null
1223
-
1224
- const normalizedTarget = normalizeDictionaryToken(statusLabel)
1225
- const loweredTarget = statusLabel.trim().toLowerCase()
1226
-
1227
- const match = entries.find((entry) => {
1228
- const label = entry.label.trim().toLowerCase()
1229
- const value = entry.value.trim().toLowerCase()
1230
- return (
1231
- entry.normalizedValue === normalizedTarget ||
1232
- label === loweredTarget ||
1233
- value === loweredTarget
1234
- )
1235
- })
1236
-
1237
- return match?.id ?? null
1238
- }
1239
-
1240
- async function resolveCustomerEntityIdByEmail(
1241
- ctx: ExecutionContext,
1242
- email: string,
1243
- ): Promise<string | null> {
1244
- const normalized = email.trim().toLowerCase()
1245
- if (!normalized) return null
1246
-
1247
- const CustomerEntityClass = resolveEntityClass(ctx, 'CustomerEntity')
1248
- if (!CustomerEntityClass) return null
1249
-
1250
- // Try direct DB lookup first (works when primaryEmail is not encrypted)
1251
- const entity = await findOneWithDecryption(
1252
- ctx.em,
1253
- CustomerEntityClass,
1254
- {
1255
- primaryEmail: normalized,
1256
- tenantId: ctx.tenantId,
1257
- organizationId: ctx.organizationId,
1258
- deletedAt: null,
1259
- },
1260
- undefined,
1261
- { tenantId: ctx.tenantId, organizationId: ctx.organizationId },
1262
- )
1263
- if (entity) return entity.id
1264
-
1265
- // Fallback: in-memory email check for encrypted primaryEmail fields
1266
- const candidates = await findWithDecryption(
1267
- ctx.em,
1268
- CustomerEntityClass,
1269
- {
1270
- tenantId: ctx.tenantId,
1271
- organizationId: ctx.organizationId,
1272
- deletedAt: null,
1273
- },
1274
- { limit: 100, orderBy: { createdAt: 'DESC' } },
1275
- { tenantId: ctx.tenantId, organizationId: ctx.organizationId },
1276
- )
1277
- const match = candidates.find(
1278
- (e) => e.primaryEmail && e.primaryEmail.toLowerCase() === normalized,
1279
- )
1280
- return match?.id ?? null
1281
- }
1282
-
1283
- async function resolveEffectiveDocumentKind(
1284
- ctx: ExecutionContext,
1285
- channelId: string,
1286
- ): Promise<'order' | 'quote'> {
1287
- const SalesChannelClass = resolveEntityClass(ctx, 'SalesChannel')
1288
- if (!SalesChannelClass) return 'order'
1289
-
1290
- const channel = await findOneWithDecryption(
1291
- ctx.em,
1292
- SalesChannelClass,
1293
- {
1294
- id: channelId,
1295
- tenantId: ctx.tenantId,
1296
- organizationId: ctx.organizationId,
1297
- deletedAt: null,
1298
- },
1299
- undefined,
1300
- { tenantId: ctx.tenantId, organizationId: ctx.organizationId },
1301
- )
1302
- if (!channel) return 'order'
1303
-
1304
- const metadata = channel.metadata as Record<string, unknown> | null
1305
- if (metadata?.quotesRequired === true) {
1306
- return 'quote'
1307
- }
1308
- return 'order'
1309
- }
1310
-
1311
- async function resolveFirstChannelId(ctx: ExecutionContext): Promise<string | null> {
1312
- const SalesChannelClass = resolveEntityClass(ctx, 'SalesChannel')
1313
- if (!SalesChannelClass) return null
1314
-
1315
- try {
1316
- const channel = await findOneWithDecryption(
1317
- ctx.em,
1318
- SalesChannelClass,
1319
- {
1320
- tenantId: ctx.tenantId,
1321
- organizationId: ctx.organizationId,
1322
- deletedAt: null,
1323
- },
1324
- { orderBy: { name: 'ASC' } },
1325
- { tenantId: ctx.tenantId, organizationId: ctx.organizationId },
1326
- )
1327
- return channel?.id ?? null
1328
- } catch {
1329
- return null
1330
- }
1331
- }
1332
-
1333
- function resolveEntityClass<K extends keyof CrossModuleEntities>(
1334
- ctx: ExecutionContext,
1335
- key: K,
1336
- ): CrossModuleEntities[K] | null {
1337
- const fromEntities = ctx.entities?.[key]
461
+ key: string,
462
+ ): unknown {
463
+ const fromEntities = (ctx.entities as Record<string, unknown> | undefined)?.[key]
1338
464
  if (fromEntities) return fromEntities
1339
465
  try { return ctx.container.resolve(key) } catch { return null }
1340
466
  }
1341
467
 
1342
- async function resolveChannelCurrency(
1343
- ctx: ExecutionContext,
1344
- channelId: string | null,
1345
- ): Promise<string | null> {
1346
- const SalesChannelClass = resolveEntityClass(ctx, 'SalesChannel')
1347
- if (!SalesChannelClass) return null
1348
-
1349
- try {
1350
- const where: Record<string, unknown> = {
1351
- tenantId: ctx.tenantId,
1352
- organizationId: ctx.organizationId,
1353
- deletedAt: null,
1354
- }
1355
- if (channelId) where.id = channelId
1356
- const channel = await findOneWithDecryption(
1357
- ctx.em,
1358
- SalesChannelClass,
1359
- where,
1360
- channelId ? undefined : { orderBy: { name: 'ASC' } },
1361
- { tenantId: ctx.tenantId, organizationId: ctx.organizationId },
1362
- )
1363
- return channel?.currencyCode ?? null
1364
- } catch {
1365
- return null
1366
- }
1367
- }
1368
-
1369
- async function resolveContactIdByNameAndType(
1370
- ctx: ExecutionContext,
1371
- contactName: string,
1372
- contactType: string,
1373
- ): Promise<string | null> {
1374
- const CustomerEntityClass = resolveEntityClass(ctx, 'CustomerEntity')
1375
- if (!CustomerEntityClass) return null
1376
-
1377
- const normalized = contactName.trim()
1378
- if (!normalized) return null
1379
-
1380
- const entity = await findOneWithDecryption(
1381
- ctx.em,
1382
- CustomerEntityClass,
1383
- {
1384
- displayName: normalized,
1385
- kind: contactType,
1386
- tenantId: ctx.tenantId,
1387
- organizationId: ctx.organizationId,
1388
- deletedAt: null,
1389
- },
1390
- undefined,
1391
- { tenantId: ctx.tenantId, organizationId: ctx.organizationId },
1392
- )
1393
-
1394
- return entity?.id ?? null
1395
- }
1396
-
1397
- interface OrderLineItem {
1398
- id: string
1399
- name?: string | null
1400
- }
1401
-
1402
- async function loadOrderLineItems(
1403
- ctx: ExecutionContext,
1404
- orderId: string,
1405
- ): Promise<OrderLineItem[]> {
1406
- try {
1407
- const result = await executeCommand<Record<string, unknown>, { lines?: OrderLineItem[] }>(
1408
- ctx,
1409
- 'sales.orders.lines.list',
1410
- { orderId, organizationId: ctx.organizationId, tenantId: ctx.tenantId },
1411
- )
1412
- return result.lines ?? []
1413
- } catch {
1414
- return []
1415
- }
1416
- }
1417
-
1418
- function matchLineItemByName(
1419
- orderLines: OrderLineItem[],
1420
- lineItemName: string,
1421
- ): string | null {
1422
- const target = lineItemName.trim().toLowerCase()
1423
- if (!target) return null
1424
-
1425
- const exact = orderLines.find((l) => (l.name || '').trim().toLowerCase() === target)
1426
- if (exact) return exact.id
1427
-
1428
- const partial = orderLines.find((l) => {
1429
- const name = (l.name || '').trim().toLowerCase()
1430
- return name.includes(target) || target.includes(name)
1431
- })
1432
- return partial?.id ?? null
1433
- }
1434
-
1435
- function normalizeDictionaryToken(value: string): string {
1436
- return value.trim().toLowerCase().replace(/[\s-]+/g, '_')
1437
- }
1438
-
1439
- function splitPersonName(name: string): { firstName: string; lastName: string } {
1440
- const trimmed = name.trim()
1441
- const parts = trimmed.split(/\s+/).filter((item) => item.length > 0)
1442
- if (parts.length <= 1) {
1443
- return {
1444
- firstName: parts[0] || trimmed,
1445
- lastName: '',
1446
- }
1447
- }
1448
- return {
1449
- firstName: parts[0],
1450
- lastName: parts.slice(1).join(' '),
1451
- }
1452
- }
1453
-
1454
- function parseNumberToken(value: string, fieldName: string): number {
1455
- const parsed = Number(value)
1456
- if (!Number.isFinite(parsed)) {
1457
- throw new ExecutionError(`Invalid numeric value for ${fieldName}`, 400)
1458
- }
1459
- return parsed
1460
- }
1461
-
1462
- function normalizeAddressSnapshot(
1463
- address: Record<string, unknown>,
1464
- ): Record<string, unknown> {
1465
- return {
1466
- addressLine1: address.line1 ?? address.addressLine1 ?? '',
1467
- addressLine2: address.line2 ?? address.addressLine2 ?? null,
1468
- companyName: address.company ?? address.companyName ?? null,
1469
- name: address.contactName ?? address.name ?? null,
1470
- city: address.city ?? null,
1471
- region: address.state ?? address.region ?? null,
1472
- postalCode: address.postalCode ?? null,
1473
- country: address.country ?? null,
1474
- }
1475
- }
1476
-
1477
- function parseDateToken(value?: string | null): Date | undefined {
1478
- if (!value) return undefined
1479
- const parsed = new Date(value)
1480
- if (Number.isNaN(parsed.getTime())) return undefined
1481
- return parsed
1482
- }
468
+ // Re-export splitPersonName for backward compat
469
+ export { splitPersonName } from './contactValidation'
1483
470
 
1484
471
  async function resolveActionDiscrepancies(
1485
472
  em: EntityManager,