@open-mercato/core 0.6.5-develop.5337.1.534b781eac → 0.6.5

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 (350) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +1 -1
  3. package/dist/bootstrap.js +46 -6
  4. package/dist/bootstrap.js.map +2 -2
  5. package/dist/generated/entities/organization/index.js +2 -0
  6. package/dist/generated/entities/organization/index.js.map +2 -2
  7. package/dist/generated/entity-fields-registry.js +1 -0
  8. package/dist/generated/entity-fields-registry.js.map +2 -2
  9. package/dist/helpers/integration/crmFixtures.js +4 -0
  10. package/dist/helpers/integration/crmFixtures.js.map +2 -2
  11. package/dist/modules/attachments/api/library/route.js +2 -2
  12. package/dist/modules/attachments/api/library/route.js.map +2 -2
  13. package/dist/modules/attachments/api/route.js +2 -0
  14. package/dist/modules/attachments/api/route.js.map +2 -2
  15. package/dist/modules/attachments/components/AttachmentContentPreview.js +9 -5
  16. package/dist/modules/attachments/components/AttachmentContentPreview.js.map +2 -2
  17. package/dist/modules/attachments/lib/access.js +18 -0
  18. package/dist/modules/attachments/lib/access.js.map +2 -2
  19. package/dist/modules/audit_logs/api/audit-logs/actions/redo/route.js +3 -2
  20. package/dist/modules/audit_logs/api/audit-logs/actions/redo/route.js.map +2 -2
  21. package/dist/modules/audit_logs/data/entities.js +2 -1
  22. package/dist/modules/audit_logs/data/entities.js.map +2 -2
  23. package/dist/modules/audit_logs/migrations/Migration20260611104500.js +13 -0
  24. package/dist/modules/audit_logs/migrations/Migration20260611104500.js.map +7 -0
  25. package/dist/modules/audit_logs/services/accessLogService.js +10 -0
  26. package/dist/modules/audit_logs/services/accessLogService.js.map +2 -2
  27. package/dist/modules/auth/api/admin/nav.js +9 -0
  28. package/dist/modules/auth/api/admin/nav.js.map +2 -2
  29. package/dist/modules/auth/api/login.js +4 -13
  30. package/dist/modules/auth/api/login.js.map +2 -2
  31. package/dist/modules/auth/commands/users.js +20 -14
  32. package/dist/modules/auth/commands/users.js.map +2 -2
  33. package/dist/modules/auth/data/entities.js +4 -2
  34. package/dist/modules/auth/data/entities.js.map +2 -2
  35. package/dist/modules/auth/lib/backendChrome.js +35 -2
  36. package/dist/modules/auth/lib/backendChrome.js.map +2 -2
  37. package/dist/modules/auth/lib/consentIntegrity.js +3 -3
  38. package/dist/modules/auth/lib/consentIntegrity.js.map +2 -2
  39. package/dist/modules/auth/migrations/Migration20260610120000.js +30 -0
  40. package/dist/modules/auth/migrations/Migration20260610120000.js.map +7 -0
  41. package/dist/modules/auth/migrations/Migration20260611103000.js +15 -0
  42. package/dist/modules/auth/migrations/Migration20260611103000.js.map +7 -0
  43. package/dist/modules/auth/services/authService.js +5 -3
  44. package/dist/modules/auth/services/authService.js.map +2 -2
  45. package/dist/modules/auth/services/rbacService.js +3 -2
  46. package/dist/modules/auth/services/rbacService.js.map +2 -2
  47. package/dist/modules/catalog/ai-tools/configuration-pack.js.map +1 -1
  48. package/dist/modules/catalog/ai-tools/prices-offers-pack.js.map +1 -1
  49. package/dist/modules/catalog/ai-tools/products-pack.js.map +1 -1
  50. package/dist/modules/catalog/ai-tools/variants-pack.js.map +1 -1
  51. package/dist/modules/communication_channels/data/entities.js.map +1 -1
  52. package/dist/modules/communication_channels/encryption.js.map +1 -1
  53. package/dist/modules/communication_channels/lib/thread-matcher.js.map +1 -1
  54. package/dist/modules/communication_channels/lib/thread-token.js.map +1 -1
  55. package/dist/modules/currencies/api/currencies/route.js +4 -3
  56. package/dist/modules/currencies/api/currencies/route.js.map +2 -2
  57. package/dist/modules/customer_accounts/api/admin/roles.js +2 -1
  58. package/dist/modules/customer_accounts/api/admin/roles.js.map +2 -2
  59. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.js +0 -3
  60. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.js.map +2 -2
  61. package/dist/modules/customer_accounts/events.js +1 -1
  62. package/dist/modules/customer_accounts/events.js.map +1 -1
  63. package/dist/modules/customer_accounts/lib/resolveTenantContext.js.map +1 -1
  64. package/dist/modules/customers/acl.js +1 -1
  65. package/dist/modules/customers/acl.js.map +1 -1
  66. package/dist/modules/customers/ai-tools/companies-pack.js.map +1 -1
  67. package/dist/modules/customers/ai-tools/deals-pack.js.map +1 -1
  68. package/dist/modules/customers/ai-tools/people-pack.js.map +1 -1
  69. package/dist/modules/customers/api/companies/route.js +4 -4
  70. package/dist/modules/customers/api/companies/route.js.map +2 -2
  71. package/dist/modules/customers/api/deals/route.js +43 -2
  72. package/dist/modules/customers/api/deals/route.js.map +2 -2
  73. package/dist/modules/customers/api/deals/summary/route.js +402 -0
  74. package/dist/modules/customers/api/deals/summary/route.js.map +7 -0
  75. package/dist/modules/customers/api/people/route.js +4 -4
  76. package/dist/modules/customers/api/people/route.js.map +2 -2
  77. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealActivities.js +16 -5
  78. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealActivities.js.map +2 -2
  79. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealData.js +22 -5
  80. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealData.js.map +2 -2
  81. package/dist/modules/customers/backend/customers/deals/[id]/page.js +12 -2
  82. package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
  83. package/dist/modules/customers/backend/customers/deals/page.js +221 -56
  84. package/dist/modules/customers/backend/customers/deals/page.js.map +3 -3
  85. package/dist/modules/customers/backend/customers/deals/pipeline/page.js +1 -1
  86. package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
  87. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +18 -0
  88. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
  89. package/dist/modules/customers/cli.js +15 -9
  90. package/dist/modules/customers/cli.js.map +2 -2
  91. package/dist/modules/customers/commands/addresses.js +5 -5
  92. package/dist/modules/customers/commands/addresses.js.map +2 -2
  93. package/dist/modules/customers/commands/comments.js +5 -5
  94. package/dist/modules/customers/commands/comments.js.map +2 -2
  95. package/dist/modules/customers/commands/deals.js +2 -2
  96. package/dist/modules/customers/commands/deals.js.map +2 -2
  97. package/dist/modules/customers/commands/entity-roles.js +2 -1
  98. package/dist/modules/customers/commands/entity-roles.js.map +2 -2
  99. package/dist/modules/customers/commands/interactions.js +8 -5
  100. package/dist/modules/customers/commands/interactions.js.map +2 -2
  101. package/dist/modules/customers/commands/shared.js +21 -6
  102. package/dist/modules/customers/commands/shared.js.map +2 -2
  103. package/dist/modules/customers/commands/tags.js +3 -3
  104. package/dist/modules/customers/commands/tags.js.map +2 -2
  105. package/dist/modules/customers/components/DealsKpiStrip.js +282 -0
  106. package/dist/modules/customers/components/DealsKpiStrip.js.map +7 -0
  107. package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js +0 -1
  108. package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js.map +2 -2
  109. package/dist/modules/customers/components/detail/DealForm.js +100 -17
  110. package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
  111. package/dist/modules/customers/components/detail/PersonDetailTabs.js +11 -3
  112. package/dist/modules/customers/components/detail/PersonDetailTabs.js.map +2 -2
  113. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +1 -2
  114. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
  115. package/dist/modules/customers/components/detail/assignableStaff.js +21 -8
  116. package/dist/modules/customers/components/detail/assignableStaff.js.map +2 -2
  117. package/dist/modules/customers/components/kpi/PipelineStageBar.js +63 -0
  118. package/dist/modules/customers/components/kpi/PipelineStageBar.js.map +7 -0
  119. package/dist/modules/customers/lib/dealsMetrics.js +82 -0
  120. package/dist/modules/customers/lib/dealsMetrics.js.map +7 -0
  121. package/dist/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.js.map +1 -1
  122. package/dist/modules/data_sync/api/run.js +1 -1
  123. package/dist/modules/data_sync/api/run.js.map +2 -2
  124. package/dist/modules/directory/api/organization-branding/route.js +214 -0
  125. package/dist/modules/directory/api/organization-branding/route.js.map +7 -0
  126. package/dist/modules/directory/api/organizations/route.js +7 -0
  127. package/dist/modules/directory/api/organizations/route.js.map +3 -3
  128. package/dist/modules/directory/backend/directory/branding/page.js +214 -0
  129. package/dist/modules/directory/backend/directory/branding/page.js.map +7 -0
  130. package/dist/modules/directory/backend/directory/branding/page.meta.js +26 -0
  131. package/dist/modules/directory/backend/directory/branding/page.meta.js.map +7 -0
  132. package/dist/modules/directory/commands/organizations.js +8 -1
  133. package/dist/modules/directory/commands/organizations.js.map +2 -2
  134. package/dist/modules/directory/data/entities.js +3 -0
  135. package/dist/modules/directory/data/entities.js.map +2 -2
  136. package/dist/modules/directory/data/validators.js +9 -0
  137. package/dist/modules/directory/data/validators.js.map +2 -2
  138. package/dist/modules/directory/migrations/Migration20260607222259_directory.js +13 -0
  139. package/dist/modules/directory/migrations/Migration20260607222259_directory.js.map +7 -0
  140. package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js +2 -1
  141. package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js.map +2 -2
  142. package/dist/modules/directory/utils/organizationScope.js +59 -27
  143. package/dist/modules/directory/utils/organizationScope.js.map +2 -2
  144. package/dist/modules/entities/api/definitions.batch.js +2 -1
  145. package/dist/modules/entities/api/definitions.batch.js.map +2 -2
  146. package/dist/modules/entities/api/entities.js +7 -0
  147. package/dist/modules/entities/api/entities.js.map +2 -2
  148. package/dist/modules/entities/api/records.js +26 -15
  149. package/dist/modules/entities/api/records.js.map +2 -2
  150. package/dist/modules/entities/backend/entities/user/[entityId]/records/[recordId]/page.js +14 -0
  151. package/dist/modules/entities/backend/entities/user/[entityId]/records/[recordId]/page.js.map +2 -2
  152. package/dist/modules/entities/backend/entities/user/[entityId]/records/create/page.js +14 -0
  153. package/dist/modules/entities/backend/entities/user/[entityId]/records/create/page.js.map +2 -2
  154. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js +12 -0
  155. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js.map +2 -2
  156. package/dist/modules/entities/components/useRecordsEntityGuard.js +30 -0
  157. package/dist/modules/entities/components/useRecordsEntityGuard.js.map +7 -0
  158. package/dist/modules/payment_gateways/api/transactions/route.js +2 -4
  159. package/dist/modules/payment_gateways/api/transactions/route.js.map +2 -2
  160. package/dist/modules/progress/api/jobs/[id]/route.js +7 -2
  161. package/dist/modules/progress/api/jobs/[id]/route.js.map +2 -2
  162. package/dist/modules/progress/api/jobs/route.js +1 -1
  163. package/dist/modules/progress/api/jobs/route.js.map +2 -2
  164. package/dist/modules/progress/lib/progressServiceImpl.js +8 -2
  165. package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
  166. package/dist/modules/query_index/data/entities.js +2 -1
  167. package/dist/modules/query_index/data/entities.js.map +2 -2
  168. package/dist/modules/query_index/lib/engine.js +4 -2
  169. package/dist/modules/query_index/lib/engine.js.map +2 -2
  170. package/dist/modules/query_index/migrations/Migration20260611103000_query_index.js +16 -0
  171. package/dist/modules/query_index/migrations/Migration20260611103000_query_index.js.map +7 -0
  172. package/dist/modules/resources/api/resources.js +2 -3
  173. package/dist/modules/resources/api/resources.js.map +2 -2
  174. package/dist/modules/sales/api/documents/factory.js +2 -2
  175. package/dist/modules/sales/api/documents/factory.js.map +2 -2
  176. package/dist/modules/sales/commands/documents.js +7 -5
  177. package/dist/modules/sales/commands/documents.js.map +2 -2
  178. package/dist/modules/sales/components/documents/SalesDocumentsTable.js +2 -1
  179. package/dist/modules/sales/components/documents/SalesDocumentsTable.js.map +2 -2
  180. package/dist/modules/sales/components/documents/salesDocumentsColumns.js +10 -0
  181. package/dist/modules/sales/components/documents/salesDocumentsColumns.js.map +7 -0
  182. package/dist/modules/staff/api/team-members.js +9 -2
  183. package/dist/modules/staff/api/team-members.js.map +2 -2
  184. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js +24 -1
  185. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js.map +2 -2
  186. package/dist/modules/staff/backend/staff/team-members/[id]/page.js +11 -6
  187. package/dist/modules/staff/backend/staff/team-members/[id]/page.js.map +2 -2
  188. package/dist/modules/staff/commands/team-members.js +1 -1
  189. package/dist/modules/staff/commands/team-members.js.map +2 -2
  190. package/dist/modules/staff/components/TeamMemberForm.js +1 -1
  191. package/dist/modules/staff/components/TeamMemberForm.js.map +2 -2
  192. package/dist/modules/staff/lib/scheduleSwitch.js +23 -0
  193. package/dist/modules/staff/lib/scheduleSwitch.js.map +7 -0
  194. package/dist/modules/sync_excel/api/import/route.js +1 -1
  195. package/dist/modules/sync_excel/api/import/route.js.map +2 -2
  196. package/dist/modules/workflows/api/definitions/route.js +3 -2
  197. package/dist/modules/workflows/api/definitions/route.js.map +2 -2
  198. package/dist/modules/workflows/backend/definitions/create/page.js +1 -2
  199. package/dist/modules/workflows/backend/definitions/create/page.js.map +2 -2
  200. package/dist/modules/workflows/backend/definitions/visual-editor/page.js +1 -2
  201. package/dist/modules/workflows/backend/definitions/visual-editor/page.js.map +2 -2
  202. package/dist/modules/workflows/components/DefinitionTriggersEditor.js +1 -2
  203. package/dist/modules/workflows/components/DefinitionTriggersEditor.js.map +2 -2
  204. package/dist/modules/workflows/components/NodeEditDialog.js +4 -13
  205. package/dist/modules/workflows/components/NodeEditDialog.js.map +2 -2
  206. package/dist/modules/workflows/components/NodeEditDialogCrudForm.js +4 -13
  207. package/dist/modules/workflows/components/NodeEditDialogCrudForm.js.map +2 -2
  208. package/dist/modules/workflows/components/WorkflowGraphImpl.js +1 -4
  209. package/dist/modules/workflows/components/WorkflowGraphImpl.js.map +2 -2
  210. package/dist/modules/workflows/components/fields/FormFieldArrayEditor.js +2 -5
  211. package/dist/modules/workflows/components/fields/FormFieldArrayEditor.js.map +2 -2
  212. package/generated/entities/organization/index.ts +1 -0
  213. package/generated/entity-fields-registry.ts +1 -0
  214. package/package.json +11 -12
  215. package/src/bootstrap.ts +65 -7
  216. package/src/helpers/integration/crmFixtures.ts +21 -1
  217. package/src/modules/attachments/AGENTS.md +79 -0
  218. package/src/modules/attachments/api/library/route.ts +2 -2
  219. package/src/modules/attachments/api/route.ts +2 -0
  220. package/src/modules/attachments/components/AttachmentContentPreview.tsx +6 -6
  221. package/src/modules/attachments/lib/access.ts +36 -0
  222. package/src/modules/audit_logs/api/audit-logs/actions/redo/route.ts +14 -2
  223. package/src/modules/audit_logs/data/entities.ts +1 -0
  224. package/src/modules/audit_logs/migrations/.snapshot-open-mercato.json +10 -0
  225. package/src/modules/audit_logs/migrations/Migration20260611104500.ts +13 -0
  226. package/src/modules/audit_logs/services/accessLogService.ts +15 -0
  227. package/src/modules/auth/api/admin/nav.ts +9 -0
  228. package/src/modules/auth/api/login.ts +13 -13
  229. package/src/modules/auth/commands/users.ts +32 -15
  230. package/src/modules/auth/data/entities.ts +13 -1
  231. package/src/modules/auth/i18n/de.json +0 -1
  232. package/src/modules/auth/i18n/en.json +0 -1
  233. package/src/modules/auth/i18n/es.json +0 -1
  234. package/src/modules/auth/i18n/pl.json +0 -1
  235. package/src/modules/auth/lib/backendChrome.tsx +37 -1
  236. package/src/modules/auth/lib/consentIntegrity.ts +6 -3
  237. package/src/modules/auth/migrations/.snapshot-open-mercato.json +20 -10
  238. package/src/modules/auth/migrations/Migration20260610120000.ts +53 -0
  239. package/src/modules/auth/migrations/Migration20260611103000.ts +21 -0
  240. package/src/modules/auth/services/authService.ts +24 -4
  241. package/src/modules/auth/services/rbacService.ts +11 -2
  242. package/src/modules/catalog/ai-tools/configuration-pack.ts +1 -1
  243. package/src/modules/catalog/ai-tools/prices-offers-pack.ts +1 -1
  244. package/src/modules/catalog/ai-tools/products-pack.ts +1 -1
  245. package/src/modules/catalog/ai-tools/variants-pack.ts +1 -1
  246. package/src/modules/communication_channels/data/entities.ts +2 -2
  247. package/src/modules/communication_channels/encryption.ts +1 -1
  248. package/src/modules/communication_channels/lib/adapter.ts +1 -1
  249. package/src/modules/communication_channels/lib/thread-matcher.ts +1 -1
  250. package/src/modules/communication_channels/lib/thread-token.ts +1 -1
  251. package/src/modules/currencies/api/currencies/route.ts +4 -3
  252. package/src/modules/customer_accounts/api/admin/roles.ts +2 -1
  253. package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.tsx +0 -3
  254. package/src/modules/customer_accounts/events.ts +1 -1
  255. package/src/modules/customer_accounts/lib/resolveTenantContext.ts +2 -2
  256. package/src/modules/customers/acl.ts +1 -1
  257. package/src/modules/customers/ai-tools/companies-pack.ts +1 -1
  258. package/src/modules/customers/ai-tools/deals-pack.ts +1 -1
  259. package/src/modules/customers/ai-tools/people-pack.ts +1 -1
  260. package/src/modules/customers/api/companies/route.ts +4 -4
  261. package/src/modules/customers/api/deals/route.ts +51 -2
  262. package/src/modules/customers/api/deals/summary/route.ts +496 -0
  263. package/src/modules/customers/api/people/route.ts +4 -4
  264. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealActivities.ts +28 -6
  265. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealData.ts +33 -6
  266. package/src/modules/customers/backend/customers/deals/[id]/page.tsx +17 -2
  267. package/src/modules/customers/backend/customers/deals/page.tsx +254 -66
  268. package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +1 -2
  269. package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +18 -0
  270. package/src/modules/customers/cli.ts +15 -15
  271. package/src/modules/customers/commands/addresses.ts +5 -5
  272. package/src/modules/customers/commands/comments.ts +5 -5
  273. package/src/modules/customers/commands/deals.ts +2 -2
  274. package/src/modules/customers/commands/entity-roles.ts +2 -1
  275. package/src/modules/customers/commands/interactions.ts +8 -5
  276. package/src/modules/customers/commands/shared.ts +26 -4
  277. package/src/modules/customers/commands/tags.ts +3 -3
  278. package/src/modules/customers/components/DealsKpiStrip.tsx +389 -0
  279. package/src/modules/customers/components/detail/ConfirmDealLostDialog.tsx +0 -1
  280. package/src/modules/customers/components/detail/DealForm.tsx +121 -19
  281. package/src/modules/customers/components/detail/PersonDetailTabs.tsx +12 -2
  282. package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +1 -2
  283. package/src/modules/customers/components/detail/assignableStaff.ts +32 -8
  284. package/src/modules/customers/components/kpi/PipelineStageBar.tsx +77 -0
  285. package/src/modules/customers/i18n/de.json +43 -0
  286. package/src/modules/customers/i18n/en.json +43 -0
  287. package/src/modules/customers/i18n/es.json +43 -0
  288. package/src/modules/customers/i18n/pl.json +43 -0
  289. package/src/modules/customers/lib/dealsMetrics.ts +159 -0
  290. package/src/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.ts +1 -1
  291. package/src/modules/data_sync/api/run.ts +1 -1
  292. package/src/modules/directory/api/organization-branding/route.ts +238 -0
  293. package/src/modules/directory/api/organizations/route.ts +7 -0
  294. package/src/modules/directory/backend/directory/branding/page.meta.ts +24 -0
  295. package/src/modules/directory/backend/directory/branding/page.tsx +248 -0
  296. package/src/modules/directory/commands/organizations.ts +9 -1
  297. package/src/modules/directory/data/entities.ts +3 -0
  298. package/src/modules/directory/data/validators.ts +12 -0
  299. package/src/modules/directory/i18n/de.json +21 -0
  300. package/src/modules/directory/i18n/en.json +21 -0
  301. package/src/modules/directory/i18n/es.json +21 -0
  302. package/src/modules/directory/i18n/pl.json +21 -0
  303. package/src/modules/directory/migrations/.snapshot-open-mercato.json +40 -0
  304. package/src/modules/directory/migrations/Migration20260607222259_directory.ts +13 -0
  305. package/src/modules/directory/subscribers/invalidateOrgScopeCache.ts +3 -1
  306. package/src/modules/directory/utils/organizationScope.ts +85 -30
  307. package/src/modules/entities/api/definitions.batch.ts +11 -7
  308. package/src/modules/entities/api/entities.ts +11 -0
  309. package/src/modules/entities/api/records.ts +46 -25
  310. package/src/modules/entities/backend/entities/user/[entityId]/records/[recordId]/page.tsx +15 -0
  311. package/src/modules/entities/backend/entities/user/[entityId]/records/create/page.tsx +15 -0
  312. package/src/modules/entities/backend/entities/user/[entityId]/records/page.tsx +23 -0
  313. package/src/modules/entities/components/useRecordsEntityGuard.ts +41 -0
  314. package/src/modules/entities/i18n/de.json +1 -0
  315. package/src/modules/entities/i18n/en.json +1 -0
  316. package/src/modules/entities/i18n/es.json +1 -0
  317. package/src/modules/entities/i18n/pl.json +1 -0
  318. package/src/modules/payment_gateways/api/transactions/route.ts +2 -5
  319. package/src/modules/progress/api/jobs/[id]/route.ts +6 -1
  320. package/src/modules/progress/api/jobs/route.ts +1 -1
  321. package/src/modules/progress/lib/progressServiceImpl.ts +7 -1
  322. package/src/modules/query_index/data/entities.ts +1 -0
  323. package/src/modules/query_index/lib/engine.ts +11 -5
  324. package/src/modules/query_index/migrations/.snapshot-open-mercato.json +11 -0
  325. package/src/modules/query_index/migrations/Migration20260611103000_query_index.ts +29 -0
  326. package/src/modules/resources/api/resources.ts +2 -3
  327. package/src/modules/sales/api/documents/factory.ts +2 -2
  328. package/src/modules/sales/commands/documents.ts +7 -5
  329. package/src/modules/sales/components/documents/SalesDocumentsTable.tsx +2 -1
  330. package/src/modules/sales/components/documents/salesDocumentsColumns.ts +6 -0
  331. package/src/modules/staff/AGENTS.md +1 -1
  332. package/src/modules/staff/api/team-members.ts +9 -2
  333. package/src/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.ts +31 -1
  334. package/src/modules/staff/backend/staff/team-members/[id]/page.tsx +18 -8
  335. package/src/modules/staff/commands/team-members.ts +5 -2
  336. package/src/modules/staff/components/TeamMemberForm.tsx +4 -1
  337. package/src/modules/staff/i18n/de.json +1 -0
  338. package/src/modules/staff/i18n/en.json +1 -0
  339. package/src/modules/staff/i18n/es.json +1 -0
  340. package/src/modules/staff/i18n/pl.json +1 -0
  341. package/src/modules/staff/lib/scheduleSwitch.ts +46 -0
  342. package/src/modules/sync_excel/api/import/route.ts +1 -1
  343. package/src/modules/workflows/api/definitions/route.ts +3 -2
  344. package/src/modules/workflows/backend/definitions/create/page.tsx +1 -2
  345. package/src/modules/workflows/backend/definitions/visual-editor/page.tsx +1 -2
  346. package/src/modules/workflows/components/DefinitionTriggersEditor.tsx +1 -2
  347. package/src/modules/workflows/components/NodeEditDialog.tsx +1 -4
  348. package/src/modules/workflows/components/NodeEditDialogCrudForm.tsx +4 -7
  349. package/src/modules/workflows/components/WorkflowGraphImpl.tsx +1 -2
  350. package/src/modules/workflows/components/fields/FormFieldArrayEditor.tsx +2 -3
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/catalog/ai-tools/prices-offers-pack.ts"],
4
- "sourcesContent": ["/**\n * `catalog.list_prices`, `catalog.list_price_kinds_base`, `catalog.list_offers`\n * (Phase 1 WS-C, Step 3.10).\n *\n * Read-only enumeration of prices (base + offer-bound), price kinds, and\n * offers for the caller tenant + organization. Mutation tools land in Step\n * 5.14 under the pending-action contract.\n *\n * `catalog.list_price_kinds_base` uses a distinct name on purpose \u2014 Step\n * 3.11 (D18) will own `catalog.list_price_kinds` verbatim; we keep both\n * names available so the D18 tool can layer merchandising-specific shape\n * over the base enumerator.\n *\n * Phase 3b of `.ai/specs/2026-04-27-ai-tools-api-backed-dry-refactor.md`:\n * `catalog.list_prices` and `catalog.list_offers` are now API-backed wrappers\n * over `GET /api/catalog/prices` and `GET /api/catalog/offers`. Tool names,\n * schemas, requiredFeatures, and output shapes are unchanged. The offers\n * route does not expose a `variantId` filter; the AI input is pre-resolved\n * via `CatalogProductPrice` to the matching offer ids and threaded through\n * the route's `id` filter (or post-filtered when more than one matches),\n * mirroring Phase 3a's `companyId` \u2192 `ids` trick for `customers.list_people`.\n */\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { z } from 'zod'\nimport { defineApiBackedAiTool } from '@open-mercato/ai-assistant/modules/ai_assistant/lib/api-backed-tool'\nimport type {\n AiApiOperationRequest,\n AiToolExecutionContext,\n} from '@open-mercato/ai-assistant/modules/ai_assistant/lib/ai-api-operation-runner'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { CatalogProductPrice } from '../data/entities'\nimport { assertTenantScope, type CatalogAiToolDefinition, type CatalogToolContext } from './types'\nimport { listPriceKindsCore } from './_shared'\n\nfunction resolveEm(ctx: CatalogToolContext | AiToolExecutionContext): EntityManager {\n return ctx.container.resolve<EntityManager>('em')\n}\n\nfunction buildScope(ctx: CatalogToolContext | AiToolExecutionContext, tenantId: string) {\n return { tenantId, organizationId: ctx.organizationId }\n}\n\nconst listPricesInput = z\n .object({\n productId: z.string().uuid().optional().describe('Restrict to prices attached to this product.'),\n variantId: z.string().uuid().optional().describe('Restrict to prices attached to this variant.'),\n priceKindId: z.string().uuid().optional().describe('Restrict to this price kind.'),\n limit: z.number().int().min(1).max(100).optional().describe('Max rows (default 50, max 100).'),\n offset: z.number().int().min(0).optional().describe('Rows to skip (default 0).'),\n })\n .passthrough()\n\ntype ListPricesInput = z.infer<typeof listPricesInput>\n\ntype ListPricesApiItem = {\n id?: string\n product_id?: string | null\n productId?: string | null\n variant_id?: string | null\n variantId?: string | null\n offer_id?: string | null\n offerId?: string | null\n price_kind_id?: string | null\n priceKindId?: string | null\n currency_code?: string | null\n currencyCode?: string | null\n kind?: string | null\n min_quantity?: number | null\n minQuantity?: number | null\n max_quantity?: number | null\n maxQuantity?: number | null\n unit_price_net?: string | number | null\n unitPriceNet?: string | number | null\n unit_price_gross?: string | number | null\n unitPriceGross?: string | number | null\n tax_rate?: string | number | null\n taxRate?: string | number | null\n tax_amount?: string | number | null\n taxAmount?: string | number | null\n channel_id?: string | null\n channelId?: string | null\n user_id?: string | null\n userId?: string | null\n user_group_id?: string | null\n userGroupId?: string | null\n customer_id?: string | null\n customerId?: string | null\n customer_group_id?: string | null\n customerGroupId?: string | null\n starts_at?: string | null\n startsAt?: string | null\n ends_at?: string | null\n endsAt?: string | null\n organization_id?: string | null\n organizationId?: string | null\n tenant_id?: string | null\n tenantId?: string | null\n created_at?: string | null\n createdAt?: string | null\n}\n\ntype ListPricesApiResponse = {\n items?: ListPricesApiItem[]\n total?: number\n}\n\ntype ListPricesOutput = {\n items: Array<Record<string, unknown>>\n total: number\n limit: number\n offset: number\n}\n\nconst listPricesTool = defineApiBackedAiTool<\n ListPricesInput,\n ListPricesApiResponse,\n ListPricesOutput\n>({\n name: 'catalog.list_prices',\n displayName: 'List prices',\n description:\n 'List catalog prices (base + offer-scoped) for the caller tenant + organization. Filters: product, variant, or price kind.',\n inputSchema: listPricesInput,\n requiredFeatures: ['catalog.products.view'],\n toOperation: (input, ctx) => {\n assertTenantScope(ctx as unknown as CatalogToolContext)\n const limit = input.limit ?? 50\n const offset = input.offset ?? 0\n const page = Math.floor(offset / limit) + 1\n\n const query: Record<string, string | number | boolean | null | undefined> = {\n page,\n pageSize: limit,\n }\n if (input.productId) query.productId = input.productId\n if (input.variantId) query.variantId = input.variantId\n if (input.priceKindId) query.priceKindId = input.priceKindId\n\n const operation: AiApiOperationRequest = {\n method: 'GET',\n path: '/catalog/prices',\n query,\n }\n return operation\n },\n mapResponse: (response, input) => {\n const limit = input.limit ?? 50\n const offset = input.offset ?? 0\n const data = (response.data ?? {}) as ListPricesApiResponse\n const rawItems: ListPricesApiItem[] = Array.isArray(data.items) ? data.items : []\n return {\n items: rawItems.map((row) => {\n const startsAtRaw = row.starts_at ?? row.startsAt ?? null\n const startsAt = startsAtRaw ? new Date(String(startsAtRaw)).toISOString() : null\n const endsAtRaw = row.ends_at ?? row.endsAt ?? null\n const endsAt = endsAtRaw ? new Date(String(endsAtRaw)).toISOString() : null\n const createdAtRaw = row.created_at ?? row.createdAt ?? null\n const createdAt = createdAtRaw ? new Date(String(createdAtRaw)).toISOString() : null\n return {\n id: row.id,\n priceKindId: row.price_kind_id ?? row.priceKindId ?? null,\n productId: row.product_id ?? row.productId ?? null,\n variantId: row.variant_id ?? row.variantId ?? null,\n offerId: row.offer_id ?? row.offerId ?? null,\n currencyCode: row.currency_code ?? row.currencyCode ?? null,\n kind: row.kind ?? null,\n minQuantity: row.min_quantity ?? row.minQuantity ?? null,\n maxQuantity: row.max_quantity ?? row.maxQuantity ?? null,\n unitPriceNet: row.unit_price_net ?? row.unitPriceNet ?? null,\n unitPriceGross: row.unit_price_gross ?? row.unitPriceGross ?? null,\n taxRate: row.tax_rate ?? row.taxRate ?? null,\n taxAmount: row.tax_amount ?? row.taxAmount ?? null,\n channelId: row.channel_id ?? row.channelId ?? null,\n userId: row.user_id ?? row.userId ?? null,\n userGroupId: row.user_group_id ?? row.userGroupId ?? null,\n customerId: row.customer_id ?? row.customerId ?? null,\n customerGroupId: row.customer_group_id ?? row.customerGroupId ?? null,\n startsAt,\n endsAt,\n organizationId: row.organization_id ?? row.organizationId ?? null,\n tenantId: row.tenant_id ?? row.tenantId ?? null,\n createdAt,\n }\n }),\n total: typeof data.total === 'number' ? data.total : 0,\n limit,\n offset,\n }\n },\n}) as unknown as CatalogAiToolDefinition\n\nconst listPriceKindsInput = z\n .object({\n limit: z.number().int().min(1).max(100).optional().describe('Max rows (default 50, max 100).'),\n offset: z.number().int().min(0).optional().describe('Rows to skip (default 0).'),\n })\n .passthrough()\n\nconst listPriceKindsTool: CatalogAiToolDefinition = {\n name: 'catalog.list_price_kinds_base',\n displayName: 'List price kinds (base)',\n description:\n 'Enumerate the tenant price kinds. Base coverage tool \u2014 Step 3.11 (D18) owns `catalog.list_price_kinds` verbatim; this tool uses a distinct name to avoid collision.',\n inputSchema: listPriceKindsInput,\n requiredFeatures: ['catalog.settings.manage'],\n tags: ['read', 'catalog'],\n handler: async (rawInput, ctx) => {\n const { tenantId } = assertTenantScope(ctx)\n const input = listPriceKindsInput.parse(rawInput)\n // Shared helper; Step 3.11 `catalog.list_price_kinds` uses the same core\n // so both tools cannot drift.\n return listPriceKindsCore(ctx, input, tenantId)\n },\n}\n\nconst listOffersInput = z\n .object({\n productId: z.string().uuid().optional().describe('Restrict to offers for this product.'),\n variantId: z.string().uuid().optional().describe('Restrict to offers whose prices are variant-scoped.'),\n active: z.boolean().optional().describe('When true, only active (non-archived) offers are returned.'),\n limit: z.number().int().min(1).max(100).optional().describe('Max rows (default 50, max 100).'),\n offset: z.number().int().min(0).optional().describe('Rows to skip (default 0).'),\n })\n .passthrough()\n\nconst NIL_UUID = '00000000-0000-0000-0000-000000000000'\n\ntype ListOffersInput = z.infer<typeof listOffersInput>\n\ntype ListOffersApiItem = {\n id?: string\n product_id?: string | null\n productId?: string | null\n channel_id?: string | null\n channelId?: string | null\n title?: string | null\n description?: string | null\n default_media_id?: string | null\n defaultMediaId?: string | null\n default_media_url?: string | null\n defaultMediaUrl?: string | null\n is_active?: boolean | null\n isActive?: boolean | null\n organization_id?: string | null\n organizationId?: string | null\n tenant_id?: string | null\n tenantId?: string | null\n created_at?: string | null\n createdAt?: string | null\n}\n\ntype ListOffersApiResponse = {\n items?: ListOffersApiItem[]\n total?: number\n}\n\ntype ListOffersOutput = {\n items: Array<Record<string, unknown>>\n total: number\n limit: number\n offset: number\n}\n\nasync function resolveOfferIdsForVariant(\n ctx: AiToolExecutionContext | CatalogToolContext,\n tenantId: string,\n variantId: string,\n): Promise<string[]> {\n const em = resolveEm(ctx)\n const priceWhere: Record<string, unknown> = { tenantId, variant: variantId }\n if (ctx.organizationId) priceWhere.organizationId = ctx.organizationId\n const prices = await findWithDecryption<CatalogProductPrice>(\n em,\n CatalogProductPrice,\n priceWhere as any,\n undefined,\n buildScope(ctx, tenantId),\n )\n const offerIds = prices\n .map((price) => (price as any).offer)\n .map((offer) => (offer && typeof offer === 'object' ? offer.id : offer))\n .filter((value: string | null): value is string => typeof value === 'string' && value.length > 0)\n return Array.from(new Set(offerIds))\n}\n\nconst listOffersTool = defineApiBackedAiTool<\n ListOffersInput,\n ListOffersApiResponse,\n ListOffersOutput\n>({\n name: 'catalog.list_offers',\n displayName: 'List offers',\n description:\n 'List catalog offers for the caller tenant + organization, optionally narrowed to a product (or a variant via its prices).',\n inputSchema: listOffersInput,\n // Must cover the underlying route. `GET /catalog/offers` requires\n // `sales.channels.manage` (offers are sales-channel scoped); the API-backed\n // runner fails closed when the tool's features don't cover the route's.\n // `catalog.products.view` stays for the variant\u2192offer resolution path.\n requiredFeatures: ['catalog.products.view', 'sales.channels.manage'],\n toOperation: async (input, ctx) => {\n const { tenantId } = assertTenantScope(ctx as unknown as CatalogToolContext)\n const limit = input.limit ?? 50\n const offset = input.offset ?? 0\n const page = Math.floor(offset / limit) + 1\n\n const query: Record<string, string | number | boolean | null | undefined> = {\n page,\n pageSize: limit,\n }\n if (input.productId) query.productId = input.productId\n if (input.active === true) query.isActive = 'true'\n\n if (input.variantId) {\n const offerIds = await resolveOfferIdsForVariant(ctx, tenantId, input.variantId)\n if (offerIds.length === 0) {\n // Empty match \u2014 feed a non-existent uuid so the route returns an\n // empty page without us bypassing the API.\n query.id = NIL_UUID\n } else if (offerIds.length === 1) {\n query.id = offerIds[0]\n }\n // For >1 offer ids the route's single-id filter cannot narrow; the\n // mapper post-filters the unfiltered response by the resolved ids.\n }\n\n const operation: AiApiOperationRequest = {\n method: 'GET',\n path: '/catalog/offers',\n query,\n }\n return operation\n },\n mapResponse: async (response, input, ctx) => {\n const limit = input.limit ?? 50\n const offset = input.offset ?? 0\n const data = (response.data ?? {}) as ListOffersApiResponse\n let rawItems: ListOffersApiItem[] = Array.isArray(data.items) ? data.items : []\n let total = typeof data.total === 'number' ? data.total : 0\n\n if (input.variantId) {\n const { tenantId } = assertTenantScope(ctx as unknown as CatalogToolContext)\n const offerIds = await resolveOfferIdsForVariant(ctx, tenantId, input.variantId)\n const offerIdSet = new Set(offerIds)\n rawItems = rawItems.filter((row) => typeof row.id === 'string' && offerIdSet.has(row.id))\n total = rawItems.length\n }\n\n return {\n items: rawItems.map((row) => {\n const createdAtRaw = row.created_at ?? row.createdAt ?? null\n const createdAt = createdAtRaw ? new Date(String(createdAtRaw)).toISOString() : null\n return {\n id: row.id,\n title: row.title ?? '',\n description: row.description ?? null,\n channelId: row.channel_id ?? row.channelId ?? null,\n productId: row.product_id ?? row.productId ?? null,\n defaultMediaId: row.default_media_id ?? row.defaultMediaId ?? null,\n defaultMediaUrl: row.default_media_url ?? row.defaultMediaUrl ?? null,\n isActive: !!(row.is_active ?? row.isActive),\n organizationId: row.organization_id ?? row.organizationId ?? null,\n tenantId: row.tenant_id ?? row.tenantId ?? null,\n createdAt,\n }\n }),\n total,\n limit,\n offset,\n }\n },\n}) as unknown as CatalogAiToolDefinition\n\nexport const pricesOffersAiTools: CatalogAiToolDefinition[] = [\n listPricesTool,\n listPriceKindsTool,\n listOffersTool,\n]\n\nexport default pricesOffersAiTools\n"],
4
+ "sourcesContent": ["/**\n * `catalog.list_prices`, `catalog.list_price_kinds_base`, `catalog.list_offers`\n * (Phase 1 WS-C, Step 3.10).\n *\n * Read-only enumeration of prices (base + offer-bound), price kinds, and\n * offers for the caller tenant + organization. Mutation tools land in Step\n * 5.14 under the pending-action contract.\n *\n * `catalog.list_price_kinds_base` uses a distinct name on purpose \u2014 Step\n * 3.11 (D18) will own `catalog.list_price_kinds` verbatim; we keep both\n * names available so the D18 tool can layer merchandising-specific shape\n * over the base enumerator.\n *\n * Phase 3b of `.ai/specs/implemented/2026-04-27-ai-tools-api-backed-dry-refactor.md`:\n * `catalog.list_prices` and `catalog.list_offers` are now API-backed wrappers\n * over `GET /api/catalog/prices` and `GET /api/catalog/offers`. Tool names,\n * schemas, requiredFeatures, and output shapes are unchanged. The offers\n * route does not expose a `variantId` filter; the AI input is pre-resolved\n * via `CatalogProductPrice` to the matching offer ids and threaded through\n * the route's `id` filter (or post-filtered when more than one matches),\n * mirroring Phase 3a's `companyId` \u2192 `ids` trick for `customers.list_people`.\n */\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { z } from 'zod'\nimport { defineApiBackedAiTool } from '@open-mercato/ai-assistant/modules/ai_assistant/lib/api-backed-tool'\nimport type {\n AiApiOperationRequest,\n AiToolExecutionContext,\n} from '@open-mercato/ai-assistant/modules/ai_assistant/lib/ai-api-operation-runner'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { CatalogProductPrice } from '../data/entities'\nimport { assertTenantScope, type CatalogAiToolDefinition, type CatalogToolContext } from './types'\nimport { listPriceKindsCore } from './_shared'\n\nfunction resolveEm(ctx: CatalogToolContext | AiToolExecutionContext): EntityManager {\n return ctx.container.resolve<EntityManager>('em')\n}\n\nfunction buildScope(ctx: CatalogToolContext | AiToolExecutionContext, tenantId: string) {\n return { tenantId, organizationId: ctx.organizationId }\n}\n\nconst listPricesInput = z\n .object({\n productId: z.string().uuid().optional().describe('Restrict to prices attached to this product.'),\n variantId: z.string().uuid().optional().describe('Restrict to prices attached to this variant.'),\n priceKindId: z.string().uuid().optional().describe('Restrict to this price kind.'),\n limit: z.number().int().min(1).max(100).optional().describe('Max rows (default 50, max 100).'),\n offset: z.number().int().min(0).optional().describe('Rows to skip (default 0).'),\n })\n .passthrough()\n\ntype ListPricesInput = z.infer<typeof listPricesInput>\n\ntype ListPricesApiItem = {\n id?: string\n product_id?: string | null\n productId?: string | null\n variant_id?: string | null\n variantId?: string | null\n offer_id?: string | null\n offerId?: string | null\n price_kind_id?: string | null\n priceKindId?: string | null\n currency_code?: string | null\n currencyCode?: string | null\n kind?: string | null\n min_quantity?: number | null\n minQuantity?: number | null\n max_quantity?: number | null\n maxQuantity?: number | null\n unit_price_net?: string | number | null\n unitPriceNet?: string | number | null\n unit_price_gross?: string | number | null\n unitPriceGross?: string | number | null\n tax_rate?: string | number | null\n taxRate?: string | number | null\n tax_amount?: string | number | null\n taxAmount?: string | number | null\n channel_id?: string | null\n channelId?: string | null\n user_id?: string | null\n userId?: string | null\n user_group_id?: string | null\n userGroupId?: string | null\n customer_id?: string | null\n customerId?: string | null\n customer_group_id?: string | null\n customerGroupId?: string | null\n starts_at?: string | null\n startsAt?: string | null\n ends_at?: string | null\n endsAt?: string | null\n organization_id?: string | null\n organizationId?: string | null\n tenant_id?: string | null\n tenantId?: string | null\n created_at?: string | null\n createdAt?: string | null\n}\n\ntype ListPricesApiResponse = {\n items?: ListPricesApiItem[]\n total?: number\n}\n\ntype ListPricesOutput = {\n items: Array<Record<string, unknown>>\n total: number\n limit: number\n offset: number\n}\n\nconst listPricesTool = defineApiBackedAiTool<\n ListPricesInput,\n ListPricesApiResponse,\n ListPricesOutput\n>({\n name: 'catalog.list_prices',\n displayName: 'List prices',\n description:\n 'List catalog prices (base + offer-scoped) for the caller tenant + organization. Filters: product, variant, or price kind.',\n inputSchema: listPricesInput,\n requiredFeatures: ['catalog.products.view'],\n toOperation: (input, ctx) => {\n assertTenantScope(ctx as unknown as CatalogToolContext)\n const limit = input.limit ?? 50\n const offset = input.offset ?? 0\n const page = Math.floor(offset / limit) + 1\n\n const query: Record<string, string | number | boolean | null | undefined> = {\n page,\n pageSize: limit,\n }\n if (input.productId) query.productId = input.productId\n if (input.variantId) query.variantId = input.variantId\n if (input.priceKindId) query.priceKindId = input.priceKindId\n\n const operation: AiApiOperationRequest = {\n method: 'GET',\n path: '/catalog/prices',\n query,\n }\n return operation\n },\n mapResponse: (response, input) => {\n const limit = input.limit ?? 50\n const offset = input.offset ?? 0\n const data = (response.data ?? {}) as ListPricesApiResponse\n const rawItems: ListPricesApiItem[] = Array.isArray(data.items) ? data.items : []\n return {\n items: rawItems.map((row) => {\n const startsAtRaw = row.starts_at ?? row.startsAt ?? null\n const startsAt = startsAtRaw ? new Date(String(startsAtRaw)).toISOString() : null\n const endsAtRaw = row.ends_at ?? row.endsAt ?? null\n const endsAt = endsAtRaw ? new Date(String(endsAtRaw)).toISOString() : null\n const createdAtRaw = row.created_at ?? row.createdAt ?? null\n const createdAt = createdAtRaw ? new Date(String(createdAtRaw)).toISOString() : null\n return {\n id: row.id,\n priceKindId: row.price_kind_id ?? row.priceKindId ?? null,\n productId: row.product_id ?? row.productId ?? null,\n variantId: row.variant_id ?? row.variantId ?? null,\n offerId: row.offer_id ?? row.offerId ?? null,\n currencyCode: row.currency_code ?? row.currencyCode ?? null,\n kind: row.kind ?? null,\n minQuantity: row.min_quantity ?? row.minQuantity ?? null,\n maxQuantity: row.max_quantity ?? row.maxQuantity ?? null,\n unitPriceNet: row.unit_price_net ?? row.unitPriceNet ?? null,\n unitPriceGross: row.unit_price_gross ?? row.unitPriceGross ?? null,\n taxRate: row.tax_rate ?? row.taxRate ?? null,\n taxAmount: row.tax_amount ?? row.taxAmount ?? null,\n channelId: row.channel_id ?? row.channelId ?? null,\n userId: row.user_id ?? row.userId ?? null,\n userGroupId: row.user_group_id ?? row.userGroupId ?? null,\n customerId: row.customer_id ?? row.customerId ?? null,\n customerGroupId: row.customer_group_id ?? row.customerGroupId ?? null,\n startsAt,\n endsAt,\n organizationId: row.organization_id ?? row.organizationId ?? null,\n tenantId: row.tenant_id ?? row.tenantId ?? null,\n createdAt,\n }\n }),\n total: typeof data.total === 'number' ? data.total : 0,\n limit,\n offset,\n }\n },\n}) as unknown as CatalogAiToolDefinition\n\nconst listPriceKindsInput = z\n .object({\n limit: z.number().int().min(1).max(100).optional().describe('Max rows (default 50, max 100).'),\n offset: z.number().int().min(0).optional().describe('Rows to skip (default 0).'),\n })\n .passthrough()\n\nconst listPriceKindsTool: CatalogAiToolDefinition = {\n name: 'catalog.list_price_kinds_base',\n displayName: 'List price kinds (base)',\n description:\n 'Enumerate the tenant price kinds. Base coverage tool \u2014 Step 3.11 (D18) owns `catalog.list_price_kinds` verbatim; this tool uses a distinct name to avoid collision.',\n inputSchema: listPriceKindsInput,\n requiredFeatures: ['catalog.settings.manage'],\n tags: ['read', 'catalog'],\n handler: async (rawInput, ctx) => {\n const { tenantId } = assertTenantScope(ctx)\n const input = listPriceKindsInput.parse(rawInput)\n // Shared helper; Step 3.11 `catalog.list_price_kinds` uses the same core\n // so both tools cannot drift.\n return listPriceKindsCore(ctx, input, tenantId)\n },\n}\n\nconst listOffersInput = z\n .object({\n productId: z.string().uuid().optional().describe('Restrict to offers for this product.'),\n variantId: z.string().uuid().optional().describe('Restrict to offers whose prices are variant-scoped.'),\n active: z.boolean().optional().describe('When true, only active (non-archived) offers are returned.'),\n limit: z.number().int().min(1).max(100).optional().describe('Max rows (default 50, max 100).'),\n offset: z.number().int().min(0).optional().describe('Rows to skip (default 0).'),\n })\n .passthrough()\n\nconst NIL_UUID = '00000000-0000-0000-0000-000000000000'\n\ntype ListOffersInput = z.infer<typeof listOffersInput>\n\ntype ListOffersApiItem = {\n id?: string\n product_id?: string | null\n productId?: string | null\n channel_id?: string | null\n channelId?: string | null\n title?: string | null\n description?: string | null\n default_media_id?: string | null\n defaultMediaId?: string | null\n default_media_url?: string | null\n defaultMediaUrl?: string | null\n is_active?: boolean | null\n isActive?: boolean | null\n organization_id?: string | null\n organizationId?: string | null\n tenant_id?: string | null\n tenantId?: string | null\n created_at?: string | null\n createdAt?: string | null\n}\n\ntype ListOffersApiResponse = {\n items?: ListOffersApiItem[]\n total?: number\n}\n\ntype ListOffersOutput = {\n items: Array<Record<string, unknown>>\n total: number\n limit: number\n offset: number\n}\n\nasync function resolveOfferIdsForVariant(\n ctx: AiToolExecutionContext | CatalogToolContext,\n tenantId: string,\n variantId: string,\n): Promise<string[]> {\n const em = resolveEm(ctx)\n const priceWhere: Record<string, unknown> = { tenantId, variant: variantId }\n if (ctx.organizationId) priceWhere.organizationId = ctx.organizationId\n const prices = await findWithDecryption<CatalogProductPrice>(\n em,\n CatalogProductPrice,\n priceWhere as any,\n undefined,\n buildScope(ctx, tenantId),\n )\n const offerIds = prices\n .map((price) => (price as any).offer)\n .map((offer) => (offer && typeof offer === 'object' ? offer.id : offer))\n .filter((value: string | null): value is string => typeof value === 'string' && value.length > 0)\n return Array.from(new Set(offerIds))\n}\n\nconst listOffersTool = defineApiBackedAiTool<\n ListOffersInput,\n ListOffersApiResponse,\n ListOffersOutput\n>({\n name: 'catalog.list_offers',\n displayName: 'List offers',\n description:\n 'List catalog offers for the caller tenant + organization, optionally narrowed to a product (or a variant via its prices).',\n inputSchema: listOffersInput,\n // Must cover the underlying route. `GET /catalog/offers` requires\n // `sales.channels.manage` (offers are sales-channel scoped); the API-backed\n // runner fails closed when the tool's features don't cover the route's.\n // `catalog.products.view` stays for the variant\u2192offer resolution path.\n requiredFeatures: ['catalog.products.view', 'sales.channels.manage'],\n toOperation: async (input, ctx) => {\n const { tenantId } = assertTenantScope(ctx as unknown as CatalogToolContext)\n const limit = input.limit ?? 50\n const offset = input.offset ?? 0\n const page = Math.floor(offset / limit) + 1\n\n const query: Record<string, string | number | boolean | null | undefined> = {\n page,\n pageSize: limit,\n }\n if (input.productId) query.productId = input.productId\n if (input.active === true) query.isActive = 'true'\n\n if (input.variantId) {\n const offerIds = await resolveOfferIdsForVariant(ctx, tenantId, input.variantId)\n if (offerIds.length === 0) {\n // Empty match \u2014 feed a non-existent uuid so the route returns an\n // empty page without us bypassing the API.\n query.id = NIL_UUID\n } else if (offerIds.length === 1) {\n query.id = offerIds[0]\n }\n // For >1 offer ids the route's single-id filter cannot narrow; the\n // mapper post-filters the unfiltered response by the resolved ids.\n }\n\n const operation: AiApiOperationRequest = {\n method: 'GET',\n path: '/catalog/offers',\n query,\n }\n return operation\n },\n mapResponse: async (response, input, ctx) => {\n const limit = input.limit ?? 50\n const offset = input.offset ?? 0\n const data = (response.data ?? {}) as ListOffersApiResponse\n let rawItems: ListOffersApiItem[] = Array.isArray(data.items) ? data.items : []\n let total = typeof data.total === 'number' ? data.total : 0\n\n if (input.variantId) {\n const { tenantId } = assertTenantScope(ctx as unknown as CatalogToolContext)\n const offerIds = await resolveOfferIdsForVariant(ctx, tenantId, input.variantId)\n const offerIdSet = new Set(offerIds)\n rawItems = rawItems.filter((row) => typeof row.id === 'string' && offerIdSet.has(row.id))\n total = rawItems.length\n }\n\n return {\n items: rawItems.map((row) => {\n const createdAtRaw = row.created_at ?? row.createdAt ?? null\n const createdAt = createdAtRaw ? new Date(String(createdAtRaw)).toISOString() : null\n return {\n id: row.id,\n title: row.title ?? '',\n description: row.description ?? null,\n channelId: row.channel_id ?? row.channelId ?? null,\n productId: row.product_id ?? row.productId ?? null,\n defaultMediaId: row.default_media_id ?? row.defaultMediaId ?? null,\n defaultMediaUrl: row.default_media_url ?? row.defaultMediaUrl ?? null,\n isActive: !!(row.is_active ?? row.isActive),\n organizationId: row.organization_id ?? row.organizationId ?? null,\n tenantId: row.tenant_id ?? row.tenantId ?? null,\n createdAt,\n }\n }),\n total,\n limit,\n offset,\n }\n },\n}) as unknown as CatalogAiToolDefinition\n\nexport const pricesOffersAiTools: CatalogAiToolDefinition[] = [\n listPricesTool,\n listPriceKindsTool,\n listOffersTool,\n]\n\nexport default pricesOffersAiTools\n"],
5
5
  "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/catalog/ai-tools/products-pack.ts"],
4
- "sourcesContent": ["/**\n * `catalog.list_products` + `catalog.get_product` (Phase 1 WS-C, Step 3.10).\n *\n * Read-only tools scoped to `ctx.tenantId` + `ctx.organizationId`. Mutation\n * tools are deferred to Step 5.14 under the pending-action contract.\n *\n * Phase 3b of `.ai/specs/2026-04-27-ai-tools-api-backed-dry-refactor.md`:\n * `catalog.list_products` is now an API-backed wrapper over\n * `GET /api/catalog/products`. Tool name, schema, requiredFeatures, and\n * output shape are unchanged.\n */\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { z } from 'zod'\nimport { defineApiBackedAiTool } from '@open-mercato/ai-assistant/modules/ai_assistant/lib/api-backed-tool'\nimport type {\n AiApiOperationRequest,\n AiToolExecutionContext,\n} from '@open-mercato/ai-assistant/modules/ai_assistant/lib/ai-api-operation-runner'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { loadCustomFieldValues } from '@open-mercato/shared/lib/crud/custom-fields'\nimport { E } from '#generated/entities.ids.generated'\nimport { Attachment } from '@open-mercato/core/modules/attachments/data/entities'\nimport {\n CatalogProduct,\n CatalogProductCategoryAssignment,\n CatalogProductTagAssignment,\n CatalogProductTag,\n CatalogProductVariant,\n CatalogProductPrice,\n CatalogProductUnitConversion,\n} from '../data/entities'\nimport { assertTenantScope, type CatalogAiToolDefinition, type CatalogToolContext } from './types'\n\nfunction resolveEm(ctx: CatalogToolContext | AiToolExecutionContext): EntityManager {\n return ctx.container.resolve<EntityManager>('em')\n}\n\nfunction buildScope(ctx: CatalogToolContext | AiToolExecutionContext, tenantId: string) {\n return { tenantId, organizationId: ctx.organizationId }\n}\n\nconst listProductsInput = z\n .object({\n q: z.string().trim().min(1).optional().describe('Optional search text matched against title / subtitle / sku / handle.'),\n limit: z.number().int().min(1).max(100).optional().describe('Maximum rows to return (default 50, max 100).'),\n offset: z.number().int().min(0).optional().describe('Number of rows to skip (default 0).'),\n categoryId: z.string().uuid().optional().describe('Restrict to products assigned to this catalog category.'),\n tagIds: z.array(z.string().uuid()).optional().describe('Restrict to products carrying at least one of these tag ids.'),\n active: z.boolean().optional().describe('When true, only active (not archived) products are returned.'),\n })\n .passthrough()\n\ntype ListProductsInput = z.infer<typeof listProductsInput>\n\ntype ListProductsApiItem = {\n id?: string\n title?: string | null\n subtitle?: string | null\n sku?: string | null\n handle?: string | null\n product_type?: string | null\n productType?: string | null\n status_entry_id?: string | null\n statusEntryId?: string | null\n primary_currency_code?: string | null\n primaryCurrencyCode?: string | null\n default_media_id?: string | null\n defaultMediaId?: string | null\n default_media_url?: string | null\n defaultMediaUrl?: string | null\n is_active?: boolean | null\n isActive?: boolean | null\n is_configurable?: boolean | null\n isConfigurable?: boolean | null\n organization_id?: string | null\n organizationId?: string | null\n tenant_id?: string | null\n tenantId?: string | null\n created_at?: string | null\n createdAt?: string | null\n updated_at?: string | null\n updatedAt?: string | null\n}\n\ntype ListProductsApiResponse = {\n items?: ListProductsApiItem[]\n total?: number\n}\n\ntype ListProductsOutput = {\n items: Array<Record<string, unknown>>\n total: number\n limit: number\n offset: number\n}\n\nconst listProductsTool = defineApiBackedAiTool<\n ListProductsInput,\n ListProductsApiResponse,\n ListProductsOutput\n>({\n name: 'catalog.list_products',\n displayName: 'List products',\n description:\n 'Search / list catalog products for the caller tenant + organization. Returns { items, total, limit, offset }.',\n inputSchema: listProductsInput,\n requiredFeatures: ['catalog.products.view'],\n toOperation: (input, ctx) => {\n assertTenantScope(ctx as unknown as CatalogToolContext)\n const limit = input.limit ?? 50\n const offset = input.offset ?? 0\n const page = Math.floor(offset / limit) + 1\n\n const query: Record<string, string | number | boolean | null | undefined> = {\n page,\n pageSize: limit,\n }\n if (input.q?.trim()) query.search = input.q.trim()\n if (input.categoryId) query.categoryIds = input.categoryId\n if (input.tagIds && input.tagIds.length > 0) query.tagIds = input.tagIds.join(',')\n if (input.active === true) query.isActive = 'true'\n\n const operation: AiApiOperationRequest = {\n method: 'GET',\n path: '/catalog/products',\n query,\n }\n return operation\n },\n mapResponse: (response, input) => {\n const limit = input.limit ?? 50\n const offset = input.offset ?? 0\n const data = (response.data ?? {}) as ListProductsApiResponse\n const rawItems: ListProductsApiItem[] = Array.isArray(data.items) ? data.items : []\n return {\n items: rawItems.map((row) => {\n const createdAtRaw = row.created_at ?? row.createdAt ?? null\n const createdAt = createdAtRaw ? new Date(String(createdAtRaw)).toISOString() : null\n const updatedAtRaw = row.updated_at ?? row.updatedAt ?? null\n const updatedAt = updatedAtRaw ? new Date(String(updatedAtRaw)).toISOString() : null\n return {\n id: row.id,\n title: row.title ?? null,\n subtitle: row.subtitle ?? null,\n sku: row.sku ?? null,\n handle: row.handle ?? null,\n productType: row.product_type ?? row.productType ?? null,\n statusEntryId: row.status_entry_id ?? row.statusEntryId ?? null,\n primaryCurrencyCode: row.primary_currency_code ?? row.primaryCurrencyCode ?? null,\n defaultMediaId: row.default_media_id ?? row.defaultMediaId ?? null,\n defaultMediaUrl: row.default_media_url ?? row.defaultMediaUrl ?? null,\n imageUrl: row.default_media_url ?? row.defaultMediaUrl ?? null,\n isActive: !!(row.is_active ?? row.isActive),\n isConfigurable: !!(row.is_configurable ?? row.isConfigurable),\n organizationId: row.organization_id ?? row.organizationId ?? null,\n tenantId: row.tenant_id ?? row.tenantId ?? null,\n createdAt,\n updatedAt,\n }\n }),\n total: typeof data.total === 'number' ? data.total : 0,\n limit,\n offset,\n }\n },\n}) as unknown as CatalogAiToolDefinition\n\nconst getProductInput = z.object({\n productId: z.string().uuid().describe('Catalog product id (UUID).'),\n includeRelated: z\n .boolean()\n .optional()\n .describe(\n 'When true, include categories, tags, variants, prices (base + offers), media (metadata only), unit conversions, and custom fields (each related list capped at 100).',\n ),\n})\n\nconst getProductTool: CatalogAiToolDefinition = {\n name: 'catalog.get_product',\n displayName: 'Get product',\n description:\n 'Fetch a catalog product by id with core fields and (optionally) categories, tags, variants, prices, media metadata, unit conversions, and custom fields. Returns { found: false } when the record is outside tenant/org scope or missing.',\n inputSchema: getProductInput,\n requiredFeatures: ['catalog.products.view'],\n tags: ['read', 'catalog'],\n handler: async (rawInput, ctx) => {\n const { tenantId } = assertTenantScope(ctx)\n const input = getProductInput.parse(rawInput)\n const em = resolveEm(ctx)\n const where: Record<string, unknown> = {\n id: input.productId,\n tenantId,\n deletedAt: null,\n }\n if (ctx.organizationId) where.organizationId = ctx.organizationId\n const product = await findOneWithDecryption<CatalogProduct>(\n em,\n CatalogProduct,\n where as any,\n undefined,\n buildScope(ctx, tenantId),\n )\n if (!product || product.tenantId !== tenantId) {\n return { found: false as const, productId: input.productId }\n }\n\n let related: Record<string, unknown> | null = null\n let customFields: Record<string, unknown> = {}\n if (input.includeRelated) {\n const scope = buildScope(ctx, tenantId)\n const [\n categoryAssignments,\n tagAssignments,\n variants,\n prices,\n mediaAttachments,\n unitConversions,\n customFieldValues,\n ] = await Promise.all([\n findWithDecryption<CatalogProductCategoryAssignment>(\n em,\n CatalogProductCategoryAssignment,\n { tenantId, product: product.id } as any,\n { limit: 100, populate: ['category'] as any } as any,\n scope,\n ),\n findWithDecryption<CatalogProductTagAssignment>(\n em,\n CatalogProductTagAssignment,\n { tenantId, product: product.id } as any,\n { limit: 100, populate: ['tag'] as any } as any,\n scope,\n ),\n findWithDecryption<CatalogProductVariant>(\n em,\n CatalogProductVariant,\n { tenantId, product: product.id, deletedAt: null } as any,\n { limit: 100, orderBy: { createdAt: 'asc' } as any } as any,\n scope,\n ),\n findWithDecryption<CatalogProductPrice>(\n em,\n CatalogProductPrice,\n { tenantId, product: product.id } as any,\n { limit: 100, orderBy: { createdAt: 'asc' } as any } as any,\n scope,\n ),\n findWithDecryption<Attachment>(\n em,\n Attachment,\n { tenantId, entityId: E.catalog.catalog_product, recordId: product.id } as any,\n { limit: 100, orderBy: { createdAt: 'asc' } as any } as any,\n scope,\n ),\n findWithDecryption<CatalogProductUnitConversion>(\n em,\n CatalogProductUnitConversion,\n { tenantId, product: product.id, deletedAt: null } as any,\n { limit: 100, orderBy: { sortOrder: 'asc', createdAt: 'asc' } as any } as any,\n scope,\n ),\n loadCustomFieldValues({\n em,\n entityId: E.catalog.catalog_product,\n recordIds: [product.id],\n tenantIdByRecord: { [product.id]: product.tenantId ?? null },\n organizationIdByRecord: { [product.id]: product.organizationId ?? null },\n tenantFallbacks: [product.tenantId ?? tenantId].filter((value): value is string => !!value),\n }),\n ])\n customFields = customFieldValues[product.id] ?? {}\n related = {\n categories: categoryAssignments\n .map((assignment) => {\n const category = (assignment as any).category\n if (!category || typeof category === 'string') {\n const fallbackId = typeof category === 'string' ? category : null\n return fallbackId ? { id: fallbackId, name: null, slug: null } : null\n }\n return {\n id: category.id,\n name: category.name ?? null,\n slug: category.slug ?? null,\n }\n })\n .filter((value): value is { id: string; name: string | null; slug: string | null } => value !== null),\n tags: tagAssignments\n .map((assignment) => {\n const tag = (assignment as any).tag as CatalogProductTag | string | null\n if (!tag || typeof tag === 'string') return null\n return { id: tag.id, label: tag.label, slug: tag.slug }\n })\n .filter((value): value is { id: string; label: string; slug: string } => value !== null),\n variants: variants.map((variant) => ({\n id: variant.id,\n name: variant.name ?? null,\n sku: variant.sku ?? null,\n barcode: variant.barcode ?? null,\n optionValues: variant.optionValues ?? null,\n defaultMediaId: variant.defaultMediaId ?? null,\n defaultMediaUrl: variant.defaultMediaUrl ?? null,\n isDefault: !!variant.isDefault,\n isActive: !!variant.isActive,\n })),\n prices: prices.map((price) => ({\n id: price.id,\n priceKindId: (price as any).priceKind && typeof (price as any).priceKind === 'object'\n ? (price as any).priceKind.id\n : (price as any).priceKind ?? null,\n currencyCode: price.currencyCode,\n kind: price.kind,\n minQuantity: price.minQuantity,\n maxQuantity: price.maxQuantity ?? null,\n unitPriceNet: price.unitPriceNet ?? null,\n unitPriceGross: price.unitPriceGross ?? null,\n channelId: price.channelId ?? null,\n offerId: (price as any).offer && typeof (price as any).offer === 'object'\n ? (price as any).offer.id\n : (price as any).offer ?? null,\n variantId: (price as any).variant && typeof (price as any).variant === 'object'\n ? (price as any).variant.id\n : (price as any).variant ?? null,\n startsAt: price.startsAt ? new Date(price.startsAt).toISOString() : null,\n endsAt: price.endsAt ? new Date(price.endsAt).toISOString() : null,\n })),\n media: mediaAttachments.map((attachment) => ({\n id: attachment.id,\n fileName: attachment.fileName,\n mimeType: attachment.mimeType,\n fileSize: attachment.fileSize,\n url: attachment.url,\n })),\n unitConversions: unitConversions.map((row) => ({\n id: row.id,\n unitCode: row.unitCode,\n toBaseFactor: row.toBaseFactor,\n sortOrder: row.sortOrder,\n isActive: !!row.isActive,\n })),\n customFields,\n }\n }\n\n return {\n found: true as const,\n product: {\n id: product.id,\n title: product.title,\n subtitle: product.subtitle ?? null,\n description: product.description ?? null,\n sku: product.sku ?? null,\n handle: product.handle ?? null,\n productType: product.productType,\n statusEntryId: product.statusEntryId ?? null,\n primaryCurrencyCode: product.primaryCurrencyCode ?? null,\n taxRate: product.taxRate ?? null,\n taxRateId: product.taxRateId ?? null,\n defaultUnit: product.defaultUnit ?? null,\n defaultSalesUnit: product.defaultSalesUnit ?? null,\n defaultSalesUnitQuantity: product.defaultSalesUnitQuantity ?? null,\n unitPriceEnabled: !!product.unitPriceEnabled,\n unitPriceReferenceUnit: product.unitPriceReferenceUnit ?? null,\n unitPriceBaseQuantity: product.unitPriceBaseQuantity ?? null,\n defaultMediaId: product.defaultMediaId ?? null,\n defaultMediaUrl: product.defaultMediaUrl ?? null,\n imageUrl: product.defaultMediaUrl ?? null,\n weightValue: product.weightValue ?? null,\n weightUnit: product.weightUnit ?? null,\n dimensions: product.dimensions ?? null,\n metadata: product.metadata ?? null,\n customFieldsetCode: product.customFieldsetCode ?? null,\n isConfigurable: !!product.isConfigurable,\n isActive: !!product.isActive,\n organizationId: product.organizationId ?? null,\n tenantId: product.tenantId ?? null,\n createdAt: product.createdAt ? new Date(product.createdAt).toISOString() : null,\n updatedAt: product.updatedAt ? new Date(product.updatedAt).toISOString() : null,\n },\n customFields,\n related,\n }\n },\n}\n\nexport const productsAiTools: CatalogAiToolDefinition[] = [listProductsTool, getProductTool]\n\nexport default productsAiTools\n"],
4
+ "sourcesContent": ["/**\n * `catalog.list_products` + `catalog.get_product` (Phase 1 WS-C, Step 3.10).\n *\n * Read-only tools scoped to `ctx.tenantId` + `ctx.organizationId`. Mutation\n * tools are deferred to Step 5.14 under the pending-action contract.\n *\n * Phase 3b of `.ai/specs/implemented/2026-04-27-ai-tools-api-backed-dry-refactor.md`:\n * `catalog.list_products` is now an API-backed wrapper over\n * `GET /api/catalog/products`. Tool name, schema, requiredFeatures, and\n * output shape are unchanged.\n */\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { z } from 'zod'\nimport { defineApiBackedAiTool } from '@open-mercato/ai-assistant/modules/ai_assistant/lib/api-backed-tool'\nimport type {\n AiApiOperationRequest,\n AiToolExecutionContext,\n} from '@open-mercato/ai-assistant/modules/ai_assistant/lib/ai-api-operation-runner'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { loadCustomFieldValues } from '@open-mercato/shared/lib/crud/custom-fields'\nimport { E } from '#generated/entities.ids.generated'\nimport { Attachment } from '@open-mercato/core/modules/attachments/data/entities'\nimport {\n CatalogProduct,\n CatalogProductCategoryAssignment,\n CatalogProductTagAssignment,\n CatalogProductTag,\n CatalogProductVariant,\n CatalogProductPrice,\n CatalogProductUnitConversion,\n} from '../data/entities'\nimport { assertTenantScope, type CatalogAiToolDefinition, type CatalogToolContext } from './types'\n\nfunction resolveEm(ctx: CatalogToolContext | AiToolExecutionContext): EntityManager {\n return ctx.container.resolve<EntityManager>('em')\n}\n\nfunction buildScope(ctx: CatalogToolContext | AiToolExecutionContext, tenantId: string) {\n return { tenantId, organizationId: ctx.organizationId }\n}\n\nconst listProductsInput = z\n .object({\n q: z.string().trim().min(1).optional().describe('Optional search text matched against title / subtitle / sku / handle.'),\n limit: z.number().int().min(1).max(100).optional().describe('Maximum rows to return (default 50, max 100).'),\n offset: z.number().int().min(0).optional().describe('Number of rows to skip (default 0).'),\n categoryId: z.string().uuid().optional().describe('Restrict to products assigned to this catalog category.'),\n tagIds: z.array(z.string().uuid()).optional().describe('Restrict to products carrying at least one of these tag ids.'),\n active: z.boolean().optional().describe('When true, only active (not archived) products are returned.'),\n })\n .passthrough()\n\ntype ListProductsInput = z.infer<typeof listProductsInput>\n\ntype ListProductsApiItem = {\n id?: string\n title?: string | null\n subtitle?: string | null\n sku?: string | null\n handle?: string | null\n product_type?: string | null\n productType?: string | null\n status_entry_id?: string | null\n statusEntryId?: string | null\n primary_currency_code?: string | null\n primaryCurrencyCode?: string | null\n default_media_id?: string | null\n defaultMediaId?: string | null\n default_media_url?: string | null\n defaultMediaUrl?: string | null\n is_active?: boolean | null\n isActive?: boolean | null\n is_configurable?: boolean | null\n isConfigurable?: boolean | null\n organization_id?: string | null\n organizationId?: string | null\n tenant_id?: string | null\n tenantId?: string | null\n created_at?: string | null\n createdAt?: string | null\n updated_at?: string | null\n updatedAt?: string | null\n}\n\ntype ListProductsApiResponse = {\n items?: ListProductsApiItem[]\n total?: number\n}\n\ntype ListProductsOutput = {\n items: Array<Record<string, unknown>>\n total: number\n limit: number\n offset: number\n}\n\nconst listProductsTool = defineApiBackedAiTool<\n ListProductsInput,\n ListProductsApiResponse,\n ListProductsOutput\n>({\n name: 'catalog.list_products',\n displayName: 'List products',\n description:\n 'Search / list catalog products for the caller tenant + organization. Returns { items, total, limit, offset }.',\n inputSchema: listProductsInput,\n requiredFeatures: ['catalog.products.view'],\n toOperation: (input, ctx) => {\n assertTenantScope(ctx as unknown as CatalogToolContext)\n const limit = input.limit ?? 50\n const offset = input.offset ?? 0\n const page = Math.floor(offset / limit) + 1\n\n const query: Record<string, string | number | boolean | null | undefined> = {\n page,\n pageSize: limit,\n }\n if (input.q?.trim()) query.search = input.q.trim()\n if (input.categoryId) query.categoryIds = input.categoryId\n if (input.tagIds && input.tagIds.length > 0) query.tagIds = input.tagIds.join(',')\n if (input.active === true) query.isActive = 'true'\n\n const operation: AiApiOperationRequest = {\n method: 'GET',\n path: '/catalog/products',\n query,\n }\n return operation\n },\n mapResponse: (response, input) => {\n const limit = input.limit ?? 50\n const offset = input.offset ?? 0\n const data = (response.data ?? {}) as ListProductsApiResponse\n const rawItems: ListProductsApiItem[] = Array.isArray(data.items) ? data.items : []\n return {\n items: rawItems.map((row) => {\n const createdAtRaw = row.created_at ?? row.createdAt ?? null\n const createdAt = createdAtRaw ? new Date(String(createdAtRaw)).toISOString() : null\n const updatedAtRaw = row.updated_at ?? row.updatedAt ?? null\n const updatedAt = updatedAtRaw ? new Date(String(updatedAtRaw)).toISOString() : null\n return {\n id: row.id,\n title: row.title ?? null,\n subtitle: row.subtitle ?? null,\n sku: row.sku ?? null,\n handle: row.handle ?? null,\n productType: row.product_type ?? row.productType ?? null,\n statusEntryId: row.status_entry_id ?? row.statusEntryId ?? null,\n primaryCurrencyCode: row.primary_currency_code ?? row.primaryCurrencyCode ?? null,\n defaultMediaId: row.default_media_id ?? row.defaultMediaId ?? null,\n defaultMediaUrl: row.default_media_url ?? row.defaultMediaUrl ?? null,\n imageUrl: row.default_media_url ?? row.defaultMediaUrl ?? null,\n isActive: !!(row.is_active ?? row.isActive),\n isConfigurable: !!(row.is_configurable ?? row.isConfigurable),\n organizationId: row.organization_id ?? row.organizationId ?? null,\n tenantId: row.tenant_id ?? row.tenantId ?? null,\n createdAt,\n updatedAt,\n }\n }),\n total: typeof data.total === 'number' ? data.total : 0,\n limit,\n offset,\n }\n },\n}) as unknown as CatalogAiToolDefinition\n\nconst getProductInput = z.object({\n productId: z.string().uuid().describe('Catalog product id (UUID).'),\n includeRelated: z\n .boolean()\n .optional()\n .describe(\n 'When true, include categories, tags, variants, prices (base + offers), media (metadata only), unit conversions, and custom fields (each related list capped at 100).',\n ),\n})\n\nconst getProductTool: CatalogAiToolDefinition = {\n name: 'catalog.get_product',\n displayName: 'Get product',\n description:\n 'Fetch a catalog product by id with core fields and (optionally) categories, tags, variants, prices, media metadata, unit conversions, and custom fields. Returns { found: false } when the record is outside tenant/org scope or missing.',\n inputSchema: getProductInput,\n requiredFeatures: ['catalog.products.view'],\n tags: ['read', 'catalog'],\n handler: async (rawInput, ctx) => {\n const { tenantId } = assertTenantScope(ctx)\n const input = getProductInput.parse(rawInput)\n const em = resolveEm(ctx)\n const where: Record<string, unknown> = {\n id: input.productId,\n tenantId,\n deletedAt: null,\n }\n if (ctx.organizationId) where.organizationId = ctx.organizationId\n const product = await findOneWithDecryption<CatalogProduct>(\n em,\n CatalogProduct,\n where as any,\n undefined,\n buildScope(ctx, tenantId),\n )\n if (!product || product.tenantId !== tenantId) {\n return { found: false as const, productId: input.productId }\n }\n\n let related: Record<string, unknown> | null = null\n let customFields: Record<string, unknown> = {}\n if (input.includeRelated) {\n const scope = buildScope(ctx, tenantId)\n const [\n categoryAssignments,\n tagAssignments,\n variants,\n prices,\n mediaAttachments,\n unitConversions,\n customFieldValues,\n ] = await Promise.all([\n findWithDecryption<CatalogProductCategoryAssignment>(\n em,\n CatalogProductCategoryAssignment,\n { tenantId, product: product.id } as any,\n { limit: 100, populate: ['category'] as any } as any,\n scope,\n ),\n findWithDecryption<CatalogProductTagAssignment>(\n em,\n CatalogProductTagAssignment,\n { tenantId, product: product.id } as any,\n { limit: 100, populate: ['tag'] as any } as any,\n scope,\n ),\n findWithDecryption<CatalogProductVariant>(\n em,\n CatalogProductVariant,\n { tenantId, product: product.id, deletedAt: null } as any,\n { limit: 100, orderBy: { createdAt: 'asc' } as any } as any,\n scope,\n ),\n findWithDecryption<CatalogProductPrice>(\n em,\n CatalogProductPrice,\n { tenantId, product: product.id } as any,\n { limit: 100, orderBy: { createdAt: 'asc' } as any } as any,\n scope,\n ),\n findWithDecryption<Attachment>(\n em,\n Attachment,\n { tenantId, entityId: E.catalog.catalog_product, recordId: product.id } as any,\n { limit: 100, orderBy: { createdAt: 'asc' } as any } as any,\n scope,\n ),\n findWithDecryption<CatalogProductUnitConversion>(\n em,\n CatalogProductUnitConversion,\n { tenantId, product: product.id, deletedAt: null } as any,\n { limit: 100, orderBy: { sortOrder: 'asc', createdAt: 'asc' } as any } as any,\n scope,\n ),\n loadCustomFieldValues({\n em,\n entityId: E.catalog.catalog_product,\n recordIds: [product.id],\n tenantIdByRecord: { [product.id]: product.tenantId ?? null },\n organizationIdByRecord: { [product.id]: product.organizationId ?? null },\n tenantFallbacks: [product.tenantId ?? tenantId].filter((value): value is string => !!value),\n }),\n ])\n customFields = customFieldValues[product.id] ?? {}\n related = {\n categories: categoryAssignments\n .map((assignment) => {\n const category = (assignment as any).category\n if (!category || typeof category === 'string') {\n const fallbackId = typeof category === 'string' ? category : null\n return fallbackId ? { id: fallbackId, name: null, slug: null } : null\n }\n return {\n id: category.id,\n name: category.name ?? null,\n slug: category.slug ?? null,\n }\n })\n .filter((value): value is { id: string; name: string | null; slug: string | null } => value !== null),\n tags: tagAssignments\n .map((assignment) => {\n const tag = (assignment as any).tag as CatalogProductTag | string | null\n if (!tag || typeof tag === 'string') return null\n return { id: tag.id, label: tag.label, slug: tag.slug }\n })\n .filter((value): value is { id: string; label: string; slug: string } => value !== null),\n variants: variants.map((variant) => ({\n id: variant.id,\n name: variant.name ?? null,\n sku: variant.sku ?? null,\n barcode: variant.barcode ?? null,\n optionValues: variant.optionValues ?? null,\n defaultMediaId: variant.defaultMediaId ?? null,\n defaultMediaUrl: variant.defaultMediaUrl ?? null,\n isDefault: !!variant.isDefault,\n isActive: !!variant.isActive,\n })),\n prices: prices.map((price) => ({\n id: price.id,\n priceKindId: (price as any).priceKind && typeof (price as any).priceKind === 'object'\n ? (price as any).priceKind.id\n : (price as any).priceKind ?? null,\n currencyCode: price.currencyCode,\n kind: price.kind,\n minQuantity: price.minQuantity,\n maxQuantity: price.maxQuantity ?? null,\n unitPriceNet: price.unitPriceNet ?? null,\n unitPriceGross: price.unitPriceGross ?? null,\n channelId: price.channelId ?? null,\n offerId: (price as any).offer && typeof (price as any).offer === 'object'\n ? (price as any).offer.id\n : (price as any).offer ?? null,\n variantId: (price as any).variant && typeof (price as any).variant === 'object'\n ? (price as any).variant.id\n : (price as any).variant ?? null,\n startsAt: price.startsAt ? new Date(price.startsAt).toISOString() : null,\n endsAt: price.endsAt ? new Date(price.endsAt).toISOString() : null,\n })),\n media: mediaAttachments.map((attachment) => ({\n id: attachment.id,\n fileName: attachment.fileName,\n mimeType: attachment.mimeType,\n fileSize: attachment.fileSize,\n url: attachment.url,\n })),\n unitConversions: unitConversions.map((row) => ({\n id: row.id,\n unitCode: row.unitCode,\n toBaseFactor: row.toBaseFactor,\n sortOrder: row.sortOrder,\n isActive: !!row.isActive,\n })),\n customFields,\n }\n }\n\n return {\n found: true as const,\n product: {\n id: product.id,\n title: product.title,\n subtitle: product.subtitle ?? null,\n description: product.description ?? null,\n sku: product.sku ?? null,\n handle: product.handle ?? null,\n productType: product.productType,\n statusEntryId: product.statusEntryId ?? null,\n primaryCurrencyCode: product.primaryCurrencyCode ?? null,\n taxRate: product.taxRate ?? null,\n taxRateId: product.taxRateId ?? null,\n defaultUnit: product.defaultUnit ?? null,\n defaultSalesUnit: product.defaultSalesUnit ?? null,\n defaultSalesUnitQuantity: product.defaultSalesUnitQuantity ?? null,\n unitPriceEnabled: !!product.unitPriceEnabled,\n unitPriceReferenceUnit: product.unitPriceReferenceUnit ?? null,\n unitPriceBaseQuantity: product.unitPriceBaseQuantity ?? null,\n defaultMediaId: product.defaultMediaId ?? null,\n defaultMediaUrl: product.defaultMediaUrl ?? null,\n imageUrl: product.defaultMediaUrl ?? null,\n weightValue: product.weightValue ?? null,\n weightUnit: product.weightUnit ?? null,\n dimensions: product.dimensions ?? null,\n metadata: product.metadata ?? null,\n customFieldsetCode: product.customFieldsetCode ?? null,\n isConfigurable: !!product.isConfigurable,\n isActive: !!product.isActive,\n organizationId: product.organizationId ?? null,\n tenantId: product.tenantId ?? null,\n createdAt: product.createdAt ? new Date(product.createdAt).toISOString() : null,\n updatedAt: product.updatedAt ? new Date(product.updatedAt).toISOString() : null,\n },\n customFields,\n related,\n }\n },\n}\n\nexport const productsAiTools: CatalogAiToolDefinition[] = [listProductsTool, getProductTool]\n\nexport default productsAiTools\n"],
5
5
  "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/catalog/ai-tools/variants-pack.ts"],
4
- "sourcesContent": ["/**\n * `catalog.list_variants` (Phase 1 WS-C, Step 3.10).\n *\n * Enumerate variants for a single product with option values + media refs.\n *\n * Phase 3b of `.ai/specs/2026-04-27-ai-tools-api-backed-dry-refactor.md`:\n * `catalog.list_variants` is now an API-backed wrapper over\n * `GET /api/catalog/variants`. Tool name, schema, requiredFeatures, and\n * output shape are unchanged.\n */\nimport { z } from 'zod'\nimport { defineApiBackedAiTool } from '@open-mercato/ai-assistant/modules/ai_assistant/lib/api-backed-tool'\nimport type {\n AiApiOperationRequest,\n AiToolExecutionContext,\n} from '@open-mercato/ai-assistant/modules/ai_assistant/lib/ai-api-operation-runner'\nimport { assertTenantScope, type CatalogAiToolDefinition, type CatalogToolContext } from './types'\n\nconst listVariantsInput = z\n .object({\n productId: z.string().uuid().describe('Parent product id (UUID).'),\n limit: z.number().int().min(1).max(100).optional().describe('Max rows (default 50, max 100).'),\n offset: z.number().int().min(0).optional().describe('Rows to skip (default 0).'),\n })\n .passthrough()\n\ntype ListVariantsInput = z.infer<typeof listVariantsInput>\n\ntype ListVariantsApiItem = {\n id?: string\n product_id?: string | null\n productId?: string | null\n name?: string | null\n sku?: string | null\n barcode?: string | null\n status_entry_id?: string | null\n statusEntryId?: string | null\n option_values?: unknown\n optionValues?: unknown\n default_media_id?: string | null\n defaultMediaId?: string | null\n default_media_url?: string | null\n defaultMediaUrl?: string | null\n weight_value?: string | number | null\n weightValue?: string | number | null\n weight_unit?: string | null\n weightUnit?: string | null\n dimensions?: unknown\n tax_rate?: string | number | null\n taxRate?: string | number | null\n tax_rate_id?: string | null\n taxRateId?: string | null\n is_default?: boolean | null\n isDefault?: boolean | null\n is_active?: boolean | null\n isActive?: boolean | null\n organization_id?: string | null\n organizationId?: string | null\n tenant_id?: string | null\n tenantId?: string | null\n created_at?: string | null\n createdAt?: string | null\n}\n\ntype ListVariantsApiResponse = {\n items?: ListVariantsApiItem[]\n total?: number\n}\n\ntype ListVariantsOutput = {\n items: Array<Record<string, unknown>>\n total: number\n limit: number\n offset: number\n}\n\nconst listVariantsTool = defineApiBackedAiTool<\n ListVariantsInput,\n ListVariantsApiResponse,\n ListVariantsOutput\n>({\n name: 'catalog.list_variants',\n displayName: 'List variants',\n description:\n 'List the variants of a catalog product (including option values, SKU, barcode, default media ref). Returns { items, total, limit, offset }.',\n inputSchema: listVariantsInput,\n requiredFeatures: ['catalog.products.view'],\n toOperation: (input, ctx) => {\n assertTenantScope(ctx as unknown as CatalogToolContext)\n const limit = input.limit ?? 50\n const offset = input.offset ?? 0\n const page = Math.floor(offset / limit) + 1\n\n const query: Record<string, string | number | boolean | null | undefined> = {\n page,\n pageSize: limit,\n productId: input.productId,\n }\n\n const operation: AiApiOperationRequest = {\n method: 'GET',\n path: '/catalog/variants',\n query,\n }\n return operation\n },\n mapResponse: (response, input) => {\n const limit = input.limit ?? 50\n const offset = input.offset ?? 0\n const data = (response.data ?? {}) as ListVariantsApiResponse\n const rawItems: ListVariantsApiItem[] = Array.isArray(data.items) ? data.items : []\n return {\n items: rawItems.map((row) => {\n const createdAtRaw = row.created_at ?? row.createdAt ?? null\n const createdAt = createdAtRaw ? new Date(String(createdAtRaw)).toISOString() : null\n return {\n id: row.id,\n name: row.name ?? null,\n sku: row.sku ?? null,\n barcode: row.barcode ?? null,\n statusEntryId: row.status_entry_id ?? row.statusEntryId ?? null,\n optionValues: row.option_values ?? row.optionValues ?? null,\n defaultMediaId: row.default_media_id ?? row.defaultMediaId ?? null,\n defaultMediaUrl: row.default_media_url ?? row.defaultMediaUrl ?? null,\n weightValue: row.weight_value ?? row.weightValue ?? null,\n weightUnit: row.weight_unit ?? row.weightUnit ?? null,\n dimensions: row.dimensions ?? null,\n taxRate: row.tax_rate ?? row.taxRate ?? null,\n taxRateId: row.tax_rate_id ?? row.taxRateId ?? null,\n isDefault: !!(row.is_default ?? row.isDefault),\n isActive: !!(row.is_active ?? row.isActive),\n productId: row.product_id ?? row.productId ?? null,\n organizationId: row.organization_id ?? row.organizationId ?? null,\n tenantId: row.tenant_id ?? row.tenantId ?? null,\n createdAt,\n }\n }),\n total: typeof data.total === 'number' ? data.total : 0,\n limit,\n offset,\n }\n },\n}) as unknown as CatalogAiToolDefinition\n\nexport const variantsAiTools: CatalogAiToolDefinition[] = [listVariantsTool]\n\nexport default variantsAiTools\n"],
4
+ "sourcesContent": ["/**\n * `catalog.list_variants` (Phase 1 WS-C, Step 3.10).\n *\n * Enumerate variants for a single product with option values + media refs.\n *\n * Phase 3b of `.ai/specs/implemented/2026-04-27-ai-tools-api-backed-dry-refactor.md`:\n * `catalog.list_variants` is now an API-backed wrapper over\n * `GET /api/catalog/variants`. Tool name, schema, requiredFeatures, and\n * output shape are unchanged.\n */\nimport { z } from 'zod'\nimport { defineApiBackedAiTool } from '@open-mercato/ai-assistant/modules/ai_assistant/lib/api-backed-tool'\nimport type {\n AiApiOperationRequest,\n AiToolExecutionContext,\n} from '@open-mercato/ai-assistant/modules/ai_assistant/lib/ai-api-operation-runner'\nimport { assertTenantScope, type CatalogAiToolDefinition, type CatalogToolContext } from './types'\n\nconst listVariantsInput = z\n .object({\n productId: z.string().uuid().describe('Parent product id (UUID).'),\n limit: z.number().int().min(1).max(100).optional().describe('Max rows (default 50, max 100).'),\n offset: z.number().int().min(0).optional().describe('Rows to skip (default 0).'),\n })\n .passthrough()\n\ntype ListVariantsInput = z.infer<typeof listVariantsInput>\n\ntype ListVariantsApiItem = {\n id?: string\n product_id?: string | null\n productId?: string | null\n name?: string | null\n sku?: string | null\n barcode?: string | null\n status_entry_id?: string | null\n statusEntryId?: string | null\n option_values?: unknown\n optionValues?: unknown\n default_media_id?: string | null\n defaultMediaId?: string | null\n default_media_url?: string | null\n defaultMediaUrl?: string | null\n weight_value?: string | number | null\n weightValue?: string | number | null\n weight_unit?: string | null\n weightUnit?: string | null\n dimensions?: unknown\n tax_rate?: string | number | null\n taxRate?: string | number | null\n tax_rate_id?: string | null\n taxRateId?: string | null\n is_default?: boolean | null\n isDefault?: boolean | null\n is_active?: boolean | null\n isActive?: boolean | null\n organization_id?: string | null\n organizationId?: string | null\n tenant_id?: string | null\n tenantId?: string | null\n created_at?: string | null\n createdAt?: string | null\n}\n\ntype ListVariantsApiResponse = {\n items?: ListVariantsApiItem[]\n total?: number\n}\n\ntype ListVariantsOutput = {\n items: Array<Record<string, unknown>>\n total: number\n limit: number\n offset: number\n}\n\nconst listVariantsTool = defineApiBackedAiTool<\n ListVariantsInput,\n ListVariantsApiResponse,\n ListVariantsOutput\n>({\n name: 'catalog.list_variants',\n displayName: 'List variants',\n description:\n 'List the variants of a catalog product (including option values, SKU, barcode, default media ref). Returns { items, total, limit, offset }.',\n inputSchema: listVariantsInput,\n requiredFeatures: ['catalog.products.view'],\n toOperation: (input, ctx) => {\n assertTenantScope(ctx as unknown as CatalogToolContext)\n const limit = input.limit ?? 50\n const offset = input.offset ?? 0\n const page = Math.floor(offset / limit) + 1\n\n const query: Record<string, string | number | boolean | null | undefined> = {\n page,\n pageSize: limit,\n productId: input.productId,\n }\n\n const operation: AiApiOperationRequest = {\n method: 'GET',\n path: '/catalog/variants',\n query,\n }\n return operation\n },\n mapResponse: (response, input) => {\n const limit = input.limit ?? 50\n const offset = input.offset ?? 0\n const data = (response.data ?? {}) as ListVariantsApiResponse\n const rawItems: ListVariantsApiItem[] = Array.isArray(data.items) ? data.items : []\n return {\n items: rawItems.map((row) => {\n const createdAtRaw = row.created_at ?? row.createdAt ?? null\n const createdAt = createdAtRaw ? new Date(String(createdAtRaw)).toISOString() : null\n return {\n id: row.id,\n name: row.name ?? null,\n sku: row.sku ?? null,\n barcode: row.barcode ?? null,\n statusEntryId: row.status_entry_id ?? row.statusEntryId ?? null,\n optionValues: row.option_values ?? row.optionValues ?? null,\n defaultMediaId: row.default_media_id ?? row.defaultMediaId ?? null,\n defaultMediaUrl: row.default_media_url ?? row.defaultMediaUrl ?? null,\n weightValue: row.weight_value ?? row.weightValue ?? null,\n weightUnit: row.weight_unit ?? row.weightUnit ?? null,\n dimensions: row.dimensions ?? null,\n taxRate: row.tax_rate ?? row.taxRate ?? null,\n taxRateId: row.tax_rate_id ?? row.taxRateId ?? null,\n isDefault: !!(row.is_default ?? row.isDefault),\n isActive: !!(row.is_active ?? row.isActive),\n productId: row.product_id ?? row.productId ?? null,\n organizationId: row.organization_id ?? row.organizationId ?? null,\n tenantId: row.tenant_id ?? row.tenantId ?? null,\n createdAt,\n }\n }),\n total: typeof data.total === 'number' ? data.total : 0,\n limit,\n offset,\n }\n },\n}) as unknown as CatalogAiToolDefinition\n\nexport const variantsAiTools: CatalogAiToolDefinition[] = [listVariantsTool]\n\nexport default variantsAiTools\n"],
5
5
  "mappings": "AAUA,SAAS,SAAS;AAClB,SAAS,6BAA6B;AAKtC,SAAS,yBAAgF;AAEzF,MAAM,oBAAoB,EACvB,OAAO;AAAA,EACN,WAAW,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,2BAA2B;AAAA,EACjE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG,EAAE,SAAS,EAAE,SAAS,iCAAiC;AAAA,EAC7F,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,SAAS,EAAE,SAAS,2BAA2B;AACjF,CAAC,EACA,YAAY;AAoDf,MAAM,mBAAmB,sBAIvB;AAAA,EACA,MAAM;AAAA,EACN,aAAa;AAAA,EACb,aACE;AAAA,EACF,aAAa;AAAA,EACb,kBAAkB,CAAC,uBAAuB;AAAA,EAC1C,aAAa,CAAC,OAAO,QAAQ;AAC3B,sBAAkB,GAAoC;AACtD,UAAM,QAAQ,MAAM,SAAS;AAC7B,UAAM,SAAS,MAAM,UAAU;AAC/B,UAAM,OAAO,KAAK,MAAM,SAAS,KAAK,IAAI;AAE1C,UAAM,QAAsE;AAAA,MAC1E;AAAA,MACA,UAAU;AAAA,MACV,WAAW,MAAM;AAAA,IACnB;AAEA,UAAM,YAAmC;AAAA,MACvC,QAAQ;AAAA,MACR,MAAM;AAAA,MACN;AAAA,IACF;AACA,WAAO;AAAA,EACT;AAAA,EACA,aAAa,CAAC,UAAU,UAAU;AAChC,UAAM,QAAQ,MAAM,SAAS;AAC7B,UAAM,SAAS,MAAM,UAAU;AAC/B,UAAM,OAAQ,SAAS,QAAQ,CAAC;AAChC,UAAM,WAAkC,MAAM,QAAQ,KAAK,KAAK,IAAI,KAAK,QAAQ,CAAC;AAClF,WAAO;AAAA,MACL,OAAO,SAAS,IAAI,CAAC,QAAQ;AAC3B,cAAM,eAAe,IAAI,cAAc,IAAI,aAAa;AACxD,cAAM,YAAY,eAAe,IAAI,KAAK,OAAO,YAAY,CAAC,EAAE,YAAY,IAAI;AAChF,eAAO;AAAA,UACL,IAAI,IAAI;AAAA,UACR,MAAM,IAAI,QAAQ;AAAA,UAClB,KAAK,IAAI,OAAO;AAAA,UAChB,SAAS,IAAI,WAAW;AAAA,UACxB,eAAe,IAAI,mBAAmB,IAAI,iBAAiB;AAAA,UAC3D,cAAc,IAAI,iBAAiB,IAAI,gBAAgB;AAAA,UACvD,gBAAgB,IAAI,oBAAoB,IAAI,kBAAkB;AAAA,UAC9D,iBAAiB,IAAI,qBAAqB,IAAI,mBAAmB;AAAA,UACjE,aAAa,IAAI,gBAAgB,IAAI,eAAe;AAAA,UACpD,YAAY,IAAI,eAAe,IAAI,cAAc;AAAA,UACjD,YAAY,IAAI,cAAc;AAAA,UAC9B,SAAS,IAAI,YAAY,IAAI,WAAW;AAAA,UACxC,WAAW,IAAI,eAAe,IAAI,aAAa;AAAA,UAC/C,WAAW,CAAC,EAAE,IAAI,cAAc,IAAI;AAAA,UACpC,UAAU,CAAC,EAAE,IAAI,aAAa,IAAI;AAAA,UAClC,WAAW,IAAI,cAAc,IAAI,aAAa;AAAA,UAC9C,gBAAgB,IAAI,mBAAmB,IAAI,kBAAkB;AAAA,UAC7D,UAAU,IAAI,aAAa,IAAI,YAAY;AAAA,UAC3C;AAAA,QACF;AAAA,MACF,CAAC;AAAA,MACD,OAAO,OAAO,KAAK,UAAU,WAAW,KAAK,QAAQ;AAAA,MACrD;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACF,CAAC;AAEM,MAAM,kBAA6C,CAAC,gBAAgB;AAE3E,IAAO,wBAAQ;",
6
6
  "names": []
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/communication_channels/data/entities.ts"],
4
- "sourcesContent": ["import { OptionalProps } from '@mikro-orm/core'\nimport { Check, Entity, Index, PrimaryKey, Property, Unique } from '@mikro-orm/decorators/legacy'\n\n/**\n * Hub entities for the Communication Channels module.\n *\n * Cross-module references (e.g. `MessageChannelLink.messageId \u2192 messages.message.id`)\n * use plain `uuid` columns, NOT MikroORM `@ManyToOne` decorators. Project rule:\n * \"No direct ORM relationships between modules \u2014 use foreign key IDs, fetch separately.\"\n * Cross-module links are declared in `data/extensions.ts` via `EntityExtension`.\n *\n * See SPEC-045d \u00A72.2.\n */\n\n// \u2500\u2500 CommunicationChannel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Per-channel lifecycle status. Set by the polling worker / outbound subscriber\n * + the `markChannelRequiresReauth` command. Existing `isActive` remains for the\n * coarse admin enable/disable toggle; `status` is the finer-grained operational\n * state used by per-user reconnect flows.\n *\n * Email integration spec \u00A7 Hub Deltas \u2192 Delta 2.\n */\nexport type CommunicationChannelStatus =\n | 'connected'\n | 'requires_reauth'\n | 'error'\n | 'disconnected'\n\n@Entity({ tableName: 'communication_channels' })\n@Index({ name: 'communication_channels_tenant_provider_idx', properties: ['tenantId', 'providerKey'] })\n// Provider-push webhooks (Gmail Pub/Sub) resolve channels by (provider_key,\n// external_identifier) WITHOUT a tenant_id \u2014 they only know the mailbox address.\n// Without this index that lookup is a full scan over every channel of the\n// provider, which a (signature-verified) push or replay repeats on every hit.\n@Index({\n name: 'communication_channels_provider_external_idx',\n expression:\n `create index \"communication_channels_provider_external_idx\" on \"communication_channels\" (\"provider_key\", \"external_identifier\") where \"deleted_at\" is null`,\n})\n@Index({ name: 'communication_channels_tenant_type_active_idx', properties: ['tenantId', 'channelType', 'isActive'] })\n@Index({\n name: 'communication_channels_user_lookup_idx',\n properties: ['userId', 'channelType', 'deletedAt'],\n})\n@Index({\n name: 'communication_channels_poll_due_idx',\n expression:\n `create index \"communication_channels_poll_due_idx\" on \"communication_channels\" (\"is_active\", \"last_polled_at\") where \"deleted_at\" is null`,\n})\n@Index({\n name: 'communication_channels_one_primary_per_user_uq',\n expression:\n `create unique index \"communication_channels_one_primary_per_user_uq\" on \"communication_channels\" (\"user_id\") where \"is_primary\" and \"user_id\" is not null and \"deleted_at\" is null`,\n})\n// One channel per (tenant, user, provider, mailbox): a reconnect heals the\n// existing row in place (see `createConnectedChannelRow`) instead of inserting a\n// duplicate that would stay polled + keep its own push subscription. Partial so\n// tenant-wide channels (null user_id) and identifier-less rows are exempt.\n@Index({\n name: 'communication_channels_user_provider_external_uq',\n expression:\n `create unique index \"communication_channels_user_provider_external_uq\" on \"communication_channels\" (\"tenant_id\", \"user_id\", \"provider_key\", \"external_identifier\") where \"deleted_at\" is null and \"user_id\" is not null and \"external_identifier\" is not null`,\n})\nexport class CommunicationChannel {\n [OptionalProps]?:\n | 'createdAt'\n | 'updatedAt'\n | 'isActive'\n | 'capabilities'\n | 'deletedAt'\n | 'externalIdentifier'\n | 'credentialsRef'\n | 'organizationId'\n | 'userId'\n | 'isPrimary'\n | 'pollIntervalSeconds'\n | 'lastPolledAt'\n | 'status'\n | 'lastError'\n | 'channelState'\n\n @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })\n id!: string\n\n @Property({ name: 'provider_key', type: 'text' })\n providerKey!: string\n\n @Property({ name: 'channel_type', type: 'text' })\n channelType!: string\n\n @Property({ name: 'display_name', type: 'text' })\n displayName!: string\n\n @Property({ name: 'external_identifier', type: 'text', nullable: true })\n externalIdentifier?: string | null\n\n @Property({ name: 'credentials_ref', type: 'uuid', nullable: true })\n credentialsRef?: string | null\n\n @Property({ name: 'capabilities', type: 'json', nullable: true })\n capabilities?: Record<string, unknown> | null\n\n @Property({ name: 'is_active', type: 'boolean', default: true })\n isActive: boolean = true\n\n /**\n * Per-user channel owner. NULL = tenant-scoped (existing behaviour, e.g. WhatsApp Business).\n * Set = user-scoped (e.g. Jane's personal Gmail). Visible only to the owning user\n * and to admins with `communication_channels.admin`. Linked to `auth:user` via\n * `EntityExtension` in `data/extensions.ts` \u2014 never a raw DB FK.\n */\n @Property({ name: 'user_id', type: 'uuid', nullable: true })\n userId?: string | null\n\n /**\n * Per-user \"primary\" flag. Only meaningful when `userId IS NOT NULL`; ignored\n * for tenant-scoped channels. Enforced as one-primary-per-user by the partial\n * unique index `communication_channels_one_primary_per_user_uq`.\n */\n @Property({ name: 'is_primary', type: 'boolean', default: false })\n isPrimary: boolean = false\n\n /**\n * Polling cadence in seconds. NULL means \"this channel does not poll\" \u2014 i.e. it\n * is push-only (webhook) or its provider opted out via\n * `ChannelCapabilities.realtimePush !== false`. Set means hub-managed polling\n * at that interval via the `poll-tick` scheduler entry.\n */\n @Property({ name: 'poll_interval_seconds', type: 'int', nullable: true })\n pollIntervalSeconds?: number | null\n\n /** Last successful poll timestamp; the scheduler enumerates by this column. */\n @Property({ name: 'last_polled_at', type: Date, nullable: true })\n lastPolledAt?: Date | null\n\n /**\n * Per-channel lifecycle status. See {@link CommunicationChannelStatus}.\n * Migration sets `status = 'connected'` for all existing active channels (default value).\n */\n @Property({ name: 'status', type: 'text', default: 'connected' })\n status: CommunicationChannelStatus = 'connected'\n\n /** Most recent classified error message for diagnostics. */\n @Property({ name: 'last_error', type: 'text', nullable: true })\n lastError?: string | null\n\n /**\n * Provider-specific resumption state, opaque to the hub. Polling adapters\n * encode their incremental cursor here (Gmail historyId, IMAP\n * UIDVALIDITY+UIDNEXT). The polling worker reads it before each\n * `fetchHistory` call and writes the adapter's returned `nextCursor` back\n * after a successful poll. Empty / NULL means \"bootstrap on next poll\".\n */\n @Property({ name: 'channel_state', type: 'json', nullable: true })\n channelState?: Record<string, unknown> | null\n\n @Property({ name: 'tenant_id', type: 'uuid' })\n tenantId!: string\n\n @Property({ name: 'organization_id', type: 'uuid', nullable: true })\n organizationId?: string | null\n\n @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })\n createdAt: Date = new Date()\n\n @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })\n updatedAt: Date = new Date()\n\n @Property({ name: 'deleted_at', type: Date, nullable: true })\n deletedAt?: Date | null\n}\n\n// \u2500\u2500 ExternalConversation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n@Entity({ tableName: 'external_conversations' })\n@Index({ name: 'external_conversations_channel_idx', properties: ['channelId', 'externalConversationId'] })\n@Index({ name: 'external_conversations_contact_person_idx', properties: ['contactPersonId'] })\n@Index({ name: 'external_conversations_assigned_user_idx', properties: ['assignedUserId'] })\n@Unique({ name: 'external_conversations_channel_external_uq', properties: ['channelId', 'externalConversationId'] })\nexport class ExternalConversation {\n [OptionalProps]?: 'createdAt' | 'updatedAt' | 'lastMessageAt' | 'subject' | 'contactPersonId' | 'assignedUserId' | 'organizationId'\n\n @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })\n id!: string\n\n @Property({ name: 'channel_id', type: 'uuid' })\n channelId!: string\n\n @Property({ name: 'external_conversation_id', type: 'text' })\n externalConversationId!: string\n\n @Property({ name: 'subject', type: 'text', nullable: true })\n subject?: string | null\n\n @Property({ name: 'contact_person_id', type: 'uuid', nullable: true })\n contactPersonId?: string | null\n\n @Property({ name: 'assigned_user_id', type: 'uuid', nullable: true })\n assignedUserId?: string | null\n\n @Property({ name: 'last_message_at', type: Date, nullable: true })\n lastMessageAt?: Date | null\n\n @Property({ name: 'tenant_id', type: 'uuid' })\n tenantId!: string\n\n @Property({ name: 'organization_id', type: 'uuid', nullable: true })\n organizationId?: string | null\n\n @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })\n createdAt: Date = new Date()\n\n @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })\n updatedAt: Date = new Date()\n}\n\n// \u2500\u2500 ExternalMessage \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n@Entity({ tableName: 'external_messages' })\n@Index({ name: 'external_messages_conversation_idx', properties: ['conversationId'] })\n@Index({ name: 'external_messages_channel_external_idx', properties: ['channelId', 'externalMessageId'] })\n@Unique({ name: 'external_messages_channel_external_uq', properties: ['channelId', 'externalMessageId'] })\nexport class ExternalMessage {\n [OptionalProps]?: 'createdAt' | 'senderIdentifier' | 'senderDisplayName' | 'providerTimestamp' | 'organizationId'\n\n @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })\n id!: string\n\n @Property({ name: 'channel_id', type: 'uuid' })\n channelId!: string\n\n @Property({ name: 'conversation_id', type: 'uuid' })\n conversationId!: string\n\n @Property({ name: 'external_message_id', type: 'text' })\n externalMessageId!: string\n\n @Property({ name: 'direction', type: 'text' })\n direction!: 'inbound' | 'outbound'\n\n @Property({ name: 'sender_identifier', type: 'text', nullable: true })\n senderIdentifier?: string | null\n\n @Property({ name: 'sender_display_name', type: 'text', nullable: true })\n senderDisplayName?: string | null\n\n @Property({ name: 'provider_timestamp', type: Date, nullable: true })\n providerTimestamp?: Date | null\n\n @Property({ name: 'tenant_id', type: 'uuid' })\n tenantId!: string\n\n @Property({ name: 'organization_id', type: 'uuid', nullable: true })\n organizationId?: string | null\n\n @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })\n createdAt: Date = new Date()\n}\n\n// \u2500\u2500 MessageChannelLink \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n@Entity({ tableName: 'message_channel_links' })\n@Index({ name: 'message_channel_links_message_idx', properties: ['messageId'] })\n@Index({ name: 'message_channel_links_ext_conv_idx', properties: ['externalConversationId'] })\n@Index({ name: 'message_channel_links_ext_msg_idx', properties: ['externalMessageId'] })\n@Unique({ name: 'message_channel_links_message_uq', properties: ['messageId'] })\nexport class MessageChannelLink {\n [OptionalProps]?: 'createdAt' | 'deliveryStatus' | 'externalMessageId' | 'channelPayload' | 'channelContentType' | 'interactiveState' | 'channelMetadata' | 'organizationId'\n\n @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })\n id!: string\n\n /** Logical link to messages.message.id (no DB FK \u2014 cross-module via EntityExtension). */\n @Property({ name: 'message_id', type: 'uuid' })\n messageId!: string\n\n /** FK to external_conversations.id (intra-module \u2014 DB FK acceptable but kept as plain uuid for symmetry). */\n @Property({ name: 'external_conversation_id', type: 'uuid' })\n externalConversationId!: string\n\n @Property({ name: 'external_message_id', type: 'uuid', nullable: true })\n externalMessageId?: string | null\n\n @Property({ name: 'provider_key', type: 'text' })\n providerKey!: string\n\n @Property({ name: 'channel_type', type: 'text' })\n channelType!: string\n\n @Property({ name: 'direction', type: 'text' })\n direction!: 'inbound' | 'outbound'\n\n @Property({ name: 'delivery_status', type: 'text' })\n deliveryStatus: string = 'pending'\n\n @Property({ name: 'channel_payload', type: 'json', nullable: true })\n channelPayload?: Record<string, unknown> | null\n\n @Property({ name: 'channel_content_type', type: 'text', nullable: true })\n channelContentType?: string | null\n\n @Property({ name: 'interactive_state', type: 'json', nullable: true })\n interactiveState?: Record<string, unknown> | null\n\n @Property({ name: 'channel_metadata', type: 'json', nullable: true })\n channelMetadata?: Record<string, unknown> | null\n\n @Property({ name: 'tenant_id', type: 'uuid' })\n tenantId!: string\n\n @Property({ name: 'organization_id', type: 'uuid', nullable: true })\n organizationId?: string | null\n\n @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })\n createdAt: Date = new Date()\n}\n\n// \u2500\u2500 ChannelThreadMapping \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n@Entity({ tableName: 'channel_thread_mappings' })\n@Index({ name: 'channel_thread_mappings_ext_conv_idx', properties: ['externalConversationId', 'tenantId'] })\n@Index({ name: 'channel_thread_mappings_thread_idx', properties: ['messageThreadId', 'tenantId'] })\n@Unique({ name: 'channel_thread_mappings_ext_conv_uq', properties: ['externalConversationId', 'tenantId'] })\nexport class ChannelThreadMapping {\n [OptionalProps]?: 'createdAt' | 'updatedAt' | 'assignedUserId' | 'organizationId'\n\n @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })\n id!: string\n\n @Property({ name: 'external_conversation_id', type: 'uuid' })\n externalConversationId!: string\n\n /** Logical link to messages.message.thread_id (no DB FK \u2014 cross-module). */\n @Property({ name: 'message_thread_id', type: 'uuid' })\n messageThreadId!: string\n\n @Property({ name: 'channel_id', type: 'uuid' })\n channelId!: string\n\n @Property({ name: 'provider_key', type: 'text' })\n providerKey!: string\n\n @Property({ name: 'external_thread_ref', type: 'text' })\n externalThreadRef!: string\n\n /** Logical link to auth.user.id (no DB FK \u2014 cross-module). */\n @Property({ name: 'assigned_user_id', type: 'uuid', nullable: true })\n assignedUserId?: string | null\n\n @Property({ name: 'tenant_id', type: 'uuid' })\n tenantId!: string\n\n @Property({ name: 'organization_id', type: 'uuid', nullable: true })\n organizationId?: string | null\n\n @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })\n createdAt: Date = new Date()\n\n @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })\n updatedAt: Date = new Date()\n}\n\n// \u2500\u2500 MessageReaction \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n@Entity({ tableName: 'message_reactions' })\n@Index({ name: 'message_reactions_message_idx', properties: ['messageId'] })\n@Index({ name: 'message_reactions_message_emoji_idx', properties: ['messageId', 'emoji'] })\n@Index({\n name: 'message_reactions_internal_actor_uq',\n expression:\n `create unique index \"message_reactions_internal_actor_uq\" on \"message_reactions\" (\"tenant_id\", \"message_id\", \"emoji\", \"reacted_by_user_id\") where \"reacted_by_user_id\" is not null`,\n})\n@Index({\n name: 'message_reactions_external_actor_uq',\n expression:\n `create unique index \"message_reactions_external_actor_uq\" on \"message_reactions\" (\"tenant_id\", \"message_id\", \"emoji\", \"reacted_by_external_id\") where \"reacted_by_external_id\" is not null`,\n})\n@Check({\n name: 'message_reactions_exactly_one_actor_chk',\n expression: `(\"reacted_by_user_id\" is null) <> (\"reacted_by_external_id\" is null)`,\n})\nexport class MessageReaction {\n [OptionalProps]?: 'createdAt' | 'reactedByUserId' | 'reactedByExternalId' | 'reactedByDisplayName' | 'providerKey' | 'externalReactionId' | 'organizationId'\n\n @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })\n id!: string\n\n /** Logical link to messages.message.id (no DB FK \u2014 cross-module). */\n @Property({ name: 'message_id', type: 'uuid' })\n messageId!: string\n\n @Property({ name: 'emoji', type: 'text' })\n emoji!: string\n\n /** Logical link to auth.user.id (no DB FK \u2014 cross-module). NULL for external reactions. */\n @Property({ name: 'reacted_by_user_id', type: 'uuid', nullable: true })\n reactedByUserId?: string | null\n\n @Property({ name: 'reacted_by_external_id', type: 'text', nullable: true })\n reactedByExternalId?: string | null\n\n @Property({ name: 'reacted_by_display_name', type: 'text', nullable: true })\n reactedByDisplayName?: string | null\n\n @Property({ name: 'provider_key', type: 'text', nullable: true })\n providerKey?: string | null\n\n @Property({ name: 'external_reaction_id', type: 'text', nullable: true })\n externalReactionId?: string | null\n\n @Property({ name: 'tenant_id', type: 'uuid' })\n tenantId!: string\n\n @Property({ name: 'organization_id', type: 'uuid', nullable: true })\n organizationId?: string | null\n\n @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })\n createdAt: Date = new Date()\n}\n\n// \u2500\u2500 ChannelThreadToken \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Per-thread HMAC-signed opaque token used by the layered thread-matcher to\n * reliably attach inbound replies to the originating Open Mercato message\n * thread, even when the recipient's mail client strips RFC 5322 headers.\n *\n * Created lazily on the first outbound message in a thread by the\n * `outbound-bridge` subscriber. The token is injected into:\n * 1. The MIME `References:` header as `<om_TOKEN@open-mercato.invalid>` \u2014\n * invisible to the recipient and survives most reply clients.\n * 2. A hidden HTML body span `<span style=\"display:none\">[OM:om_TOKEN]</span>` \u2014\n * survives when References is stripped (e.g. some mobile clients).\n * 3. A plain-text trailer `[OM:om_TOKEN]` \u2014 survives plain-text-only replies.\n *\n * The unique constraint is `(tenantId, token)` \u2014 tenant isolation by\n * construction. HMAC verification (via `lib/thread-token.ts`) defends against\n * forged inbound messages: tokens that don't HMAC-verify never reach the DB\n * lookup.\n *\n * See `.ai/specs/2026-05-27-email-integration-inbound-reliability-and-threading.md`.\n */\n@Entity({ tableName: 'channel_thread_tokens' })\n// One token row per (tenant, thread): the matcher resolves every reply to the\n// same thread regardless of which outbound send minted the token. The unique\n// constraint also makes `getOrCreateThreadToken` race-safe (insert-on-conflict).\n@Unique({ name: 'channel_thread_tokens_thread_uq', properties: ['tenantId', 'messageThreadId'] })\n@Unique({ name: 'channel_thread_tokens_token_uq', properties: ['tenantId', 'token'] })\nexport class ChannelThreadToken {\n [OptionalProps]?: 'createdAt' | 'lastSeenAt' | 'organizationId'\n\n @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })\n id!: string\n\n @Property({ name: 'tenant_id', type: 'uuid' })\n tenantId!: string\n\n @Property({ name: 'organization_id', type: 'uuid', nullable: true })\n organizationId?: string | null\n\n /** Logical link to messages.message.thread_id (no DB FK \u2014 cross-module). */\n @Property({ name: 'message_thread_id', type: 'uuid' })\n messageThreadId!: string\n\n /**\n * HMAC-signed opaque token, format: `om_<22b64url>_<11b64url>` (16 random\n * bytes + 8 HMAC bytes, each base64url-encoded without padding), ~37 chars.\n */\n @Property({ name: 'token', type: 'text' })\n token!: string\n\n @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })\n createdAt: Date = new Date()\n\n /**\n * Updated whenever a thread-matcher token-based strategy resolves to this\n * row. Used for future GC: tokens with `last_seen_at < now() - 90 days`\n * are pruning candidates.\n */\n @Property({ name: 'last_seen_at', type: Date, nullable: true })\n lastSeenAt?: Date | null\n}\n\n// \u2500\u2500 ChannelIngestDeadLetter \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Inbound messages that fail permanently during ingest land here so an\n * operator can replay them after fixing parsers / schemas. Transient\n * failures (DB blip, network timeout) DO NOT write here \u2014 those abort the\n * poll loop without advancing the cursor so the message is re-fetched on\n * the next tick.\n *\n * `raw_body` is encrypted at rest via the module's `encryption.ts`\n * `defaultEncryptionMaps` entry (MIME bodies may contain PII).\n *\n * See `.ai/specs/2026-05-27-email-integration-inbound-reliability-and-threading.md`\n * (\u00A7 3 Data Model).\n */\n@Entity({ tableName: 'channel_ingest_dead_letters' })\n@Index({ name: 'channel_ingest_dead_letters_channel_idx', properties: ['channelId', 'tenantId'] })\n@Index({ name: 'channel_ingest_dead_letters_created_idx', properties: ['tenantId', 'createdAt'] })\nexport class ChannelIngestDeadLetter {\n [OptionalProps]?:\n | 'createdAt'\n | 'organizationId'\n | 'externalMessageId'\n | 'externalUid'\n | 'rawBody'\n\n @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })\n id!: string\n\n @Property({ name: 'tenant_id', type: 'uuid' })\n tenantId!: string\n\n @Property({ name: 'organization_id', type: 'uuid', nullable: true })\n organizationId?: string | null\n\n @Property({ name: 'channel_id', type: 'uuid' })\n channelId!: string\n\n @Property({ name: 'provider_key', type: 'text' })\n providerKey!: string\n\n /** External UID / sequence-number for the provider (e.g. IMAP UID, Gmail messageId). */\n @Property({ name: 'external_uid', type: 'text', nullable: true })\n externalUid?: string | null\n\n @Property({ name: 'external_message_id', type: 'text', nullable: true })\n externalMessageId?: string | null\n\n @Property({ name: 'error_class', type: 'text' })\n errorClass!: string\n\n @Property({ name: 'error_message', type: 'text' })\n errorMessage!: string\n\n /** Truncated source \u2014 first N bytes of the raw MIME / payload (encrypted at rest). */\n @Property({ name: 'raw_body', type: 'text', nullable: true })\n rawBody?: string | null\n\n @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })\n createdAt: Date = new Date()\n}\n"],
4
+ "sourcesContent": ["import { OptionalProps } from '@mikro-orm/core'\nimport { Check, Entity, Index, PrimaryKey, Property, Unique } from '@mikro-orm/decorators/legacy'\n\n/**\n * Hub entities for the Communication Channels module.\n *\n * Cross-module references (e.g. `MessageChannelLink.messageId \u2192 messages.message.id`)\n * use plain `uuid` columns, NOT MikroORM `@ManyToOne` decorators. Project rule:\n * \"No direct ORM relationships between modules \u2014 use foreign key IDs, fetch separately.\"\n * Cross-module links are declared in `data/extensions.ts` via `EntityExtension`.\n *\n * See SPEC-045d \u00A72.2.\n */\n\n// \u2500\u2500 CommunicationChannel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Per-channel lifecycle status. Set by the polling worker / outbound subscriber\n * + the `markChannelRequiresReauth` command. Existing `isActive` remains for the\n * coarse admin enable/disable toggle; `status` is the finer-grained operational\n * state used by per-user reconnect flows.\n *\n * Email integration spec \u00A7 Hub Deltas \u2192 Delta 2.\n */\nexport type CommunicationChannelStatus =\n | 'connected'\n | 'requires_reauth'\n | 'error'\n | 'disconnected'\n\n@Entity({ tableName: 'communication_channels' })\n@Index({ name: 'communication_channels_tenant_provider_idx', properties: ['tenantId', 'providerKey'] })\n// Provider-push webhooks (Gmail Pub/Sub) resolve channels by (provider_key,\n// external_identifier) WITHOUT a tenant_id \u2014 they only know the mailbox address.\n// Without this index that lookup is a full scan over every channel of the\n// provider, which a (signature-verified) push or replay repeats on every hit.\n@Index({\n name: 'communication_channels_provider_external_idx',\n expression:\n `create index \"communication_channels_provider_external_idx\" on \"communication_channels\" (\"provider_key\", \"external_identifier\") where \"deleted_at\" is null`,\n})\n@Index({ name: 'communication_channels_tenant_type_active_idx', properties: ['tenantId', 'channelType', 'isActive'] })\n@Index({\n name: 'communication_channels_user_lookup_idx',\n properties: ['userId', 'channelType', 'deletedAt'],\n})\n@Index({\n name: 'communication_channels_poll_due_idx',\n expression:\n `create index \"communication_channels_poll_due_idx\" on \"communication_channels\" (\"is_active\", \"last_polled_at\") where \"deleted_at\" is null`,\n})\n@Index({\n name: 'communication_channels_one_primary_per_user_uq',\n expression:\n `create unique index \"communication_channels_one_primary_per_user_uq\" on \"communication_channels\" (\"user_id\") where \"is_primary\" and \"user_id\" is not null and \"deleted_at\" is null`,\n})\n// One channel per (tenant, user, provider, mailbox): a reconnect heals the\n// existing row in place (see `createConnectedChannelRow`) instead of inserting a\n// duplicate that would stay polled + keep its own push subscription. Partial so\n// tenant-wide channels (null user_id) and identifier-less rows are exempt.\n@Index({\n name: 'communication_channels_user_provider_external_uq',\n expression:\n `create unique index \"communication_channels_user_provider_external_uq\" on \"communication_channels\" (\"tenant_id\", \"user_id\", \"provider_key\", \"external_identifier\") where \"deleted_at\" is null and \"user_id\" is not null and \"external_identifier\" is not null`,\n})\nexport class CommunicationChannel {\n [OptionalProps]?:\n | 'createdAt'\n | 'updatedAt'\n | 'isActive'\n | 'capabilities'\n | 'deletedAt'\n | 'externalIdentifier'\n | 'credentialsRef'\n | 'organizationId'\n | 'userId'\n | 'isPrimary'\n | 'pollIntervalSeconds'\n | 'lastPolledAt'\n | 'status'\n | 'lastError'\n | 'channelState'\n\n @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })\n id!: string\n\n @Property({ name: 'provider_key', type: 'text' })\n providerKey!: string\n\n @Property({ name: 'channel_type', type: 'text' })\n channelType!: string\n\n @Property({ name: 'display_name', type: 'text' })\n displayName!: string\n\n @Property({ name: 'external_identifier', type: 'text', nullable: true })\n externalIdentifier?: string | null\n\n @Property({ name: 'credentials_ref', type: 'uuid', nullable: true })\n credentialsRef?: string | null\n\n @Property({ name: 'capabilities', type: 'json', nullable: true })\n capabilities?: Record<string, unknown> | null\n\n @Property({ name: 'is_active', type: 'boolean', default: true })\n isActive: boolean = true\n\n /**\n * Per-user channel owner. NULL = tenant-scoped (existing behaviour, e.g. WhatsApp Business).\n * Set = user-scoped (e.g. Jane's personal Gmail). Visible only to the owning user\n * and to admins with `communication_channels.admin`. Linked to `auth:user` via\n * `EntityExtension` in `data/extensions.ts` \u2014 never a raw DB FK.\n */\n @Property({ name: 'user_id', type: 'uuid', nullable: true })\n userId?: string | null\n\n /**\n * Per-user \"primary\" flag. Only meaningful when `userId IS NOT NULL`; ignored\n * for tenant-scoped channels. Enforced as one-primary-per-user by the partial\n * unique index `communication_channels_one_primary_per_user_uq`.\n */\n @Property({ name: 'is_primary', type: 'boolean', default: false })\n isPrimary: boolean = false\n\n /**\n * Polling cadence in seconds. NULL means \"this channel does not poll\" \u2014 i.e. it\n * is push-only (webhook) or its provider opted out via\n * `ChannelCapabilities.realtimePush !== false`. Set means hub-managed polling\n * at that interval via the `poll-tick` scheduler entry.\n */\n @Property({ name: 'poll_interval_seconds', type: 'int', nullable: true })\n pollIntervalSeconds?: number | null\n\n /** Last successful poll timestamp; the scheduler enumerates by this column. */\n @Property({ name: 'last_polled_at', type: Date, nullable: true })\n lastPolledAt?: Date | null\n\n /**\n * Per-channel lifecycle status. See {@link CommunicationChannelStatus}.\n * Migration sets `status = 'connected'` for all existing active channels (default value).\n */\n @Property({ name: 'status', type: 'text', default: 'connected' })\n status: CommunicationChannelStatus = 'connected'\n\n /** Most recent classified error message for diagnostics. */\n @Property({ name: 'last_error', type: 'text', nullable: true })\n lastError?: string | null\n\n /**\n * Provider-specific resumption state, opaque to the hub. Polling adapters\n * encode their incremental cursor here (Gmail historyId, IMAP\n * UIDVALIDITY+UIDNEXT). The polling worker reads it before each\n * `fetchHistory` call and writes the adapter's returned `nextCursor` back\n * after a successful poll. Empty / NULL means \"bootstrap on next poll\".\n */\n @Property({ name: 'channel_state', type: 'json', nullable: true })\n channelState?: Record<string, unknown> | null\n\n @Property({ name: 'tenant_id', type: 'uuid' })\n tenantId!: string\n\n @Property({ name: 'organization_id', type: 'uuid', nullable: true })\n organizationId?: string | null\n\n @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })\n createdAt: Date = new Date()\n\n @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })\n updatedAt: Date = new Date()\n\n @Property({ name: 'deleted_at', type: Date, nullable: true })\n deletedAt?: Date | null\n}\n\n// \u2500\u2500 ExternalConversation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n@Entity({ tableName: 'external_conversations' })\n@Index({ name: 'external_conversations_channel_idx', properties: ['channelId', 'externalConversationId'] })\n@Index({ name: 'external_conversations_contact_person_idx', properties: ['contactPersonId'] })\n@Index({ name: 'external_conversations_assigned_user_idx', properties: ['assignedUserId'] })\n@Unique({ name: 'external_conversations_channel_external_uq', properties: ['channelId', 'externalConversationId'] })\nexport class ExternalConversation {\n [OptionalProps]?: 'createdAt' | 'updatedAt' | 'lastMessageAt' | 'subject' | 'contactPersonId' | 'assignedUserId' | 'organizationId'\n\n @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })\n id!: string\n\n @Property({ name: 'channel_id', type: 'uuid' })\n channelId!: string\n\n @Property({ name: 'external_conversation_id', type: 'text' })\n externalConversationId!: string\n\n @Property({ name: 'subject', type: 'text', nullable: true })\n subject?: string | null\n\n @Property({ name: 'contact_person_id', type: 'uuid', nullable: true })\n contactPersonId?: string | null\n\n @Property({ name: 'assigned_user_id', type: 'uuid', nullable: true })\n assignedUserId?: string | null\n\n @Property({ name: 'last_message_at', type: Date, nullable: true })\n lastMessageAt?: Date | null\n\n @Property({ name: 'tenant_id', type: 'uuid' })\n tenantId!: string\n\n @Property({ name: 'organization_id', type: 'uuid', nullable: true })\n organizationId?: string | null\n\n @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })\n createdAt: Date = new Date()\n\n @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })\n updatedAt: Date = new Date()\n}\n\n// \u2500\u2500 ExternalMessage \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n@Entity({ tableName: 'external_messages' })\n@Index({ name: 'external_messages_conversation_idx', properties: ['conversationId'] })\n@Index({ name: 'external_messages_channel_external_idx', properties: ['channelId', 'externalMessageId'] })\n@Unique({ name: 'external_messages_channel_external_uq', properties: ['channelId', 'externalMessageId'] })\nexport class ExternalMessage {\n [OptionalProps]?: 'createdAt' | 'senderIdentifier' | 'senderDisplayName' | 'providerTimestamp' | 'organizationId'\n\n @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })\n id!: string\n\n @Property({ name: 'channel_id', type: 'uuid' })\n channelId!: string\n\n @Property({ name: 'conversation_id', type: 'uuid' })\n conversationId!: string\n\n @Property({ name: 'external_message_id', type: 'text' })\n externalMessageId!: string\n\n @Property({ name: 'direction', type: 'text' })\n direction!: 'inbound' | 'outbound'\n\n @Property({ name: 'sender_identifier', type: 'text', nullable: true })\n senderIdentifier?: string | null\n\n @Property({ name: 'sender_display_name', type: 'text', nullable: true })\n senderDisplayName?: string | null\n\n @Property({ name: 'provider_timestamp', type: Date, nullable: true })\n providerTimestamp?: Date | null\n\n @Property({ name: 'tenant_id', type: 'uuid' })\n tenantId!: string\n\n @Property({ name: 'organization_id', type: 'uuid', nullable: true })\n organizationId?: string | null\n\n @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })\n createdAt: Date = new Date()\n}\n\n// \u2500\u2500 MessageChannelLink \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n@Entity({ tableName: 'message_channel_links' })\n@Index({ name: 'message_channel_links_message_idx', properties: ['messageId'] })\n@Index({ name: 'message_channel_links_ext_conv_idx', properties: ['externalConversationId'] })\n@Index({ name: 'message_channel_links_ext_msg_idx', properties: ['externalMessageId'] })\n@Unique({ name: 'message_channel_links_message_uq', properties: ['messageId'] })\nexport class MessageChannelLink {\n [OptionalProps]?: 'createdAt' | 'deliveryStatus' | 'externalMessageId' | 'channelPayload' | 'channelContentType' | 'interactiveState' | 'channelMetadata' | 'organizationId'\n\n @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })\n id!: string\n\n /** Logical link to messages.message.id (no DB FK \u2014 cross-module via EntityExtension). */\n @Property({ name: 'message_id', type: 'uuid' })\n messageId!: string\n\n /** FK to external_conversations.id (intra-module \u2014 DB FK acceptable but kept as plain uuid for symmetry). */\n @Property({ name: 'external_conversation_id', type: 'uuid' })\n externalConversationId!: string\n\n @Property({ name: 'external_message_id', type: 'uuid', nullable: true })\n externalMessageId?: string | null\n\n @Property({ name: 'provider_key', type: 'text' })\n providerKey!: string\n\n @Property({ name: 'channel_type', type: 'text' })\n channelType!: string\n\n @Property({ name: 'direction', type: 'text' })\n direction!: 'inbound' | 'outbound'\n\n @Property({ name: 'delivery_status', type: 'text' })\n deliveryStatus: string = 'pending'\n\n @Property({ name: 'channel_payload', type: 'json', nullable: true })\n channelPayload?: Record<string, unknown> | null\n\n @Property({ name: 'channel_content_type', type: 'text', nullable: true })\n channelContentType?: string | null\n\n @Property({ name: 'interactive_state', type: 'json', nullable: true })\n interactiveState?: Record<string, unknown> | null\n\n @Property({ name: 'channel_metadata', type: 'json', nullable: true })\n channelMetadata?: Record<string, unknown> | null\n\n @Property({ name: 'tenant_id', type: 'uuid' })\n tenantId!: string\n\n @Property({ name: 'organization_id', type: 'uuid', nullable: true })\n organizationId?: string | null\n\n @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })\n createdAt: Date = new Date()\n}\n\n// \u2500\u2500 ChannelThreadMapping \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n@Entity({ tableName: 'channel_thread_mappings' })\n@Index({ name: 'channel_thread_mappings_ext_conv_idx', properties: ['externalConversationId', 'tenantId'] })\n@Index({ name: 'channel_thread_mappings_thread_idx', properties: ['messageThreadId', 'tenantId'] })\n@Unique({ name: 'channel_thread_mappings_ext_conv_uq', properties: ['externalConversationId', 'tenantId'] })\nexport class ChannelThreadMapping {\n [OptionalProps]?: 'createdAt' | 'updatedAt' | 'assignedUserId' | 'organizationId'\n\n @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })\n id!: string\n\n @Property({ name: 'external_conversation_id', type: 'uuid' })\n externalConversationId!: string\n\n /** Logical link to messages.message.thread_id (no DB FK \u2014 cross-module). */\n @Property({ name: 'message_thread_id', type: 'uuid' })\n messageThreadId!: string\n\n @Property({ name: 'channel_id', type: 'uuid' })\n channelId!: string\n\n @Property({ name: 'provider_key', type: 'text' })\n providerKey!: string\n\n @Property({ name: 'external_thread_ref', type: 'text' })\n externalThreadRef!: string\n\n /** Logical link to auth.user.id (no DB FK \u2014 cross-module). */\n @Property({ name: 'assigned_user_id', type: 'uuid', nullable: true })\n assignedUserId?: string | null\n\n @Property({ name: 'tenant_id', type: 'uuid' })\n tenantId!: string\n\n @Property({ name: 'organization_id', type: 'uuid', nullable: true })\n organizationId?: string | null\n\n @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })\n createdAt: Date = new Date()\n\n @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })\n updatedAt: Date = new Date()\n}\n\n// \u2500\u2500 MessageReaction \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n@Entity({ tableName: 'message_reactions' })\n@Index({ name: 'message_reactions_message_idx', properties: ['messageId'] })\n@Index({ name: 'message_reactions_message_emoji_idx', properties: ['messageId', 'emoji'] })\n@Index({\n name: 'message_reactions_internal_actor_uq',\n expression:\n `create unique index \"message_reactions_internal_actor_uq\" on \"message_reactions\" (\"tenant_id\", \"message_id\", \"emoji\", \"reacted_by_user_id\") where \"reacted_by_user_id\" is not null`,\n})\n@Index({\n name: 'message_reactions_external_actor_uq',\n expression:\n `create unique index \"message_reactions_external_actor_uq\" on \"message_reactions\" (\"tenant_id\", \"message_id\", \"emoji\", \"reacted_by_external_id\") where \"reacted_by_external_id\" is not null`,\n})\n@Check({\n name: 'message_reactions_exactly_one_actor_chk',\n expression: `(\"reacted_by_user_id\" is null) <> (\"reacted_by_external_id\" is null)`,\n})\nexport class MessageReaction {\n [OptionalProps]?: 'createdAt' | 'reactedByUserId' | 'reactedByExternalId' | 'reactedByDisplayName' | 'providerKey' | 'externalReactionId' | 'organizationId'\n\n @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })\n id!: string\n\n /** Logical link to messages.message.id (no DB FK \u2014 cross-module). */\n @Property({ name: 'message_id', type: 'uuid' })\n messageId!: string\n\n @Property({ name: 'emoji', type: 'text' })\n emoji!: string\n\n /** Logical link to auth.user.id (no DB FK \u2014 cross-module). NULL for external reactions. */\n @Property({ name: 'reacted_by_user_id', type: 'uuid', nullable: true })\n reactedByUserId?: string | null\n\n @Property({ name: 'reacted_by_external_id', type: 'text', nullable: true })\n reactedByExternalId?: string | null\n\n @Property({ name: 'reacted_by_display_name', type: 'text', nullable: true })\n reactedByDisplayName?: string | null\n\n @Property({ name: 'provider_key', type: 'text', nullable: true })\n providerKey?: string | null\n\n @Property({ name: 'external_reaction_id', type: 'text', nullable: true })\n externalReactionId?: string | null\n\n @Property({ name: 'tenant_id', type: 'uuid' })\n tenantId!: string\n\n @Property({ name: 'organization_id', type: 'uuid', nullable: true })\n organizationId?: string | null\n\n @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })\n createdAt: Date = new Date()\n}\n\n// \u2500\u2500 ChannelThreadToken \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Per-thread HMAC-signed opaque token used by the layered thread-matcher to\n * reliably attach inbound replies to the originating Open Mercato message\n * thread, even when the recipient's mail client strips RFC 5322 headers.\n *\n * Created lazily on the first outbound message in a thread by the\n * `outbound-bridge` subscriber. The token is injected into:\n * 1. The MIME `References:` header as `<om_TOKEN@open-mercato.invalid>` \u2014\n * invisible to the recipient and survives most reply clients.\n * 2. A hidden HTML body span `<span style=\"display:none\">[OM:om_TOKEN]</span>` \u2014\n * survives when References is stripped (e.g. some mobile clients).\n * 3. A plain-text trailer `[OM:om_TOKEN]` \u2014 survives plain-text-only replies.\n *\n * The unique constraint is `(tenantId, token)` \u2014 tenant isolation by\n * construction. HMAC verification (via `lib/thread-token.ts`) defends against\n * forged inbound messages: tokens that don't HMAC-verify never reach the DB\n * lookup.\n *\n * See `.ai/specs/implemented/2026-05-27-email-integration-inbound-reliability-and-threading.md`.\n */\n@Entity({ tableName: 'channel_thread_tokens' })\n// One token row per (tenant, thread): the matcher resolves every reply to the\n// same thread regardless of which outbound send minted the token. The unique\n// constraint also makes `getOrCreateThreadToken` race-safe (insert-on-conflict).\n@Unique({ name: 'channel_thread_tokens_thread_uq', properties: ['tenantId', 'messageThreadId'] })\n@Unique({ name: 'channel_thread_tokens_token_uq', properties: ['tenantId', 'token'] })\nexport class ChannelThreadToken {\n [OptionalProps]?: 'createdAt' | 'lastSeenAt' | 'organizationId'\n\n @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })\n id!: string\n\n @Property({ name: 'tenant_id', type: 'uuid' })\n tenantId!: string\n\n @Property({ name: 'organization_id', type: 'uuid', nullable: true })\n organizationId?: string | null\n\n /** Logical link to messages.message.thread_id (no DB FK \u2014 cross-module). */\n @Property({ name: 'message_thread_id', type: 'uuid' })\n messageThreadId!: string\n\n /**\n * HMAC-signed opaque token, format: `om_<22b64url>_<11b64url>` (16 random\n * bytes + 8 HMAC bytes, each base64url-encoded without padding), ~37 chars.\n */\n @Property({ name: 'token', type: 'text' })\n token!: string\n\n @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })\n createdAt: Date = new Date()\n\n /**\n * Updated whenever a thread-matcher token-based strategy resolves to this\n * row. Used for future GC: tokens with `last_seen_at < now() - 90 days`\n * are pruning candidates.\n */\n @Property({ name: 'last_seen_at', type: Date, nullable: true })\n lastSeenAt?: Date | null\n}\n\n// \u2500\u2500 ChannelIngestDeadLetter \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Inbound messages that fail permanently during ingest land here so an\n * operator can replay them after fixing parsers / schemas. Transient\n * failures (DB blip, network timeout) DO NOT write here \u2014 those abort the\n * poll loop without advancing the cursor so the message is re-fetched on\n * the next tick.\n *\n * `raw_body` is encrypted at rest via the module's `encryption.ts`\n * `defaultEncryptionMaps` entry (MIME bodies may contain PII).\n *\n * See `.ai/specs/implemented/2026-05-27-email-integration-inbound-reliability-and-threading.md`\n * (\u00A7 3 Data Model).\n */\n@Entity({ tableName: 'channel_ingest_dead_letters' })\n@Index({ name: 'channel_ingest_dead_letters_channel_idx', properties: ['channelId', 'tenantId'] })\n@Index({ name: 'channel_ingest_dead_letters_created_idx', properties: ['tenantId', 'createdAt'] })\nexport class ChannelIngestDeadLetter {\n [OptionalProps]?:\n | 'createdAt'\n | 'organizationId'\n | 'externalMessageId'\n | 'externalUid'\n | 'rawBody'\n\n @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })\n id!: string\n\n @Property({ name: 'tenant_id', type: 'uuid' })\n tenantId!: string\n\n @Property({ name: 'organization_id', type: 'uuid', nullable: true })\n organizationId?: string | null\n\n @Property({ name: 'channel_id', type: 'uuid' })\n channelId!: string\n\n @Property({ name: 'provider_key', type: 'text' })\n providerKey!: string\n\n /** External UID / sequence-number for the provider (e.g. IMAP UID, Gmail messageId). */\n @Property({ name: 'external_uid', type: 'text', nullable: true })\n externalUid?: string | null\n\n @Property({ name: 'external_message_id', type: 'text', nullable: true })\n externalMessageId?: string | null\n\n @Property({ name: 'error_class', type: 'text' })\n errorClass!: string\n\n @Property({ name: 'error_message', type: 'text' })\n errorMessage!: string\n\n /** Truncated source \u2014 first N bytes of the raw MIME / payload (encrypted at rest). */\n @Property({ name: 'raw_body', type: 'text', nullable: true })\n rawBody?: string | null\n\n @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })\n createdAt: Date = new Date()\n}\n"],
5
5
  "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/modules/communication_channels/encryption.ts"],
4
- "sourcesContent": ["import type { ModuleEncryptionMap } from '@open-mercato/shared/modules/encryption'\n\n/**\n * Per-entity encryption maps for the communication_channels module.\n *\n * Credential encryption for `IntegrationCredentials.credentials` is owned by\n * the `integrations` module (see packages/core/src/modules/integrations/encryption.ts).\n *\n * `ChannelIngestDeadLetter.raw_body` holds truncated MIME bodies of\n * permanently-failed inbound messages. MIME bodies routinely contain PII\n * (sender/recipient addresses, quoted prior emails, attachments), so the\n * column is encrypted at rest. Reads use `findWithDecryption` per the\n * `packages/core/AGENTS.md` Encryption section.\n *\n * At-rest posture for the OTHER PII-bearing hub columns (deliberate, reviewed):\n * - The canonical email content is already encrypted in its PRIMARY stores \u2014\n * `messages.message` (subject/body/external_email+hash; see\n * messages/encryption.ts) and `customers.customer_interaction` (title/body).\n * - The hub keeps SECONDARY copies that are currently PLAINTEXT at rest:\n * `external_messages.sender_identifier`/`sender_display_name`,\n * `external_conversations.subject`, and `message_channel_links.channel_payload`.\n * The per-user access-control layer governs who can READ these; at-rest\n * encryption is a separate concern tracked as a follow-up (it requires\n * auditing every read path \u2014 the `_channelPayload` enricher and the\n * channel-payload renderer read these \u2014 to route through `findWithDecryption`).\n * - `message_channel_links.channel_metadata` MUST stay plaintext: the thread\n * matcher and sent-folder dedup query `channel_metadata->>'messageId'` BY\n * VALUE, and an encrypted column is not queryable by value (\u00A716 footgun).\n * - `external_messages.sender_identifier` similarly needs a deterministic\n * `*_hash` blind-index column before it can be encrypted, because inbound\n * contact resolution looks addresses up by value (see the address blind-index\n * follow-up in customers/lib/findPeopleByAddresses.ts).\n *\n * See `.ai/specs/2026-05-27-email-integration-inbound-reliability-and-threading.md`\n * (\u00A7 3 Encryption posture).\n */\nexport const defaultEncryptionMaps: ModuleEncryptionMap[] = [\n {\n entityId: 'communication_channels:channel_ingest_dead_letter',\n fields: [{ field: 'raw_body' }],\n },\n]\n\nexport default defaultEncryptionMaps\n"],
4
+ "sourcesContent": ["import type { ModuleEncryptionMap } from '@open-mercato/shared/modules/encryption'\n\n/**\n * Per-entity encryption maps for the communication_channels module.\n *\n * Credential encryption for `IntegrationCredentials.credentials` is owned by\n * the `integrations` module (see packages/core/src/modules/integrations/encryption.ts).\n *\n * `ChannelIngestDeadLetter.raw_body` holds truncated MIME bodies of\n * permanently-failed inbound messages. MIME bodies routinely contain PII\n * (sender/recipient addresses, quoted prior emails, attachments), so the\n * column is encrypted at rest. Reads use `findWithDecryption` per the\n * `packages/core/AGENTS.md` Encryption section.\n *\n * At-rest posture for the OTHER PII-bearing hub columns (deliberate, reviewed):\n * - The canonical email content is already encrypted in its PRIMARY stores \u2014\n * `messages.message` (subject/body/external_email+hash; see\n * messages/encryption.ts) and `customers.customer_interaction` (title/body).\n * - The hub keeps SECONDARY copies that are currently PLAINTEXT at rest:\n * `external_messages.sender_identifier`/`sender_display_name`,\n * `external_conversations.subject`, and `message_channel_links.channel_payload`.\n * The per-user access-control layer governs who can READ these; at-rest\n * encryption is a separate concern tracked as a follow-up (it requires\n * auditing every read path \u2014 the `_channelPayload` enricher and the\n * channel-payload renderer read these \u2014 to route through `findWithDecryption`).\n * - `message_channel_links.channel_metadata` MUST stay plaintext: the thread\n * matcher and sent-folder dedup query `channel_metadata->>'messageId'` BY\n * VALUE, and an encrypted column is not queryable by value (\u00A716 footgun).\n * - `external_messages.sender_identifier` similarly needs a deterministic\n * `*_hash` blind-index column before it can be encrypted, because inbound\n * contact resolution looks addresses up by value (see the address blind-index\n * follow-up in customers/lib/findPeopleByAddresses.ts).\n *\n * See `.ai/specs/implemented/2026-05-27-email-integration-inbound-reliability-and-threading.md`\n * (\u00A7 3 Encryption posture).\n */\nexport const defaultEncryptionMaps: ModuleEncryptionMap[] = [\n {\n entityId: 'communication_channels:channel_ingest_dead_letter',\n fields: [{ field: 'raw_body' }],\n },\n]\n\nexport default defaultEncryptionMaps\n"],
5
5
  "mappings": "AAoCO,MAAM,wBAA+C;AAAA,EAC1D;AAAA,IACE,UAAU;AAAA,IACV,QAAQ,CAAC,EAAE,OAAO,WAAW,CAAC;AAAA,EAChC;AACF;AAEA,IAAO,qBAAQ;",
6
6
  "names": []
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/communication_channels/lib/thread-matcher.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { ChannelThreadMapping, ChannelThreadToken, MessageChannelLink } from '../data/entities'\nimport { extractTokenFromBody, extractTokenFromHeaders } from './thread-token'\n\n/**\n * Layered thread-matching for inbound messages. Five ordered strategies;\n * first hit wins.\n *\n * 1. Token in References / In-Reply-To headers (high confidence)\n * 2. Token in body (high confidence)\n * 3. JWZ on Message-Id (medium confidence)\n * 4. Subject + participants (last 30 days) (low confidence)\n * 5. None \u2192 caller creates a new thread\n *\n * All DB queries are tenant-scoped. The matcher returns the resolved\n * thread id (or null) and lets the caller perform the actual ingest. It\n * never flushes the caller's unit of work: the only write is a scoped raw\n * `UPDATE` that bumps a matched token's `last_seen_at` (a future-GC hint),\n * which does not touch the caller's pending entities.\n *\n * See `.ai/specs/2026-05-27-email-integration-inbound-reliability-and-threading.md`\n * \u00A7 4 Threading Algorithm.\n */\n\nexport type ThreadMatchInput = {\n channelId: string\n tenantId: string\n organizationId: string | null\n\n /** RFC 5322 Message-ID of this inbound message (no angle brackets). */\n messageId: string | null\n /** In-Reply-To header value (no angle brackets). */\n inReplyTo: string | null\n /** References header, ordered root \u2192 most recent. */\n references: string[]\n\n /** Already-normalized subject is preferred; we re-normalize defensively. */\n subject: string\n fromAddress: string\n toAddresses: string[]\n ccAddresses: string[]\n\n bodyPlain: string | null\n bodyHtml: string | null\n\n receivedAt: Date\n}\n\nexport type ThreadMatchStrategy =\n | 'token-references'\n | 'token-body'\n | 'jwz-headers'\n | 'subject-participants'\n\nexport type ThreadMatchConfidence = 'high' | 'medium' | 'low'\n\nexport type ThreadMatch = {\n messageThreadId: string\n matchedBy: ThreadMatchStrategy\n confidence: ThreadMatchConfidence\n}\n\nexport type ThreadMatcherDeps = {\n em: EntityManager\n /** Stable reference for testing \u2014 defaults to `new Date()`. */\n now?: () => Date\n}\n\nconst SUBJECT_PREFIX_PATTERN = /^\\s*((?:re|fwd|fw|aw|wg|sv|tr|antw)\\s*[:\\-]\\s*|\\[[^\\]]+\\]\\s*)+/i\nconst PARTICIPANT_LOOKBACK_DAYS = 30\nconst MAX_REFERENCES_TO_SCAN = 40\n\n/**\n * Normalize a subject for low-confidence subject+participants matching:\n * - Trim whitespace\n * - Strip leading reply/forward prefixes (`Re:`, `RE:`, `Aw:`, `Fwd:`, `Tr:`, `WG:`, `Sv:`, etc.)\n * - Strip leading bracketed tags (`[EXTERNAL]`, `[Encrypted]`, `[SPAM?]`, \u2026)\n * - Repeat until the pattern no longer matches\n * - Lowercase the result\n *\n * Returns an empty string for `null`/empty input \u2014 the caller should\n * skip the subject+participants strategy in that case.\n */\nexport function normalizeSubject(subject: string | null | undefined): string {\n if (typeof subject !== 'string') return ''\n let current = subject.trim()\n // Loop until no prefix matches \u2014 handles `Re: Fwd: [EXTERNAL] Re: \u2026`.\n let safety = 16\n while (safety > 0 && SUBJECT_PREFIX_PATTERN.test(current)) {\n current = current.replace(SUBJECT_PREFIX_PATTERN, '').trim()\n safety -= 1\n }\n return current.toLowerCase()\n}\n\nexport async function matchThread(\n input: ThreadMatchInput,\n deps: ThreadMatcherDeps,\n): Promise<ThreadMatch | null> {\n const { em, now } = deps\n const tenantId = input.tenantId\n const dscope = { tenantId, organizationId: input.organizationId }\n\n // Strategy 1: token in References / In-Reply-To header.\n const headerToken = extractTokenFromHeaders(input.inReplyTo, input.references)\n if (headerToken) {\n const threadId = await resolveTokenThread(em, dscope, {\n tenantId,\n channelId: input.channelId,\n token: headerToken,\n now,\n })\n if (threadId) {\n return {\n messageThreadId: threadId,\n matchedBy: 'token-references',\n confidence: 'high',\n }\n }\n }\n\n // Strategy 2: token in body.\n const bodyToken = extractTokenFromBody(input.bodyHtml, input.bodyPlain)\n if (bodyToken) {\n const threadId = await resolveTokenThread(em, dscope, {\n tenantId,\n channelId: input.channelId,\n token: bodyToken,\n now,\n })\n if (threadId) {\n return {\n messageThreadId: threadId,\n matchedBy: 'token-body',\n confidence: 'high',\n }\n }\n }\n\n // Strategy 3: JWZ \u2014 find any MessageChannelLink in this channel whose\n // recorded `channelMetadata.messageId` matches In-Reply-To or any of the\n // References values. The first hit's `messages.message.threadId` is our\n // thread. Bounded by MAX_REFERENCES_TO_SCAN.\n const candidates = collectReferenceCandidates(input.inReplyTo, input.references)\n if (candidates.length > 0) {\n const jwzMatch = await findThreadByMessageIds(em, {\n tenantId,\n organizationId: input.organizationId,\n channelId: input.channelId,\n messageIds: candidates,\n })\n if (jwzMatch) {\n return {\n messageThreadId: jwzMatch,\n matchedBy: 'jwz-headers',\n confidence: 'medium',\n }\n }\n }\n\n // Strategy 4: subject + participants in the same channel within 30 days.\n // Low confidence \u2014 never used to overwrite a stronger token-based match\n // (we already returned above on hits). The caller may still choose to\n // create a new thread when confidence === 'low'.\n const normalizedSubject = normalizeSubject(input.subject)\n if (normalizedSubject.length > 0) {\n const cutoff = subtractDays((now ?? (() => new Date()))(), PARTICIPANT_LOOKBACK_DAYS)\n const participants = collectParticipants(input)\n if (participants.length > 0) {\n const subjectMatch = await findThreadBySubjectParticipants(em, {\n tenantId,\n organizationId: input.organizationId,\n channelId: input.channelId,\n normalizedSubject,\n participants,\n cutoff,\n })\n if (subjectMatch) {\n return {\n messageThreadId: subjectMatch,\n matchedBy: 'subject-participants',\n confidence: 'low',\n }\n }\n }\n }\n\n // No match \u2014 caller creates a new thread.\n return null\n}\n\n/**\n * Resolve a thread token to its `messageThreadId`, but ONLY when that thread\n * belongs to the channel that received the inbound message.\n *\n * The token is unguessable + HMAC-signed + tenant-scoped, so it cannot leak\n * across tenants. Within a tenant, though, the same external contact may\n * correspond with several users/channels \u2014 and a thread token that ends up in\n * a *different* mailbox (e.g. a forwarded thread) must not graft the inbound\n * message onto another channel's thread. The `ChannelThreadMapping`\n * (thread \u2194 channel) is the authoritative link; if there's no mapping joining\n * this thread to the receiving channel, we treat the token as a non-match and\n * let the lower-confidence strategies (or a fresh thread) take over.\n *\n * Returns the thread id on a verified hit (and bumps `last_seen_at`), else null.\n */\nasync function resolveTokenThread(\n em: EntityManager,\n dscope: { tenantId: string; organizationId: string | null },\n args: { tenantId: string; channelId: string; token: string; now?: () => Date },\n): Promise<string | null> {\n const row = await findOneWithDecryption(\n em,\n ChannelThreadToken,\n {\n tenantId: args.tenantId,\n organizationId: dscope.organizationId,\n token: args.token,\n },\n undefined,\n dscope,\n )\n if (!row) return null\n\n const mapping = await findOneWithDecryption(\n em,\n ChannelThreadMapping,\n {\n tenantId: args.tenantId,\n organizationId: dscope.organizationId,\n messageThreadId: row.messageThreadId,\n channelId: args.channelId,\n },\n undefined,\n dscope,\n )\n if (!mapping) return null\n\n // Bump `last_seen_at` (future-GC hint) via a scoped raw UPDATE rather than\n // `em.flush()`. A flush here would commit the ENTIRE unit of work, including\n // any pending mutations the caller (`ingest-inbound-message`, shared by all\n // provider adapters) holds \u2014 turning this \"pure\" matcher into a hidden commit\n // boundary. The raw UPDATE keeps the matcher side-effect-free w.r.t. the\n // caller's EntityManager.\n await em.getConnection().execute(\n `UPDATE channel_thread_tokens\n SET last_seen_at = ?\n WHERE id = ?\n AND tenant_id = ?\n AND ((?::uuid IS NULL AND organization_id IS NULL) OR organization_id = ?::uuid)`,\n [\n (args.now ?? (() => new Date()))(),\n row.id,\n args.tenantId,\n dscope.organizationId,\n dscope.organizationId,\n ],\n )\n return row.messageThreadId\n}\n\nfunction collectReferenceCandidates(\n inReplyTo: string | null,\n references: string[] | undefined,\n): string[] {\n const out: string[] = []\n const push = (value: string | null | undefined): void => {\n if (typeof value !== 'string') return\n const stripped = value.replace(/^<|>$/g, '').trim()\n if (stripped.length > 0) out.push(stripped)\n }\n push(inReplyTo)\n if (Array.isArray(references)) {\n for (const ref of references) push(ref)\n }\n return Array.from(new Set(out)).slice(0, MAX_REFERENCES_TO_SCAN)\n}\n\nasync function findThreadByMessageIds(\n em: EntityManager,\n args: { tenantId: string; organizationId: string | null; channelId: string; messageIds: string[] },\n): Promise<string | null> {\n if (args.messageIds.length === 0) return null\n // MikroORM v7 dropped the Knex builder; we use raw SQL via\n // `em.getConnection().execute()` with positional placeholders. The JSONB\n // lookup compares `channel_metadata->>'messageId'` against a Postgres\n // text array built from the candidate message-ids.\n // Escape backslash BEFORE quote \u2014 a Message-ID can legitimately contain `\\`\n // (RFC 5322), and an unescaped backslash yields a malformed Postgres array\n // literal that throws and silently defeats threading.\n const idArray = `{${args.messageIds\n .map((id) => `\"${id.replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"')}\"`)\n .join(',')}}`\n const rows = await em.getConnection().execute<Array<{ message_id: string }>>(\n `SELECT link.message_id FROM message_channel_links AS link\n INNER JOIN external_conversations AS conv\n ON conv.id = link.external_conversation_id\n WHERE link.tenant_id = ?\n AND ((?::uuid IS NULL AND link.organization_id IS NULL) OR link.organization_id = ?::uuid)\n AND conv.tenant_id = ?\n AND ((?::uuid IS NULL AND conv.organization_id IS NULL) OR conv.organization_id = ?::uuid)\n AND conv.channel_id = ?\n AND link.channel_metadata->>'messageId' = ANY(?::text[])\n LIMIT 1`,\n [\n args.tenantId,\n args.organizationId,\n args.organizationId,\n args.tenantId,\n args.organizationId,\n args.organizationId,\n args.channelId,\n idArray,\n ],\n )\n if (!rows || rows.length === 0) return null\n\n const messageId = rows[0].message_id as string\n // Translate `messages.message.id` to `messages.message.thread_id`. We\n // intentionally avoid importing the messages entity (cross-module rule).\n const threadRows = await em.getConnection().execute<Array<{ thread_id: string | null }>>(\n `SELECT thread_id FROM messages\n WHERE id = ?\n AND tenant_id = ?\n AND ((?::uuid IS NULL AND organization_id IS NULL) OR organization_id = ?::uuid)\n AND deleted_at IS NULL\n LIMIT 1`,\n [messageId, args.tenantId, args.organizationId, args.organizationId],\n )\n if (!threadRows || threadRows.length === 0) return null\n return threadRows[0].thread_id as string | null\n}\n\nfunction collectParticipants(input: ThreadMatchInput): string[] {\n const set = new Set<string>()\n const push = (value: string | null | undefined): void => {\n if (typeof value !== 'string') return\n const cleaned = value.trim().toLowerCase()\n if (cleaned.length > 0) set.add(cleaned)\n }\n push(input.fromAddress)\n for (const addr of input.toAddresses) push(addr)\n for (const addr of input.ccAddresses) push(addr)\n return Array.from(set)\n}\n\nasync function findThreadBySubjectParticipants(\n em: EntityManager,\n args: {\n tenantId: string\n organizationId: string | null\n channelId: string\n normalizedSubject: string\n participants: string[]\n cutoff: Date\n },\n): Promise<string | null> {\n // MikroORM v7 raw SQL \u2014 see `findThreadByMessageIds` for the rationale.\n // We look at recent links from this channel whose channelMetadata subject\n // (server-side normalized) equals the inbound subject AND share at least\n // one participant. The first match wins.\n const subjectLowerLike = args.normalizedSubject\n // Escape backslash before quote (see findThreadByMessageIds) \u2014 participant\n // addresses are attacker-influenced inbound header values.\n const participantList = `{${args.participants\n .map((p) => `\"${p.replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"')}\"`)\n .join(',')}}`\n const rows = await em.getConnection().execute<Array<{ thread_id: string | null }>>(\n `SELECT messages.thread_id\n FROM message_channel_links AS link\n INNER JOIN external_conversations AS conv\n ON conv.id = link.external_conversation_id\n INNER JOIN messages\n ON messages.id = link.message_id\n AND messages.tenant_id = link.tenant_id\n AND ((link.organization_id IS NULL AND messages.organization_id IS NULL) OR messages.organization_id = link.organization_id)\n AND messages.deleted_at IS NULL\n WHERE link.tenant_id = ?\n AND ((?::uuid IS NULL AND link.organization_id IS NULL) OR link.organization_id = ?::uuid)\n AND conv.tenant_id = ?\n AND ((?::uuid IS NULL AND conv.organization_id IS NULL) OR conv.organization_id = ?::uuid)\n AND conv.channel_id = ?\n AND link.created_at >= ?\n AND lower(regexp_replace(coalesce(link.channel_metadata->>'subject', ''),\n '^\\\\s*((re|fwd|fw|aw|wg|sv|tr|antw)\\\\s*[:\\\\-]\\\\s*|\\\\[[^\\\\]]+\\\\]\\\\s*)+',\n '',\n 'i'\n )) = ?\n AND (\n lower(coalesce(link.channel_metadata->>'from', '')) = ANY(?::text[])\n OR EXISTS (\n SELECT 1 FROM jsonb_array_elements_text(coalesce(link.channel_metadata->'to', '[]'::jsonb)) AS t(addr)\n WHERE lower(t.addr) = ANY(?::text[])\n )\n OR EXISTS (\n SELECT 1 FROM jsonb_array_elements_text(coalesce(link.channel_metadata->'cc', '[]'::jsonb)) AS t(addr)\n WHERE lower(t.addr) = ANY(?::text[])\n )\n )\n ORDER BY link.created_at DESC\n LIMIT 1`,\n [\n args.tenantId,\n args.organizationId,\n args.organizationId,\n args.tenantId,\n args.organizationId,\n args.organizationId,\n args.channelId,\n args.cutoff,\n subjectLowerLike,\n participantList,\n participantList,\n participantList,\n ],\n )\n if (!rows || rows.length === 0) return null\n return rows[0].thread_id as string | null\n}\n\nfunction subtractDays(from: Date, days: number): Date {\n const next = new Date(from)\n next.setUTCDate(next.getUTCDate() - days)\n return next\n}\n\n// Re-export `MessageChannelLink` so callers importing types from this module\n// don't have to reach into `data/entities.ts` indirectly.\nexport type { MessageChannelLink }\n"],
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { ChannelThreadMapping, ChannelThreadToken, MessageChannelLink } from '../data/entities'\nimport { extractTokenFromBody, extractTokenFromHeaders } from './thread-token'\n\n/**\n * Layered thread-matching for inbound messages. Five ordered strategies;\n * first hit wins.\n *\n * 1. Token in References / In-Reply-To headers (high confidence)\n * 2. Token in body (high confidence)\n * 3. JWZ on Message-Id (medium confidence)\n * 4. Subject + participants (last 30 days) (low confidence)\n * 5. None \u2192 caller creates a new thread\n *\n * All DB queries are tenant-scoped. The matcher returns the resolved\n * thread id (or null) and lets the caller perform the actual ingest. It\n * never flushes the caller's unit of work: the only write is a scoped raw\n * `UPDATE` that bumps a matched token's `last_seen_at` (a future-GC hint),\n * which does not touch the caller's pending entities.\n *\n * See `.ai/specs/implemented/2026-05-27-email-integration-inbound-reliability-and-threading.md`\n * \u00A7 4 Threading Algorithm.\n */\n\nexport type ThreadMatchInput = {\n channelId: string\n tenantId: string\n organizationId: string | null\n\n /** RFC 5322 Message-ID of this inbound message (no angle brackets). */\n messageId: string | null\n /** In-Reply-To header value (no angle brackets). */\n inReplyTo: string | null\n /** References header, ordered root \u2192 most recent. */\n references: string[]\n\n /** Already-normalized subject is preferred; we re-normalize defensively. */\n subject: string\n fromAddress: string\n toAddresses: string[]\n ccAddresses: string[]\n\n bodyPlain: string | null\n bodyHtml: string | null\n\n receivedAt: Date\n}\n\nexport type ThreadMatchStrategy =\n | 'token-references'\n | 'token-body'\n | 'jwz-headers'\n | 'subject-participants'\n\nexport type ThreadMatchConfidence = 'high' | 'medium' | 'low'\n\nexport type ThreadMatch = {\n messageThreadId: string\n matchedBy: ThreadMatchStrategy\n confidence: ThreadMatchConfidence\n}\n\nexport type ThreadMatcherDeps = {\n em: EntityManager\n /** Stable reference for testing \u2014 defaults to `new Date()`. */\n now?: () => Date\n}\n\nconst SUBJECT_PREFIX_PATTERN = /^\\s*((?:re|fwd|fw|aw|wg|sv|tr|antw)\\s*[:\\-]\\s*|\\[[^\\]]+\\]\\s*)+/i\nconst PARTICIPANT_LOOKBACK_DAYS = 30\nconst MAX_REFERENCES_TO_SCAN = 40\n\n/**\n * Normalize a subject for low-confidence subject+participants matching:\n * - Trim whitespace\n * - Strip leading reply/forward prefixes (`Re:`, `RE:`, `Aw:`, `Fwd:`, `Tr:`, `WG:`, `Sv:`, etc.)\n * - Strip leading bracketed tags (`[EXTERNAL]`, `[Encrypted]`, `[SPAM?]`, \u2026)\n * - Repeat until the pattern no longer matches\n * - Lowercase the result\n *\n * Returns an empty string for `null`/empty input \u2014 the caller should\n * skip the subject+participants strategy in that case.\n */\nexport function normalizeSubject(subject: string | null | undefined): string {\n if (typeof subject !== 'string') return ''\n let current = subject.trim()\n // Loop until no prefix matches \u2014 handles `Re: Fwd: [EXTERNAL] Re: \u2026`.\n let safety = 16\n while (safety > 0 && SUBJECT_PREFIX_PATTERN.test(current)) {\n current = current.replace(SUBJECT_PREFIX_PATTERN, '').trim()\n safety -= 1\n }\n return current.toLowerCase()\n}\n\nexport async function matchThread(\n input: ThreadMatchInput,\n deps: ThreadMatcherDeps,\n): Promise<ThreadMatch | null> {\n const { em, now } = deps\n const tenantId = input.tenantId\n const dscope = { tenantId, organizationId: input.organizationId }\n\n // Strategy 1: token in References / In-Reply-To header.\n const headerToken = extractTokenFromHeaders(input.inReplyTo, input.references)\n if (headerToken) {\n const threadId = await resolveTokenThread(em, dscope, {\n tenantId,\n channelId: input.channelId,\n token: headerToken,\n now,\n })\n if (threadId) {\n return {\n messageThreadId: threadId,\n matchedBy: 'token-references',\n confidence: 'high',\n }\n }\n }\n\n // Strategy 2: token in body.\n const bodyToken = extractTokenFromBody(input.bodyHtml, input.bodyPlain)\n if (bodyToken) {\n const threadId = await resolveTokenThread(em, dscope, {\n tenantId,\n channelId: input.channelId,\n token: bodyToken,\n now,\n })\n if (threadId) {\n return {\n messageThreadId: threadId,\n matchedBy: 'token-body',\n confidence: 'high',\n }\n }\n }\n\n // Strategy 3: JWZ \u2014 find any MessageChannelLink in this channel whose\n // recorded `channelMetadata.messageId` matches In-Reply-To or any of the\n // References values. The first hit's `messages.message.threadId` is our\n // thread. Bounded by MAX_REFERENCES_TO_SCAN.\n const candidates = collectReferenceCandidates(input.inReplyTo, input.references)\n if (candidates.length > 0) {\n const jwzMatch = await findThreadByMessageIds(em, {\n tenantId,\n organizationId: input.organizationId,\n channelId: input.channelId,\n messageIds: candidates,\n })\n if (jwzMatch) {\n return {\n messageThreadId: jwzMatch,\n matchedBy: 'jwz-headers',\n confidence: 'medium',\n }\n }\n }\n\n // Strategy 4: subject + participants in the same channel within 30 days.\n // Low confidence \u2014 never used to overwrite a stronger token-based match\n // (we already returned above on hits). The caller may still choose to\n // create a new thread when confidence === 'low'.\n const normalizedSubject = normalizeSubject(input.subject)\n if (normalizedSubject.length > 0) {\n const cutoff = subtractDays((now ?? (() => new Date()))(), PARTICIPANT_LOOKBACK_DAYS)\n const participants = collectParticipants(input)\n if (participants.length > 0) {\n const subjectMatch = await findThreadBySubjectParticipants(em, {\n tenantId,\n organizationId: input.organizationId,\n channelId: input.channelId,\n normalizedSubject,\n participants,\n cutoff,\n })\n if (subjectMatch) {\n return {\n messageThreadId: subjectMatch,\n matchedBy: 'subject-participants',\n confidence: 'low',\n }\n }\n }\n }\n\n // No match \u2014 caller creates a new thread.\n return null\n}\n\n/**\n * Resolve a thread token to its `messageThreadId`, but ONLY when that thread\n * belongs to the channel that received the inbound message.\n *\n * The token is unguessable + HMAC-signed + tenant-scoped, so it cannot leak\n * across tenants. Within a tenant, though, the same external contact may\n * correspond with several users/channels \u2014 and a thread token that ends up in\n * a *different* mailbox (e.g. a forwarded thread) must not graft the inbound\n * message onto another channel's thread. The `ChannelThreadMapping`\n * (thread \u2194 channel) is the authoritative link; if there's no mapping joining\n * this thread to the receiving channel, we treat the token as a non-match and\n * let the lower-confidence strategies (or a fresh thread) take over.\n *\n * Returns the thread id on a verified hit (and bumps `last_seen_at`), else null.\n */\nasync function resolveTokenThread(\n em: EntityManager,\n dscope: { tenantId: string; organizationId: string | null },\n args: { tenantId: string; channelId: string; token: string; now?: () => Date },\n): Promise<string | null> {\n const row = await findOneWithDecryption(\n em,\n ChannelThreadToken,\n {\n tenantId: args.tenantId,\n organizationId: dscope.organizationId,\n token: args.token,\n },\n undefined,\n dscope,\n )\n if (!row) return null\n\n const mapping = await findOneWithDecryption(\n em,\n ChannelThreadMapping,\n {\n tenantId: args.tenantId,\n organizationId: dscope.organizationId,\n messageThreadId: row.messageThreadId,\n channelId: args.channelId,\n },\n undefined,\n dscope,\n )\n if (!mapping) return null\n\n // Bump `last_seen_at` (future-GC hint) via a scoped raw UPDATE rather than\n // `em.flush()`. A flush here would commit the ENTIRE unit of work, including\n // any pending mutations the caller (`ingest-inbound-message`, shared by all\n // provider adapters) holds \u2014 turning this \"pure\" matcher into a hidden commit\n // boundary. The raw UPDATE keeps the matcher side-effect-free w.r.t. the\n // caller's EntityManager.\n await em.getConnection().execute(\n `UPDATE channel_thread_tokens\n SET last_seen_at = ?\n WHERE id = ?\n AND tenant_id = ?\n AND ((?::uuid IS NULL AND organization_id IS NULL) OR organization_id = ?::uuid)`,\n [\n (args.now ?? (() => new Date()))(),\n row.id,\n args.tenantId,\n dscope.organizationId,\n dscope.organizationId,\n ],\n )\n return row.messageThreadId\n}\n\nfunction collectReferenceCandidates(\n inReplyTo: string | null,\n references: string[] | undefined,\n): string[] {\n const out: string[] = []\n const push = (value: string | null | undefined): void => {\n if (typeof value !== 'string') return\n const stripped = value.replace(/^<|>$/g, '').trim()\n if (stripped.length > 0) out.push(stripped)\n }\n push(inReplyTo)\n if (Array.isArray(references)) {\n for (const ref of references) push(ref)\n }\n return Array.from(new Set(out)).slice(0, MAX_REFERENCES_TO_SCAN)\n}\n\nasync function findThreadByMessageIds(\n em: EntityManager,\n args: { tenantId: string; organizationId: string | null; channelId: string; messageIds: string[] },\n): Promise<string | null> {\n if (args.messageIds.length === 0) return null\n // MikroORM v7 dropped the Knex builder; we use raw SQL via\n // `em.getConnection().execute()` with positional placeholders. The JSONB\n // lookup compares `channel_metadata->>'messageId'` against a Postgres\n // text array built from the candidate message-ids.\n // Escape backslash BEFORE quote \u2014 a Message-ID can legitimately contain `\\`\n // (RFC 5322), and an unescaped backslash yields a malformed Postgres array\n // literal that throws and silently defeats threading.\n const idArray = `{${args.messageIds\n .map((id) => `\"${id.replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"')}\"`)\n .join(',')}}`\n const rows = await em.getConnection().execute<Array<{ message_id: string }>>(\n `SELECT link.message_id FROM message_channel_links AS link\n INNER JOIN external_conversations AS conv\n ON conv.id = link.external_conversation_id\n WHERE link.tenant_id = ?\n AND ((?::uuid IS NULL AND link.organization_id IS NULL) OR link.organization_id = ?::uuid)\n AND conv.tenant_id = ?\n AND ((?::uuid IS NULL AND conv.organization_id IS NULL) OR conv.organization_id = ?::uuid)\n AND conv.channel_id = ?\n AND link.channel_metadata->>'messageId' = ANY(?::text[])\n LIMIT 1`,\n [\n args.tenantId,\n args.organizationId,\n args.organizationId,\n args.tenantId,\n args.organizationId,\n args.organizationId,\n args.channelId,\n idArray,\n ],\n )\n if (!rows || rows.length === 0) return null\n\n const messageId = rows[0].message_id as string\n // Translate `messages.message.id` to `messages.message.thread_id`. We\n // intentionally avoid importing the messages entity (cross-module rule).\n const threadRows = await em.getConnection().execute<Array<{ thread_id: string | null }>>(\n `SELECT thread_id FROM messages\n WHERE id = ?\n AND tenant_id = ?\n AND ((?::uuid IS NULL AND organization_id IS NULL) OR organization_id = ?::uuid)\n AND deleted_at IS NULL\n LIMIT 1`,\n [messageId, args.tenantId, args.organizationId, args.organizationId],\n )\n if (!threadRows || threadRows.length === 0) return null\n return threadRows[0].thread_id as string | null\n}\n\nfunction collectParticipants(input: ThreadMatchInput): string[] {\n const set = new Set<string>()\n const push = (value: string | null | undefined): void => {\n if (typeof value !== 'string') return\n const cleaned = value.trim().toLowerCase()\n if (cleaned.length > 0) set.add(cleaned)\n }\n push(input.fromAddress)\n for (const addr of input.toAddresses) push(addr)\n for (const addr of input.ccAddresses) push(addr)\n return Array.from(set)\n}\n\nasync function findThreadBySubjectParticipants(\n em: EntityManager,\n args: {\n tenantId: string\n organizationId: string | null\n channelId: string\n normalizedSubject: string\n participants: string[]\n cutoff: Date\n },\n): Promise<string | null> {\n // MikroORM v7 raw SQL \u2014 see `findThreadByMessageIds` for the rationale.\n // We look at recent links from this channel whose channelMetadata subject\n // (server-side normalized) equals the inbound subject AND share at least\n // one participant. The first match wins.\n const subjectLowerLike = args.normalizedSubject\n // Escape backslash before quote (see findThreadByMessageIds) \u2014 participant\n // addresses are attacker-influenced inbound header values.\n const participantList = `{${args.participants\n .map((p) => `\"${p.replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"')}\"`)\n .join(',')}}`\n const rows = await em.getConnection().execute<Array<{ thread_id: string | null }>>(\n `SELECT messages.thread_id\n FROM message_channel_links AS link\n INNER JOIN external_conversations AS conv\n ON conv.id = link.external_conversation_id\n INNER JOIN messages\n ON messages.id = link.message_id\n AND messages.tenant_id = link.tenant_id\n AND ((link.organization_id IS NULL AND messages.organization_id IS NULL) OR messages.organization_id = link.organization_id)\n AND messages.deleted_at IS NULL\n WHERE link.tenant_id = ?\n AND ((?::uuid IS NULL AND link.organization_id IS NULL) OR link.organization_id = ?::uuid)\n AND conv.tenant_id = ?\n AND ((?::uuid IS NULL AND conv.organization_id IS NULL) OR conv.organization_id = ?::uuid)\n AND conv.channel_id = ?\n AND link.created_at >= ?\n AND lower(regexp_replace(coalesce(link.channel_metadata->>'subject', ''),\n '^\\\\s*((re|fwd|fw|aw|wg|sv|tr|antw)\\\\s*[:\\\\-]\\\\s*|\\\\[[^\\\\]]+\\\\]\\\\s*)+',\n '',\n 'i'\n )) = ?\n AND (\n lower(coalesce(link.channel_metadata->>'from', '')) = ANY(?::text[])\n OR EXISTS (\n SELECT 1 FROM jsonb_array_elements_text(coalesce(link.channel_metadata->'to', '[]'::jsonb)) AS t(addr)\n WHERE lower(t.addr) = ANY(?::text[])\n )\n OR EXISTS (\n SELECT 1 FROM jsonb_array_elements_text(coalesce(link.channel_metadata->'cc', '[]'::jsonb)) AS t(addr)\n WHERE lower(t.addr) = ANY(?::text[])\n )\n )\n ORDER BY link.created_at DESC\n LIMIT 1`,\n [\n args.tenantId,\n args.organizationId,\n args.organizationId,\n args.tenantId,\n args.organizationId,\n args.organizationId,\n args.channelId,\n args.cutoff,\n subjectLowerLike,\n participantList,\n participantList,\n participantList,\n ],\n )\n if (!rows || rows.length === 0) return null\n return rows[0].thread_id as string | null\n}\n\nfunction subtractDays(from: Date, days: number): Date {\n const next = new Date(from)\n next.setUTCDate(next.getUTCDate() - days)\n return next\n}\n\n// Re-export `MessageChannelLink` so callers importing types from this module\n// don't have to reach into `data/entities.ts` indirectly.\nexport type { MessageChannelLink }\n"],
5
5
  "mappings": "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",
6
6
  "names": []
7
7
  }