@open-mercato/core 0.6.8-develop.6983.1.e4f794099e → 0.6.8-develop.6985.1.fb93574faa

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 (597) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/generated/entities/notification/index.js +6 -0
  3. package/dist/generated/entities/notification/index.js.map +2 -2
  4. package/dist/generated/entities/notification_preference/index.js +19 -0
  5. package/dist/generated/entities/notification_preference/index.js.map +7 -0
  6. package/dist/generated/entities/notification_type/index.js +21 -0
  7. package/dist/generated/entities/notification_type/index.js.map +7 -0
  8. package/dist/generated/entities/notification_type_override/index.js +17 -0
  9. package/dist/generated/entities/notification_type_override/index.js.map +7 -0
  10. package/dist/generated/entities/push_notification_delivery/index.js +41 -0
  11. package/dist/generated/entities/push_notification_delivery/index.js.map +7 -0
  12. package/dist/generated/entities/user_device/index.js +35 -0
  13. package/dist/generated/entities/user_device/index.js.map +7 -0
  14. package/dist/generated/entities.ids.generated.js +12 -1
  15. package/dist/generated/entities.ids.generated.js.map +2 -2
  16. package/dist/generated/entity-fields-registry.js +72 -0
  17. package/dist/generated/entity-fields-registry.js.map +2 -2
  18. package/dist/helpers/integration/api.js +2 -1
  19. package/dist/helpers/integration/api.js.map +2 -2
  20. package/dist/helpers/integration/appRoot.js +9 -0
  21. package/dist/helpers/integration/appRoot.js.map +7 -0
  22. package/dist/helpers/integration/dbFixtures.js +1 -4
  23. package/dist/helpers/integration/dbFixtures.js.map +2 -2
  24. package/dist/helpers/integration/pushFake.js +126 -0
  25. package/dist/helpers/integration/pushFake.js.map +7 -0
  26. package/dist/modules/auth/notifications.js +16 -0
  27. package/dist/modules/auth/notifications.js.map +2 -2
  28. package/dist/modules/business_rules/notifications.js +2 -0
  29. package/dist/modules/business_rules/notifications.js.map +2 -2
  30. package/dist/modules/catalog/notifications.js +2 -0
  31. package/dist/modules/catalog/notifications.js.map +2 -2
  32. package/dist/modules/communication_channels/acl.js +9 -0
  33. package/dist/modules/communication_channels/acl.js.map +2 -2
  34. package/dist/modules/communication_channels/api/delete/admin/channels/[id]/route.js +133 -0
  35. package/dist/modules/communication_channels/api/delete/admin/channels/[id]/route.js.map +7 -0
  36. package/dist/modules/communication_channels/api/get/channels/[id]/health/route.js +6 -2
  37. package/dist/modules/communication_channels/api/get/channels/[id]/health/route.js.map +2 -2
  38. package/dist/modules/communication_channels/api/get/channels/[id]/route.js +6 -2
  39. package/dist/modules/communication_channels/api/get/channels/[id]/route.js.map +2 -2
  40. package/dist/modules/communication_channels/api/get/channels/route.js +2 -1
  41. package/dist/modules/communication_channels/api/get/channels/route.js.map +2 -2
  42. package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js +10 -5
  43. package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js.map +2 -2
  44. package/dist/modules/communication_channels/api/post/channels/connect/credentials/route.js +29 -1
  45. package/dist/modules/communication_channels/api/post/channels/connect/credentials/route.js.map +2 -2
  46. package/dist/modules/communication_channels/api/post/channels/connect/tenant-credentials/route.js +157 -0
  47. package/dist/modules/communication_channels/api/post/channels/connect/tenant-credentials/route.js.map +7 -0
  48. package/dist/modules/communication_channels/backend/communication_channels/channels/page.js +8 -1
  49. package/dist/modules/communication_channels/backend/communication_channels/channels/page.js.map +2 -2
  50. package/dist/modules/communication_channels/commands/admin-delete-channel.js +141 -0
  51. package/dist/modules/communication_channels/commands/admin-delete-channel.js.map +7 -0
  52. package/dist/modules/communication_channels/commands/connect-credential-channel.js +25 -8
  53. package/dist/modules/communication_channels/commands/connect-credential-channel.js.map +2 -2
  54. package/dist/modules/communication_channels/commands/deliver-outbound-message.js +2 -2
  55. package/dist/modules/communication_channels/commands/deliver-outbound-message.js.map +2 -2
  56. package/dist/modules/communication_channels/commands/push-register.js +1 -1
  57. package/dist/modules/communication_channels/commands/push-register.js.map +2 -2
  58. package/dist/modules/communication_channels/commands/push-unregister.js +4 -3
  59. package/dist/modules/communication_channels/commands/push-unregister.js.map +2 -2
  60. package/dist/modules/communication_channels/data/entities.js +4 -0
  61. package/dist/modules/communication_channels/data/entities.js.map +2 -2
  62. package/dist/modules/communication_channels/data/validators.js +2 -1
  63. package/dist/modules/communication_channels/data/validators.js.map +2 -2
  64. package/dist/modules/communication_channels/lib/access-control.js +11 -1
  65. package/dist/modules/communication_channels/lib/access-control.js.map +2 -2
  66. package/dist/modules/communication_channels/lib/connect-channel.js +20 -12
  67. package/dist/modules/communication_channels/lib/connect-channel.js.map +2 -2
  68. package/dist/modules/communication_channels/lib/push-adapter.js +68 -0
  69. package/dist/modules/communication_channels/lib/push-adapter.js.map +7 -0
  70. package/dist/modules/communication_channels/lib/push-capabilities.js +31 -0
  71. package/dist/modules/communication_channels/lib/push-capabilities.js.map +7 -0
  72. package/dist/modules/communication_channels/lib/push-connect-error.js +20 -0
  73. package/dist/modules/communication_channels/lib/push-connect-error.js.map +7 -0
  74. package/dist/modules/communication_channels/lib/push-credential-errors.js +41 -0
  75. package/dist/modules/communication_channels/lib/push-credential-errors.js.map +7 -0
  76. package/dist/modules/communication_channels/lib/push-envelope.js +46 -0
  77. package/dist/modules/communication_channels/lib/push-envelope.js.map +7 -0
  78. package/dist/modules/communication_channels/lib/refcounted-client-cache.js +64 -0
  79. package/dist/modules/communication_channels/lib/refcounted-client-cache.js.map +7 -0
  80. package/dist/modules/communication_channels/migrations/Migration20260703123630_communication_channels.js +13 -0
  81. package/dist/modules/communication_channels/migrations/Migration20260703123630_communication_channels.js.map +7 -0
  82. package/dist/modules/communication_channels/notifications.js +4 -0
  83. package/dist/modules/communication_channels/notifications.js.map +2 -2
  84. package/dist/modules/communication_channels/setup.js +2 -0
  85. package/dist/modules/communication_channels/setup.js.map +2 -2
  86. package/dist/modules/configs/lib/upgrade-actions.js +21 -0
  87. package/dist/modules/configs/lib/upgrade-actions.js.map +2 -2
  88. package/dist/modules/customer_accounts/notifications.js +12 -0
  89. package/dist/modules/customer_accounts/notifications.js.map +2 -2
  90. package/dist/modules/customers/notifications.js +4 -0
  91. package/dist/modules/customers/notifications.js.map +2 -2
  92. package/dist/modules/devices/acl.js +11 -0
  93. package/dist/modules/devices/acl.js.map +7 -0
  94. package/dist/modules/devices/api/[id]/route.js +167 -0
  95. package/dist/modules/devices/api/[id]/route.js.map +7 -0
  96. package/dist/modules/devices/api/admin/devices/[id]/route.js +219 -0
  97. package/dist/modules/devices/api/admin/devices/[id]/route.js.map +7 -0
  98. package/dist/modules/devices/api/admin/devices/route.js +140 -0
  99. package/dist/modules/devices/api/admin/devices/route.js.map +7 -0
  100. package/dist/modules/devices/api/auth.js +9 -0
  101. package/dist/modules/devices/api/auth.js.map +7 -0
  102. package/dist/modules/devices/api/deviceList.js +53 -0
  103. package/dist/modules/devices/api/deviceList.js.map +7 -0
  104. package/dist/modules/devices/api/deviceOps.js +118 -0
  105. package/dist/modules/devices/api/deviceOps.js.map +7 -0
  106. package/dist/modules/devices/api/openapi.js +19 -0
  107. package/dist/modules/devices/api/openapi.js.map +7 -0
  108. package/dist/modules/devices/api/route.js +143 -0
  109. package/dist/modules/devices/api/route.js.map +7 -0
  110. package/dist/modules/devices/backend/devices/[id]/page.js +135 -0
  111. package/dist/modules/devices/backend/devices/[id]/page.js.map +7 -0
  112. package/dist/modules/devices/backend/devices/[id]/page.meta.js +16 -0
  113. package/dist/modules/devices/backend/devices/[id]/page.meta.js.map +7 -0
  114. package/dist/modules/devices/backend/devices/create/page.js +68 -0
  115. package/dist/modules/devices/backend/devices/create/page.js.map +7 -0
  116. package/dist/modules/devices/backend/devices/create/page.meta.js +16 -0
  117. package/dist/modules/devices/backend/devices/create/page.meta.js.map +7 -0
  118. package/dist/modules/devices/backend/devices/page.js +235 -0
  119. package/dist/modules/devices/backend/devices/page.js.map +7 -0
  120. package/dist/modules/devices/backend/devices/page.meta.js +23 -0
  121. package/dist/modules/devices/backend/devices/page.meta.js.map +7 -0
  122. package/dist/modules/devices/commands/deactivate.js +107 -0
  123. package/dist/modules/devices/commands/deactivate.js.map +7 -0
  124. package/dist/modules/devices/commands/index.js +4 -0
  125. package/dist/modules/devices/commands/index.js.map +7 -0
  126. package/dist/modules/devices/commands/register.js +183 -0
  127. package/dist/modules/devices/commands/register.js.map +7 -0
  128. package/dist/modules/devices/commands/shared.js +74 -0
  129. package/dist/modules/devices/commands/shared.js.map +7 -0
  130. package/dist/modules/devices/commands/update.js +126 -0
  131. package/dist/modules/devices/commands/update.js.map +7 -0
  132. package/dist/modules/devices/data/entities.js +83 -0
  133. package/dist/modules/devices/data/entities.js.map +7 -0
  134. package/dist/modules/devices/data/validators.js +49 -0
  135. package/dist/modules/devices/data/validators.js.map +7 -0
  136. package/dist/modules/devices/di.js +11 -0
  137. package/dist/modules/devices/di.js.map +7 -0
  138. package/dist/modules/devices/encryption.js +12 -0
  139. package/dist/modules/devices/encryption.js.map +7 -0
  140. package/dist/modules/devices/events.js +19 -0
  141. package/dist/modules/devices/events.js.map +7 -0
  142. package/dist/modules/devices/index.js +17 -0
  143. package/dist/modules/devices/index.js.map +7 -0
  144. package/dist/modules/devices/lib/operationMetadata.js +20 -0
  145. package/dist/modules/devices/lib/operationMetadata.js.map +7 -0
  146. package/dist/modules/devices/migrations/Migration20260602120000.js +15 -0
  147. package/dist/modules/devices/migrations/Migration20260602120000.js.map +7 -0
  148. package/dist/modules/devices/migrations/Migration20260630120000.js +15 -0
  149. package/dist/modules/devices/migrations/Migration20260630120000.js.map +7 -0
  150. package/dist/modules/devices/migrations/Migration20260701120000.js +13 -0
  151. package/dist/modules/devices/migrations/Migration20260701120000.js.map +7 -0
  152. package/dist/modules/devices/migrations/Migration20260721120000.js +15 -0
  153. package/dist/modules/devices/migrations/Migration20260721120000.js.map +7 -0
  154. package/dist/modules/devices/setup.js +13 -0
  155. package/dist/modules/devices/setup.js.map +7 -0
  156. package/dist/modules/entities/cli.js +8 -3
  157. package/dist/modules/entities/cli.js.map +2 -2
  158. package/dist/modules/entities/migrations/Migration20260722120000.js +28 -0
  159. package/dist/modules/entities/migrations/Migration20260722120000.js.map +7 -0
  160. package/dist/modules/inbox_ops/notifications.js +2 -0
  161. package/dist/modules/inbox_ops/notifications.js.map +2 -2
  162. package/dist/modules/messages/notifications.js +2 -0
  163. package/dist/modules/messages/notifications.js.map +2 -2
  164. package/dist/modules/notifications/acl.js +3 -1
  165. package/dist/modules/notifications/acl.js.map +2 -2
  166. package/dist/modules/notifications/api/admin/preferences/route.js +167 -0
  167. package/dist/modules/notifications/api/admin/preferences/route.js.map +7 -0
  168. package/dist/modules/notifications/api/channels/route.js +54 -0
  169. package/dist/modules/notifications/api/channels/route.js.map +7 -0
  170. package/dist/modules/notifications/api/preferences/route.js +126 -0
  171. package/dist/modules/notifications/api/preferences/route.js.map +7 -0
  172. package/dist/modules/notifications/api/route.js +8 -1
  173. package/dist/modules/notifications/api/route.js.map +2 -2
  174. package/dist/modules/notifications/api/types/[id]/channels/[channel]/route.js +187 -0
  175. package/dist/modules/notifications/api/types/[id]/channels/[channel]/route.js.map +7 -0
  176. package/dist/modules/notifications/api/types/route.js +225 -0
  177. package/dist/modules/notifications/api/types/route.js.map +7 -0
  178. package/dist/modules/notifications/api/unread-count/route.js +5 -1
  179. package/dist/modules/notifications/api/unread-count/route.js.map +2 -2
  180. package/dist/modules/notifications/backend/notifications/user-preferences/page.js +10 -0
  181. package/dist/modules/notifications/backend/notifications/user-preferences/page.js.map +7 -0
  182. package/dist/modules/notifications/backend/notifications/user-preferences/page.meta.js +28 -0
  183. package/dist/modules/notifications/backend/notifications/user-preferences/page.meta.js.map +7 -0
  184. package/dist/modules/notifications/backend/profile/notification-preferences/page.js +10 -0
  185. package/dist/modules/notifications/backend/profile/notification-preferences/page.js.map +7 -0
  186. package/dist/modules/notifications/backend/profile/notification-preferences/page.meta.js +33 -0
  187. package/dist/modules/notifications/backend/profile/notification-preferences/page.meta.js.map +7 -0
  188. package/dist/modules/notifications/data/entities.js +128 -1
  189. package/dist/modules/notifications/data/entities.js.map +2 -2
  190. package/dist/modules/notifications/data/validators.js +95 -2
  191. package/dist/modules/notifications/data/validators.js.map +2 -2
  192. package/dist/modules/notifications/di.js +7 -0
  193. package/dist/modules/notifications/di.js.map +2 -2
  194. package/dist/modules/notifications/events.js +17 -0
  195. package/dist/modules/notifications/events.js.map +2 -2
  196. package/dist/modules/notifications/frontend/NotificationPreferenceMatrix.js +190 -0
  197. package/dist/modules/notifications/frontend/NotificationPreferenceMatrix.js.map +7 -0
  198. package/dist/modules/notifications/frontend/NotificationPreferencesPageClient.js +124 -0
  199. package/dist/modules/notifications/frontend/NotificationPreferencesPageClient.js.map +7 -0
  200. package/dist/modules/notifications/frontend/NotificationSettingsPageClient.js +166 -1
  201. package/dist/modules/notifications/frontend/NotificationSettingsPageClient.js.map +2 -2
  202. package/dist/modules/notifications/frontend/NotificationUserPreferencesAdminPageClient.js +172 -0
  203. package/dist/modules/notifications/frontend/NotificationUserPreferencesAdminPageClient.js.map +7 -0
  204. package/dist/modules/notifications/generators.js +63 -0
  205. package/dist/modules/notifications/generators.js.map +7 -0
  206. package/dist/modules/notifications/lib/delivery-strategies-registry.js +19 -0
  207. package/dist/modules/notifications/lib/delivery-strategies-registry.js.map +7 -0
  208. package/dist/modules/notifications/lib/deliveryStrategies.js.map +2 -2
  209. package/dist/modules/notifications/lib/derive-category.js +8 -0
  210. package/dist/modules/notifications/lib/derive-category.js.map +7 -0
  211. package/dist/modules/notifications/lib/notification-channel-registry.js +35 -0
  212. package/dist/modules/notifications/lib/notification-channel-registry.js.map +7 -0
  213. package/dist/modules/notifications/lib/notification-type-registry.js +87 -0
  214. package/dist/modules/notifications/lib/notification-type-registry.js.map +7 -0
  215. package/dist/modules/notifications/lib/notificationCopy.js +14 -0
  216. package/dist/modules/notifications/lib/notificationCopy.js.map +7 -0
  217. package/dist/modules/notifications/lib/notificationFactory.js +4 -1
  218. package/dist/modules/notifications/lib/notificationFactory.js.map +2 -2
  219. package/dist/modules/notifications/lib/notificationMapper.js +2 -0
  220. package/dist/modules/notifications/lib/notificationMapper.js.map +2 -2
  221. package/dist/modules/notifications/lib/notificationPreferenceService.js +83 -0
  222. package/dist/modules/notifications/lib/notificationPreferenceService.js.map +7 -0
  223. package/dist/modules/notifications/lib/notificationService.js +83 -28
  224. package/dist/modules/notifications/lib/notificationService.js.map +2 -2
  225. package/dist/modules/notifications/lib/notificationVisibility.js +24 -0
  226. package/dist/modules/notifications/lib/notificationVisibility.js.map +7 -0
  227. package/dist/modules/notifications/lib/routeHelpers.js +11 -1
  228. package/dist/modules/notifications/lib/routeHelpers.js.map +2 -2
  229. package/dist/modules/notifications/lib/shouldDeliver.js +46 -0
  230. package/dist/modules/notifications/lib/shouldDeliver.js.map +7 -0
  231. package/dist/modules/notifications/lib/strategies/email-delivery-strategy.js +54 -0
  232. package/dist/modules/notifications/lib/strategies/email-delivery-strategy.js.map +7 -0
  233. package/dist/modules/notifications/lib/strategies/in-app-delivery-strategy.js +12 -0
  234. package/dist/modules/notifications/lib/strategies/in-app-delivery-strategy.js.map +7 -0
  235. package/dist/modules/notifications/lib/typeCatalogue.js +71 -0
  236. package/dist/modules/notifications/lib/typeCatalogue.js.map +7 -0
  237. package/dist/modules/notifications/lib/typeChannelSettings.js +12 -0
  238. package/dist/modules/notifications/lib/typeChannelSettings.js.map +7 -0
  239. package/dist/modules/notifications/lib/typeOverrides.js +19 -0
  240. package/dist/modules/notifications/lib/typeOverrides.js.map +7 -0
  241. package/dist/modules/notifications/migrations/Migration20260625122947_notifications.js +14 -0
  242. package/dist/modules/notifications/migrations/Migration20260625122947_notifications.js.map +7 -0
  243. package/dist/modules/notifications/migrations/Migration20260626120500_notifications_push_payload.js +15 -0
  244. package/dist/modules/notifications/migrations/Migration20260626120500_notifications_push_payload.js.map +7 -0
  245. package/dist/modules/notifications/migrations/Migration20260626130000_notifications_non_opt_out.js +13 -0
  246. package/dist/modules/notifications/migrations/Migration20260626130000_notifications_non_opt_out.js.map +7 -0
  247. package/dist/modules/notifications/migrations/Migration20260626140000_notifications_type_category_silent.js +15 -0
  248. package/dist/modules/notifications/migrations/Migration20260626140000_notifications_type_category_silent.js.map +7 -0
  249. package/dist/modules/notifications/migrations/Migration20260706120000_notifications_channels.js +13 -0
  250. package/dist/modules/notifications/migrations/Migration20260706120000_notifications_channels.js.map +7 -0
  251. package/dist/modules/notifications/migrations/Migration20260716123121_notifications.js +14 -0
  252. package/dist/modules/notifications/migrations/Migration20260716123121_notifications.js.map +7 -0
  253. package/dist/modules/notifications/notification-channels.js +26 -0
  254. package/dist/modules/notifications/notification-channels.js.map +7 -0
  255. package/dist/modules/notifications/notifications.delivery-strategies.js +12 -0
  256. package/dist/modules/notifications/notifications.delivery-strategies.js.map +7 -0
  257. package/dist/modules/notifications/setup.js +16 -1
  258. package/dist/modules/notifications/setup.js.map +2 -2
  259. package/dist/modules/notifications/subscribers/deliver-notification.js +66 -57
  260. package/dist/modules/notifications/subscribers/deliver-notification.js.map +2 -2
  261. package/dist/modules/notifications/subscribers/sync-notification-types.js +16 -0
  262. package/dist/modules/notifications/subscribers/sync-notification-types.js.map +7 -0
  263. package/dist/modules/notifications/widgets/injection/profile-preferences-menu/widget.js +23 -0
  264. package/dist/modules/notifications/widgets/injection/profile-preferences-menu/widget.js.map +7 -0
  265. package/dist/modules/notifications/widgets/injection-table.js +14 -0
  266. package/dist/modules/notifications/widgets/injection-table.js.map +7 -0
  267. package/dist/modules/push_notifications/acl.js +13 -0
  268. package/dist/modules/push_notifications/acl.js.map +7 -0
  269. package/dist/modules/push_notifications/api/custom-send/route.js +141 -0
  270. package/dist/modules/push_notifications/api/custom-send/route.js.map +7 -0
  271. package/dist/modules/push_notifications/api/deliveries/[id]/route.js +110 -0
  272. package/dist/modules/push_notifications/api/deliveries/[id]/route.js.map +7 -0
  273. package/dist/modules/push_notifications/api/deliveries/route.js +56 -0
  274. package/dist/modules/push_notifications/api/deliveries/route.js.map +7 -0
  275. package/dist/modules/push_notifications/api/openapi.js +19 -0
  276. package/dist/modules/push_notifications/api/openapi.js.map +7 -0
  277. package/dist/modules/push_notifications/backend/push_notifications/[id]/page.js +103 -0
  278. package/dist/modules/push_notifications/backend/push_notifications/[id]/page.js.map +7 -0
  279. package/dist/modules/push_notifications/backend/push_notifications/[id]/page.meta.js +13 -0
  280. package/dist/modules/push_notifications/backend/push_notifications/[id]/page.meta.js.map +7 -0
  281. package/dist/modules/push_notifications/backend/push_notifications/page.js +213 -0
  282. package/dist/modules/push_notifications/backend/push_notifications/page.js.map +7 -0
  283. package/dist/modules/push_notifications/backend/push_notifications/page.meta.js +23 -0
  284. package/dist/modules/push_notifications/backend/push_notifications/page.meta.js.map +7 -0
  285. package/dist/modules/push_notifications/backend/push_notifications/send/page.js +245 -0
  286. package/dist/modules/push_notifications/backend/push_notifications/send/page.js.map +7 -0
  287. package/dist/modules/push_notifications/backend/push_notifications/send/page.meta.js +26 -0
  288. package/dist/modules/push_notifications/backend/push_notifications/send/page.meta.js.map +7 -0
  289. package/dist/modules/push_notifications/data/entities.js +92 -0
  290. package/dist/modules/push_notifications/data/entities.js.map +7 -0
  291. package/dist/modules/push_notifications/data/extensions.js +23 -0
  292. package/dist/modules/push_notifications/data/extensions.js.map +7 -0
  293. package/dist/modules/push_notifications/data/validators.js +89 -0
  294. package/dist/modules/push_notifications/data/validators.js.map +7 -0
  295. package/dist/modules/push_notifications/di.js +17 -0
  296. package/dist/modules/push_notifications/di.js.map +7 -0
  297. package/dist/modules/push_notifications/events.js +24 -0
  298. package/dist/modules/push_notifications/events.js.map +7 -0
  299. package/dist/modules/push_notifications/index.js +16 -0
  300. package/dist/modules/push_notifications/index.js.map +7 -0
  301. package/dist/modules/push_notifications/lib/fake-provider-recorder.js +80 -0
  302. package/dist/modules/push_notifications/lib/fake-provider-recorder.js.map +7 -0
  303. package/dist/modules/push_notifications/lib/push-delivery-strategy.js +54 -0
  304. package/dist/modules/push_notifications/lib/push-delivery-strategy.js.map +7 -0
  305. package/dist/modules/push_notifications/lib/push-delivery.js +297 -0
  306. package/dist/modules/push_notifications/lib/push-delivery.js.map +7 -0
  307. package/dist/modules/push_notifications/lib/push-fanout.js +138 -0
  308. package/dist/modules/push_notifications/lib/push-fanout.js.map +7 -0
  309. package/dist/modules/push_notifications/lib/push-health.js +32 -0
  310. package/dist/modules/push_notifications/lib/push-health.js.map +7 -0
  311. package/dist/modules/push_notifications/lib/push-reaper.js +87 -0
  312. package/dist/modules/push_notifications/lib/push-reaper.js.map +7 -0
  313. package/dist/modules/push_notifications/lib/push-receipt-reaper.js +136 -0
  314. package/dist/modules/push_notifications/lib/push-receipt-reaper.js.map +7 -0
  315. package/dist/modules/push_notifications/lib/push-stub-adapter.js +63 -0
  316. package/dist/modules/push_notifications/lib/push-stub-adapter.js.map +7 -0
  317. package/dist/modules/push_notifications/lib/queue.js +53 -0
  318. package/dist/modules/push_notifications/lib/queue.js.map +7 -0
  319. package/dist/modules/push_notifications/lib/reclaim-window.js +13 -0
  320. package/dist/modules/push_notifications/lib/reclaim-window.js.map +7 -0
  321. package/dist/modules/push_notifications/lib/send-custom-push.js +48 -0
  322. package/dist/modules/push_notifications/lib/send-custom-push.js.map +7 -0
  323. package/dist/modules/push_notifications/migrations/Migration20260625150049_push_notifications.js +16 -0
  324. package/dist/modules/push_notifications/migrations/Migration20260625150049_push_notifications.js.map +7 -0
  325. package/dist/modules/push_notifications/migrations/Migration20260626120000_push_notifications_silent.js +13 -0
  326. package/dist/modules/push_notifications/migrations/Migration20260626120000_push_notifications_silent.js.map +7 -0
  327. package/dist/modules/push_notifications/notifications.delivery-strategies.js +8 -0
  328. package/dist/modules/push_notifications/notifications.delivery-strategies.js.map +7 -0
  329. package/dist/modules/push_notifications/notifications.js +40 -0
  330. package/dist/modules/push_notifications/notifications.js.map +7 -0
  331. package/dist/modules/push_notifications/setup.js +52 -0
  332. package/dist/modules/push_notifications/setup.js.map +7 -0
  333. package/dist/modules/push_notifications/workers/reclaim-stuck.worker.js +57 -0
  334. package/dist/modules/push_notifications/workers/reclaim-stuck.worker.js.map +7 -0
  335. package/dist/modules/push_notifications/workers/send-push.worker.js +27 -0
  336. package/dist/modules/push_notifications/workers/send-push.worker.js.map +7 -0
  337. package/dist/modules/sales/notifications.js +8 -0
  338. package/dist/modules/sales/notifications.js.map +2 -2
  339. package/dist/modules/staff/notifications.js +6 -0
  340. package/dist/modules/staff/notifications.js.map +2 -2
  341. package/dist/modules/workflows/notifications.js +2 -0
  342. package/dist/modules/workflows/notifications.js.map +2 -2
  343. package/generated/entities/notification/index.ts +3 -0
  344. package/generated/entities/notification_preference/index.ts +8 -0
  345. package/generated/entities/notification_type/index.ts +9 -0
  346. package/generated/entities/notification_type_override/index.ts +7 -0
  347. package/generated/entities/push_notification_delivery/index.ts +19 -0
  348. package/generated/entities/user_device/index.ts +16 -0
  349. package/generated/entities.ids.generated.ts +12 -1
  350. package/generated/entity-fields-registry.ts +72 -0
  351. package/package.json +7 -7
  352. package/src/helpers/integration/api.ts +2 -1
  353. package/src/helpers/integration/appRoot.ts +13 -0
  354. package/src/helpers/integration/dbFixtures.ts +1 -5
  355. package/src/helpers/integration/pushFake.ts +219 -0
  356. package/src/modules/auth/i18n/de.json +1 -0
  357. package/src/modules/auth/i18n/en.json +1 -0
  358. package/src/modules/auth/i18n/es.json +1 -0
  359. package/src/modules/auth/i18n/ko.json +1 -0
  360. package/src/modules/auth/i18n/pl.json +1 -0
  361. package/src/modules/auth/notifications.ts +16 -0
  362. package/src/modules/business_rules/i18n/de.json +2 -1
  363. package/src/modules/business_rules/i18n/en.json +2 -1
  364. package/src/modules/business_rules/i18n/es.json +2 -1
  365. package/src/modules/business_rules/i18n/ko.json +2 -1
  366. package/src/modules/business_rules/i18n/pl.json +2 -1
  367. package/src/modules/business_rules/notifications.ts +2 -0
  368. package/src/modules/catalog/i18n/de.json +2 -1
  369. package/src/modules/catalog/i18n/en.json +2 -1
  370. package/src/modules/catalog/i18n/es.json +2 -1
  371. package/src/modules/catalog/i18n/ko.json +2 -1
  372. package/src/modules/catalog/i18n/pl.json +2 -1
  373. package/src/modules/catalog/notifications.ts +2 -0
  374. package/src/modules/communication_channels/acl.ts +9 -0
  375. package/src/modules/communication_channels/api/delete/admin/channels/[id]/route.ts +168 -0
  376. package/src/modules/communication_channels/api/get/channels/[id]/health/route.ts +6 -2
  377. package/src/modules/communication_channels/api/get/channels/[id]/route.ts +6 -2
  378. package/src/modules/communication_channels/api/get/channels/route.ts +2 -1
  379. package/src/modules/communication_channels/api/post/channels/[id]/test-send/route.ts +16 -5
  380. package/src/modules/communication_channels/api/post/channels/connect/credentials/route.ts +37 -1
  381. package/src/modules/communication_channels/api/post/channels/connect/tenant-credentials/route.ts +175 -0
  382. package/src/modules/communication_channels/backend/communication_channels/channels/page.tsx +11 -1
  383. package/src/modules/communication_channels/commands/admin-delete-channel.ts +203 -0
  384. package/src/modules/communication_channels/commands/connect-credential-channel.ts +63 -15
  385. package/src/modules/communication_channels/commands/deliver-outbound-message.ts +10 -2
  386. package/src/modules/communication_channels/commands/push-register.ts +7 -1
  387. package/src/modules/communication_channels/commands/push-unregister.ts +14 -3
  388. package/src/modules/communication_channels/data/entities.ts +10 -0
  389. package/src/modules/communication_channels/data/validators.ts +1 -0
  390. package/src/modules/communication_channels/i18n/de.json +35 -1
  391. package/src/modules/communication_channels/i18n/en.json +35 -1
  392. package/src/modules/communication_channels/i18n/es.json +35 -1
  393. package/src/modules/communication_channels/i18n/ko.json +35 -1
  394. package/src/modules/communication_channels/i18n/pl.json +35 -1
  395. package/src/modules/communication_channels/lib/access-control.ts +42 -0
  396. package/src/modules/communication_channels/lib/adapter.ts +25 -0
  397. package/src/modules/communication_channels/lib/connect-channel.ts +61 -26
  398. package/src/modules/communication_channels/lib/push-adapter.ts +139 -0
  399. package/src/modules/communication_channels/lib/push-capabilities.ts +42 -0
  400. package/src/modules/communication_channels/lib/push-connect-error.ts +52 -0
  401. package/src/modules/communication_channels/lib/push-credential-errors.ts +58 -0
  402. package/src/modules/communication_channels/lib/push-envelope.ts +104 -0
  403. package/src/modules/communication_channels/lib/refcounted-client-cache.ts +123 -0
  404. package/src/modules/communication_channels/migrations/.snapshot-open-mercato.json +9 -0
  405. package/src/modules/communication_channels/migrations/Migration20260703123630_communication_channels.ts +20 -0
  406. package/src/modules/communication_channels/notifications.ts +4 -0
  407. package/src/modules/communication_channels/setup.ts +2 -0
  408. package/src/modules/configs/i18n/de.json +5 -1
  409. package/src/modules/configs/i18n/en.json +5 -1
  410. package/src/modules/configs/i18n/es.json +5 -1
  411. package/src/modules/configs/i18n/ko.json +5 -1
  412. package/src/modules/configs/i18n/pl.json +5 -1
  413. package/src/modules/configs/lib/upgrade-actions.ts +21 -0
  414. package/src/modules/customer_accounts/i18n/de.json +2 -1
  415. package/src/modules/customer_accounts/i18n/en.json +2 -1
  416. package/src/modules/customer_accounts/i18n/es.json +2 -1
  417. package/src/modules/customer_accounts/i18n/ko.json +2 -1
  418. package/src/modules/customer_accounts/i18n/pl.json +2 -1
  419. package/src/modules/customer_accounts/notifications.ts +12 -0
  420. package/src/modules/customers/i18n/de.json +2 -1
  421. package/src/modules/customers/i18n/en.json +2 -1
  422. package/src/modules/customers/i18n/es.json +2 -1
  423. package/src/modules/customers/i18n/ko.json +2 -1
  424. package/src/modules/customers/i18n/pl.json +2 -1
  425. package/src/modules/customers/notifications.ts +4 -0
  426. package/src/modules/devices/AGENTS.md +101 -0
  427. package/src/modules/devices/acl.ts +7 -0
  428. package/src/modules/devices/api/[id]/route.ts +180 -0
  429. package/src/modules/devices/api/admin/devices/[id]/route.ts +235 -0
  430. package/src/modules/devices/api/admin/devices/route.ts +160 -0
  431. package/src/modules/devices/api/auth.ts +13 -0
  432. package/src/modules/devices/api/deviceList.ts +54 -0
  433. package/src/modules/devices/api/deviceOps.ts +186 -0
  434. package/src/modules/devices/api/openapi.ts +23 -0
  435. package/src/modules/devices/api/route.ts +165 -0
  436. package/src/modules/devices/backend/devices/[id]/page.meta.ts +12 -0
  437. package/src/modules/devices/backend/devices/[id]/page.tsx +164 -0
  438. package/src/modules/devices/backend/devices/create/page.meta.ts +12 -0
  439. package/src/modules/devices/backend/devices/create/page.tsx +81 -0
  440. package/src/modules/devices/backend/devices/page.meta.ts +21 -0
  441. package/src/modules/devices/backend/devices/page.tsx +259 -0
  442. package/src/modules/devices/commands/deactivate.ts +126 -0
  443. package/src/modules/devices/commands/index.ts +3 -0
  444. package/src/modules/devices/commands/register.ts +199 -0
  445. package/src/modules/devices/commands/shared.ts +119 -0
  446. package/src/modules/devices/commands/update.ts +142 -0
  447. package/src/modules/devices/data/entities.ts +79 -0
  448. package/src/modules/devices/data/validators.ts +65 -0
  449. package/src/modules/devices/di.ts +9 -0
  450. package/src/modules/devices/encryption.ts +15 -0
  451. package/src/modules/devices/events.ts +16 -0
  452. package/src/modules/devices/i18n/de.json +45 -0
  453. package/src/modules/devices/i18n/en.json +45 -0
  454. package/src/modules/devices/i18n/es.json +45 -0
  455. package/src/modules/devices/i18n/ko.json +45 -0
  456. package/src/modules/devices/i18n/pl.json +45 -0
  457. package/src/modules/devices/index.ts +15 -0
  458. package/src/modules/devices/lib/operationMetadata.ts +35 -0
  459. package/src/modules/devices/migrations/.snapshot-open-mercato.json +200 -0
  460. package/src/modules/devices/migrations/Migration20260602120000.ts +15 -0
  461. package/src/modules/devices/migrations/Migration20260630120000.ts +20 -0
  462. package/src/modules/devices/migrations/Migration20260701120000.ts +13 -0
  463. package/src/modules/devices/migrations/Migration20260721120000.ts +18 -0
  464. package/src/modules/devices/setup.ts +11 -0
  465. package/src/modules/entities/cli.ts +16 -2
  466. package/src/modules/entities/migrations/Migration20260722120000.ts +38 -0
  467. package/src/modules/inbox_ops/i18n/de.json +2 -1
  468. package/src/modules/inbox_ops/i18n/en.json +2 -1
  469. package/src/modules/inbox_ops/i18n/es.json +2 -1
  470. package/src/modules/inbox_ops/i18n/ko.json +2 -1
  471. package/src/modules/inbox_ops/i18n/pl.json +2 -1
  472. package/src/modules/inbox_ops/notifications.ts +2 -0
  473. package/src/modules/messages/i18n/de.json +2 -1
  474. package/src/modules/messages/i18n/en.json +2 -1
  475. package/src/modules/messages/i18n/es.json +2 -1
  476. package/src/modules/messages/i18n/ko.json +2 -1
  477. package/src/modules/messages/i18n/pl.json +2 -1
  478. package/src/modules/messages/notifications.ts +2 -0
  479. package/src/modules/notifications/AGENTS.md +54 -0
  480. package/src/modules/notifications/acl.ts +2 -0
  481. package/src/modules/notifications/api/admin/preferences/route.ts +193 -0
  482. package/src/modules/notifications/api/channels/route.ts +54 -0
  483. package/src/modules/notifications/api/preferences/route.ts +131 -0
  484. package/src/modules/notifications/api/route.ts +8 -1
  485. package/src/modules/notifications/api/types/[id]/channels/[channel]/route.ts +226 -0
  486. package/src/modules/notifications/api/types/route.ts +239 -0
  487. package/src/modules/notifications/api/unread-count/route.ts +5 -1
  488. package/src/modules/notifications/backend/notifications/user-preferences/page.meta.ts +26 -0
  489. package/src/modules/notifications/backend/notifications/user-preferences/page.tsx +12 -0
  490. package/src/modules/notifications/backend/profile/notification-preferences/page.meta.ts +31 -0
  491. package/src/modules/notifications/backend/profile/notification-preferences/page.tsx +12 -0
  492. package/src/modules/notifications/data/entities.ts +144 -0
  493. package/src/modules/notifications/data/validators.ts +116 -0
  494. package/src/modules/notifications/di.ts +7 -0
  495. package/src/modules/notifications/events.ts +23 -0
  496. package/src/modules/notifications/frontend/NotificationPreferenceMatrix.tsx +287 -0
  497. package/src/modules/notifications/frontend/NotificationPreferencesPageClient.tsx +155 -0
  498. package/src/modules/notifications/frontend/NotificationSettingsPageClient.tsx +273 -2
  499. package/src/modules/notifications/frontend/NotificationUserPreferencesAdminPageClient.tsx +214 -0
  500. package/src/modules/notifications/generators.ts +82 -0
  501. package/src/modules/notifications/i18n/de.json +43 -0
  502. package/src/modules/notifications/i18n/en.json +43 -0
  503. package/src/modules/notifications/i18n/es.json +43 -0
  504. package/src/modules/notifications/i18n/ko.json +43 -0
  505. package/src/modules/notifications/i18n/pl.json +44 -1
  506. package/src/modules/notifications/lib/delivery-strategies-registry.ts +30 -0
  507. package/src/modules/notifications/lib/deliveryStrategies.ts +40 -4
  508. package/src/modules/notifications/lib/derive-category.ts +15 -0
  509. package/src/modules/notifications/lib/notification-channel-registry.ts +59 -0
  510. package/src/modules/notifications/lib/notification-type-registry.ts +182 -0
  511. package/src/modules/notifications/lib/notificationCopy.ts +34 -0
  512. package/src/modules/notifications/lib/notificationFactory.ts +5 -1
  513. package/src/modules/notifications/lib/notificationMapper.ts +2 -0
  514. package/src/modules/notifications/lib/notificationPreferenceService.ts +138 -0
  515. package/src/modules/notifications/lib/notificationService.ts +129 -25
  516. package/src/modules/notifications/lib/notificationVisibility.ts +48 -0
  517. package/src/modules/notifications/lib/routeHelpers.ts +14 -1
  518. package/src/modules/notifications/lib/shouldDeliver.ts +112 -0
  519. package/src/modules/notifications/lib/strategies/email-delivery-strategy.ts +63 -0
  520. package/src/modules/notifications/lib/strategies/in-app-delivery-strategy.ts +21 -0
  521. package/src/modules/notifications/lib/typeCatalogue.ts +141 -0
  522. package/src/modules/notifications/lib/typeChannelSettings.ts +29 -0
  523. package/src/modules/notifications/lib/typeOverrides.ts +46 -0
  524. package/src/modules/notifications/migrations/.snapshot-open-mercato.json +874 -100
  525. package/src/modules/notifications/migrations/Migration20260625122947_notifications.ts +14 -0
  526. package/src/modules/notifications/migrations/Migration20260626120500_notifications_push_payload.ts +15 -0
  527. package/src/modules/notifications/migrations/Migration20260626130000_notifications_non_opt_out.ts +13 -0
  528. package/src/modules/notifications/migrations/Migration20260626140000_notifications_type_category_silent.ts +15 -0
  529. package/src/modules/notifications/migrations/Migration20260706120000_notifications_channels.ts +13 -0
  530. package/src/modules/notifications/migrations/Migration20260716123121_notifications.ts +14 -0
  531. package/src/modules/notifications/notification-channels.ts +30 -0
  532. package/src/modules/notifications/notifications.delivery-strategies.ts +15 -0
  533. package/src/modules/notifications/setup.ts +23 -1
  534. package/src/modules/notifications/subscribers/deliver-notification.ts +105 -71
  535. package/src/modules/notifications/subscribers/sync-notification-types.ts +18 -0
  536. package/src/modules/notifications/widgets/injection/profile-preferences-menu/widget.ts +35 -0
  537. package/src/modules/notifications/widgets/injection-table.ts +18 -0
  538. package/src/modules/push_notifications/AGENTS.md +92 -0
  539. package/src/modules/push_notifications/acl.ts +9 -0
  540. package/src/modules/push_notifications/api/custom-send/route.ts +175 -0
  541. package/src/modules/push_notifications/api/deliveries/[id]/route.ts +122 -0
  542. package/src/modules/push_notifications/api/deliveries/route.ts +58 -0
  543. package/src/modules/push_notifications/api/openapi.ts +22 -0
  544. package/src/modules/push_notifications/backend/push_notifications/[id]/page.meta.ts +9 -0
  545. package/src/modules/push_notifications/backend/push_notifications/[id]/page.tsx +149 -0
  546. package/src/modules/push_notifications/backend/push_notifications/page.meta.ts +21 -0
  547. package/src/modules/push_notifications/backend/push_notifications/page.tsx +239 -0
  548. package/src/modules/push_notifications/backend/push_notifications/send/page.meta.ts +24 -0
  549. package/src/modules/push_notifications/backend/push_notifications/send/page.tsx +327 -0
  550. package/src/modules/push_notifications/data/entities.ts +111 -0
  551. package/src/modules/push_notifications/data/extensions.ts +29 -0
  552. package/src/modules/push_notifications/data/validators.ts +102 -0
  553. package/src/modules/push_notifications/di.ts +20 -0
  554. package/src/modules/push_notifications/events.ts +21 -0
  555. package/src/modules/push_notifications/i18n/de.json +79 -0
  556. package/src/modules/push_notifications/i18n/en.json +79 -0
  557. package/src/modules/push_notifications/i18n/es.json +79 -0
  558. package/src/modules/push_notifications/i18n/ko.json +79 -0
  559. package/src/modules/push_notifications/i18n/pl.json +79 -0
  560. package/src/modules/push_notifications/index.ts +15 -0
  561. package/src/modules/push_notifications/lib/fake-provider-recorder.ts +154 -0
  562. package/src/modules/push_notifications/lib/push-delivery-strategy.ts +90 -0
  563. package/src/modules/push_notifications/lib/push-delivery.ts +455 -0
  564. package/src/modules/push_notifications/lib/push-fanout.ts +237 -0
  565. package/src/modules/push_notifications/lib/push-health.ts +47 -0
  566. package/src/modules/push_notifications/lib/push-reaper.ts +140 -0
  567. package/src/modules/push_notifications/lib/push-receipt-reaper.ts +210 -0
  568. package/src/modules/push_notifications/lib/push-stub-adapter.ts +80 -0
  569. package/src/modules/push_notifications/lib/queue.ts +74 -0
  570. package/src/modules/push_notifications/lib/reclaim-window.ts +16 -0
  571. package/src/modules/push_notifications/lib/send-custom-push.ts +100 -0
  572. package/src/modules/push_notifications/migrations/.snapshot-open-mercato.json +386 -0
  573. package/src/modules/push_notifications/migrations/Migration20260625150049_push_notifications.ts +16 -0
  574. package/src/modules/push_notifications/migrations/Migration20260626120000_push_notifications_silent.ts +13 -0
  575. package/src/modules/push_notifications/notifications.delivery-strategies.ts +8 -0
  576. package/src/modules/push_notifications/notifications.ts +50 -0
  577. package/src/modules/push_notifications/setup.ts +92 -0
  578. package/src/modules/push_notifications/workers/reclaim-stuck.worker.ts +78 -0
  579. package/src/modules/push_notifications/workers/send-push.worker.ts +34 -0
  580. package/src/modules/sales/i18n/de.json +1 -0
  581. package/src/modules/sales/i18n/en.json +1 -0
  582. package/src/modules/sales/i18n/es.json +1 -0
  583. package/src/modules/sales/i18n/ko.json +1 -0
  584. package/src/modules/sales/i18n/pl.json +1 -0
  585. package/src/modules/sales/notifications.ts +8 -0
  586. package/src/modules/staff/i18n/de.json +1 -0
  587. package/src/modules/staff/i18n/en.json +1 -0
  588. package/src/modules/staff/i18n/es.json +1 -0
  589. package/src/modules/staff/i18n/ko.json +1 -0
  590. package/src/modules/staff/i18n/pl.json +1 -0
  591. package/src/modules/staff/notifications.ts +6 -0
  592. package/src/modules/workflows/i18n/de.json +1 -0
  593. package/src/modules/workflows/i18n/en.json +1 -0
  594. package/src/modules/workflows/i18n/es.json +1 -0
  595. package/src/modules/workflows/i18n/ko.json +1 -0
  596. package/src/modules/workflows/i18n/pl.json +1 -0
  597. package/src/modules/workflows/notifications.ts +2 -0
@@ -1,5 +1,13 @@
1
1
  import { authorizeFeatures } from "@open-mercato/shared/security/featurePolicy";
2
2
  const ADMIN_FEATURE = "communication_channels.admin";
3
+ function channelOrgScopeWhere(orgId) {
4
+ return orgId ? { $or: [{ organizationId: orgId }, { organizationId: null }] } : { organizationId: null };
5
+ }
6
+ function channelOrgScopeWhereFromFilter(filter) {
7
+ const organizationIds = filter?.organizationIds;
8
+ if (!organizationIds || organizationIds.length === 0) return {};
9
+ return { $or: [{ organizationId: { $in: organizationIds } }, { organizationId: null }] };
10
+ }
3
11
  function assertCanAccessChannel(channel, currentUserId, userFeatures) {
4
12
  if (!channel) {
5
13
  throw new ChannelAccessDeniedError("Channel not found");
@@ -38,6 +46,8 @@ export {
38
46
  ADMIN_FEATURE,
39
47
  ChannelAccessDeniedError,
40
48
  assertCanAccessChannel,
41
- assertCanManageChannel
49
+ assertCanManageChannel,
50
+ channelOrgScopeWhere,
51
+ channelOrgScopeWhereFromFilter
42
52
  };
43
53
  //# sourceMappingURL=access-control.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/communication_channels/lib/access-control.ts"],
4
- "sourcesContent": ["import { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'\n\n/**\n * Per-user channel access control \u2014 service-layer authorization helpers.\n *\n * Personal mailbox privacy (v1: strict owner-only). A per-user channel\n * (`userId` set) may be read or managed ONLY by its owner \u2014 not even an admin\n * or superadmin can act on another user's personal mailbox. Tenant-wide /\n * shared channels (`userId == null`, e.g. WhatsApp Business / Slack workspaces)\n * remain accessible to any caller the route already feature-gated.\n *\n * Routes also narrow at the SQL layer (the admin channel list returns\n * `user_id IS NULL` rows only; the profile list filters `user_id =\n * currentUser.id`); these helpers are the per-channel authorization backstop a\n * route calls once it has loaded the row.\n */\n\nexport const ADMIN_FEATURE = 'communication_channels.admin'\n\n/**\n * Throws when the caller may not access a specific channel. Returns silently\n * when access is allowed.\n *\n * Personal mailbox privacy (v1: strict owner-only). A per-user channel\n * (`userId` set) may be acted on ONLY by its owner \u2014 not even an admin /\n * superadmin can poll, test-send, import history from, register push on, or\n * delete another user's personal mailbox. Tenant-wide channels\n * (`userId == null`, e.g. WhatsApp Business / Slack workspaces) remain\n * accessible to any caller the route already feature-gated.\n *\n * `userFeatures` is retained on the signature (callers pass it) but no longer\n * grants a bypass; it is reserved for the v2 admin-oversight feature.\n *\n * Callers MUST pass the fully decrypted channel row \u2014 we read `userId` directly.\n */\nexport function assertCanAccessChannel(\n channel: { userId?: string | null } | null | undefined,\n currentUserId: string | null | undefined,\n userFeatures: string[] | null | undefined,\n): void {\n if (!channel) {\n // Defensive: callers pre-check existence and return 404, so this is\n // unreachable in practice. Throw the typed access error (route-mapped to a\n // masked 404) rather than a bare Error that would surface as a 500.\n throw new ChannelAccessDeniedError('Channel not found')\n }\n void userFeatures\n // Tenant-wide / shared channel \u2014 accessible to all callers.\n if (channel.userId == null) return\n // Personal mailbox \u2014 owner only, regardless of admin grants (v1).\n if (channel.userId !== currentUserId) {\n throw new ChannelAccessDeniedError(\n 'Channel is a personal mailbox owned by another user',\n )\n }\n}\n\n/**\n * Authorization for MANAGING (mutating) a channel \u2014 disconnect, poll, set\n * primary, import history, register push. Encodes the per-user ownership rule:\n *\n * - **Personal mailbox** (`userId` set): the **owner has full control** of\n * their own account; any other caller \u2014 admin included \u2014 is denied (v1\n * strict owner-only, no bypass). Routes gate these on\n * `communication_channels.connect_user_channel` so every email user reaches\n * this check for their own channels.\n * - **Tenant-wide / shared channel** (`userId == null`, e.g. WhatsApp\n * Business / Slack workspaces): requires the route's `elevatedFeature`\n * (`communication_channels.manage`, `\u2026channel.push.manage`, or\n * `\u2026channel.import_history`). Evaluated by the shared feature policy.\n *\n * Throws `ChannelAccessDeniedError` (route handlers map it to 404, masking\n * existence) when the caller may not manage the channel.\n */\nexport function assertCanManageChannel(\n channel: { userId?: string | null } | null | undefined,\n currentUserId: string | null | undefined,\n userFeatures: string[] | null | undefined,\n elevatedFeature: string,\n): void {\n if (!channel) {\n // Defensive: callers pre-check existence and return 404, so this is\n // unreachable in practice. Throw the typed access error (route-mapped to a\n // masked 404) rather than a bare Error that would surface as a 500.\n throw new ChannelAccessDeniedError('Channel not found')\n }\n // Personal mailbox \u2014 owner only. The owner manages their own account fully\n // (gated by connect_user_channel at the route); no admin bypass in v1.\n if (channel.userId != null) {\n if (channel.userId !== currentUserId) {\n throw new ChannelAccessDeniedError('Channel is a personal mailbox owned by another user')\n }\n return\n }\n // Tenant-wide / shared channel \u2014 requires the elevated management feature.\n const grantedFeatures = Array.isArray(userFeatures) ? userFeatures : []\n if (!authorizeFeatures([elevatedFeature], { grantedFeatures })) {\n throw new ChannelAccessDeniedError(`Managing a shared channel requires '${elevatedFeature}'`)\n }\n}\n\n/**\n * Stable error class so route handlers can map to a 403 instead of leaking the\n * underlying message verbatim. Subclasses `Error` for compatibility with the\n * platform's error logging helpers.\n */\nexport class ChannelAccessDeniedError extends Error {\n override name = 'ChannelAccessDeniedError'\n readonly statusCode = 403\n}\n"],
5
- "mappings": "AAAA,SAAS,yBAAyB;AAiB3B,MAAM,gBAAgB;AAkBtB,SAAS,uBACd,SACA,eACA,cACM;AACN,MAAI,CAAC,SAAS;AAIZ,UAAM,IAAI,yBAAyB,mBAAmB;AAAA,EACxD;AACA,OAAK;AAEL,MAAI,QAAQ,UAAU,KAAM;AAE5B,MAAI,QAAQ,WAAW,eAAe;AACpC,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACF;AAmBO,SAAS,uBACd,SACA,eACA,cACA,iBACM;AACN,MAAI,CAAC,SAAS;AAIZ,UAAM,IAAI,yBAAyB,mBAAmB;AAAA,EACxD;AAGA,MAAI,QAAQ,UAAU,MAAM;AAC1B,QAAI,QAAQ,WAAW,eAAe;AACpC,YAAM,IAAI,yBAAyB,qDAAqD;AAAA,IAC1F;AACA;AAAA,EACF;AAEA,QAAM,kBAAkB,MAAM,QAAQ,YAAY,IAAI,eAAe,CAAC;AACtE,MAAI,CAAC,kBAAkB,CAAC,eAAe,GAAG,EAAE,gBAAgB,CAAC,GAAG;AAC9D,UAAM,IAAI,yBAAyB,uCAAuC,eAAe,GAAG;AAAA,EAC9F;AACF;AAOO,MAAM,iCAAiC,MAAM;AAAA,EAA7C;AAAA;AACL,SAAS,OAAO;AAChB,SAAS,aAAa;AAAA;AACxB;",
4
+ "sourcesContent": ["import { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'\n\n/**\n * Per-user channel access control \u2014 service-layer authorization helpers.\n *\n * Personal mailbox privacy (v1: strict owner-only). A per-user channel\n * (`userId` set) may be read or managed ONLY by its owner \u2014 not even an admin\n * or superadmin can act on another user's personal mailbox. Tenant-wide /\n * shared channels (`userId == null`, e.g. WhatsApp Business / Slack workspaces)\n * remain accessible to any caller the route already feature-gated.\n *\n * Routes also narrow at the SQL layer (the admin channel list returns\n * `user_id IS NULL` rows only; the profile list filters `user_id =\n * currentUser.id`); these helpers are the per-channel authorization backstop a\n * route calls once it has loaded the row.\n */\n\nexport const ADMIN_FEATURE = 'communication_channels.admin'\n\n/**\n * Org-scoping fragment for reading/managing channels.\n *\n * Tenant-wide channels are stored with `organization_id IS NULL` (the\n * tenant-scoped push providers FCM/APNs/Expo do this deliberately \u2014 see\n * `connect-credential-channel.ts`). They are visible and manageable from ANY\n * org in the tenant, so a read scoped to the caller's selected org must also\n * match the NULL rows; otherwise a channel connected while an admin had a\n * non-null org would be invisible to every listing/detail query (and vice\n * versa). Org-scoped channels (`organization_id = <org>`) stay scoped to their\n * org.\n *\n * Spread the result into a MikroORM `where` object alongside `tenantId` etc.\n */\nexport function channelOrgScopeWhere(\n orgId: string | null | undefined,\n): Record<string, unknown> {\n return orgId\n ? { $or: [{ organizationId: orgId }, { organizationId: null }] }\n : { organizationId: null }\n}\n\n/**\n * Same NULL-inclusive org scoping, expressed over a resolved\n * `OrganizationScopeFilter` (`resolveOrganizationScopeFilter`) rather than a\n * single org id.\n *\n * Read routes scope on the caller's *selected* organization (the\n * `om_selected_org` cookie the top-bar switcher writes), not on the org baked\n * into the session token (#5012) \u2014 that resolution yields a list of org ids, or\n * `undefined` when the caller is unrestricted (super-admin viewing all orgs).\n * An unrestricted caller sees every channel in the tenant, so the fragment is\n * empty; a restricted one gets their orgs plus the tenant-wide (`NULL`) rows.\n */\nexport function channelOrgScopeWhereFromFilter(\n filter: { organizationIds: string[] | undefined } | null | undefined,\n): Record<string, unknown> {\n const organizationIds = filter?.organizationIds\n if (!organizationIds || organizationIds.length === 0) return {}\n return { $or: [{ organizationId: { $in: organizationIds } }, { organizationId: null }] }\n}\n\n/**\n * Throws when the caller may not access a specific channel. Returns silently\n * when access is allowed.\n *\n * Personal mailbox privacy (v1: strict owner-only). A per-user channel\n * (`userId` set) may be acted on ONLY by its owner \u2014 not even an admin /\n * superadmin can poll, test-send, import history from, register push on, or\n * delete another user's personal mailbox. Tenant-wide channels\n * (`userId == null`, e.g. WhatsApp Business / Slack workspaces) remain\n * accessible to any caller the route already feature-gated.\n *\n * `userFeatures` is retained on the signature (callers pass it) but no longer\n * grants a bypass; it is reserved for the v2 admin-oversight feature.\n *\n * Callers MUST pass the fully decrypted channel row \u2014 we read `userId` directly.\n */\nexport function assertCanAccessChannel(\n channel: { userId?: string | null } | null | undefined,\n currentUserId: string | null | undefined,\n userFeatures: string[] | null | undefined,\n): void {\n if (!channel) {\n // Defensive: callers pre-check existence and return 404, so this is\n // unreachable in practice. Throw the typed access error (route-mapped to a\n // masked 404) rather than a bare Error that would surface as a 500.\n throw new ChannelAccessDeniedError('Channel not found')\n }\n void userFeatures\n // Tenant-wide / shared channel \u2014 accessible to all callers.\n if (channel.userId == null) return\n // Personal mailbox \u2014 owner only, regardless of admin grants (v1).\n if (channel.userId !== currentUserId) {\n throw new ChannelAccessDeniedError(\n 'Channel is a personal mailbox owned by another user',\n )\n }\n}\n\n/**\n * Authorization for MANAGING (mutating) a channel \u2014 disconnect, poll, set\n * primary, import history, register push. Encodes the per-user ownership rule:\n *\n * - **Personal mailbox** (`userId` set): the **owner has full control** of\n * their own account; any other caller \u2014 admin included \u2014 is denied (v1\n * strict owner-only, no bypass). Routes gate these on\n * `communication_channels.connect_user_channel` so every email user reaches\n * this check for their own channels.\n * - **Tenant-wide / shared channel** (`userId == null`, e.g. WhatsApp\n * Business / Slack workspaces): requires the route's `elevatedFeature`\n * (`communication_channels.manage`, `\u2026channel.push.manage`, or\n * `\u2026channel.import_history`). Evaluated by the shared feature policy.\n *\n * Throws `ChannelAccessDeniedError` (route handlers map it to 404, masking\n * existence) when the caller may not manage the channel.\n */\nexport function assertCanManageChannel(\n channel: { userId?: string | null } | null | undefined,\n currentUserId: string | null | undefined,\n userFeatures: string[] | null | undefined,\n elevatedFeature: string,\n): void {\n if (!channel) {\n // Defensive: callers pre-check existence and return 404, so this is\n // unreachable in practice. Throw the typed access error (route-mapped to a\n // masked 404) rather than a bare Error that would surface as a 500.\n throw new ChannelAccessDeniedError('Channel not found')\n }\n // Personal mailbox \u2014 owner only. The owner manages their own account fully\n // (gated by connect_user_channel at the route); no admin bypass in v1.\n if (channel.userId != null) {\n if (channel.userId !== currentUserId) {\n throw new ChannelAccessDeniedError('Channel is a personal mailbox owned by another user')\n }\n return\n }\n // Tenant-wide / shared channel \u2014 requires the elevated management feature.\n const grantedFeatures = Array.isArray(userFeatures) ? userFeatures : []\n if (!authorizeFeatures([elevatedFeature], { grantedFeatures })) {\n throw new ChannelAccessDeniedError(`Managing a shared channel requires '${elevatedFeature}'`)\n }\n}\n\n/**\n * Stable error class so route handlers can map to a 403 instead of leaking the\n * underlying message verbatim. Subclasses `Error` for compatibility with the\n * platform's error logging helpers.\n */\nexport class ChannelAccessDeniedError extends Error {\n override name = 'ChannelAccessDeniedError'\n readonly statusCode = 403\n}\n"],
5
+ "mappings": "AAAA,SAAS,yBAAyB;AAiB3B,MAAM,gBAAgB;AAgBtB,SAAS,qBACd,OACyB;AACzB,SAAO,QACH,EAAE,KAAK,CAAC,EAAE,gBAAgB,MAAM,GAAG,EAAE,gBAAgB,KAAK,CAAC,EAAE,IAC7D,EAAE,gBAAgB,KAAK;AAC7B;AAcO,SAAS,+BACd,QACyB;AACzB,QAAM,kBAAkB,QAAQ;AAChC,MAAI,CAAC,mBAAmB,gBAAgB,WAAW,EAAG,QAAO,CAAC;AAC9D,SAAO,EAAE,KAAK,CAAC,EAAE,gBAAgB,EAAE,KAAK,gBAAgB,EAAE,GAAG,EAAE,gBAAgB,KAAK,CAAC,EAAE;AACzF;AAkBO,SAAS,uBACd,SACA,eACA,cACM;AACN,MAAI,CAAC,SAAS;AAIZ,UAAM,IAAI,yBAAyB,mBAAmB;AAAA,EACxD;AACA,OAAK;AAEL,MAAI,QAAQ,UAAU,KAAM;AAE5B,MAAI,QAAQ,WAAW,eAAe;AACpC,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACF;AAmBO,SAAS,uBACd,SACA,eACA,cACA,iBACM;AACN,MAAI,CAAC,SAAS;AAIZ,UAAM,IAAI,yBAAyB,mBAAmB;AAAA,EACxD;AAGA,MAAI,QAAQ,UAAU,MAAM;AAC1B,QAAI,QAAQ,WAAW,eAAe;AACpC,YAAM,IAAI,yBAAyB,qDAAqD;AAAA,IAC1F;AACA;AAAA,EACF;AAEA,QAAM,kBAAkB,MAAM,QAAQ,YAAY,IAAI,eAAe,CAAC;AACtE,MAAI,CAAC,kBAAkB,CAAC,eAAe,GAAG,EAAE,gBAAgB,CAAC,GAAG;AAC9D,UAAM,IAAI,yBAAyB,uCAAuC,eAAe,GAAG;AAAA,EAC9F;AACF;AAOO,MAAM,iCAAiC,MAAM;AAAA,EAA7C;AAAA;AACL,SAAS,OAAO;AAChB,SAAS,aAAa;AAAA;AACxB;",
6
6
  "names": []
7
7
  }
@@ -13,10 +13,12 @@ class MailboxAlreadyConnectedError extends Error {
13
13
  async function createConnectedChannelRow(args) {
14
14
  const { em, adapter, providerKey, displayName, externalIdentifier, credentialsRefId, userId, scope } = args;
15
15
  const credentialsAvailable = credentialsRefId !== null;
16
+ const isTenantWidePush = adapter.channelType === "push" && userId === null;
17
+ const effectiveExternalIdentifier = isTenantWidePush ? null : externalIdentifier;
16
18
  const pollIntervalSeconds = args.pollIntervalSeconds !== void 0 ? args.pollIntervalSeconds : adapter.capabilities?.realtimePush === false ? POLLING_ONLY_DEFAULT_INTERVAL_SECONDS : null;
17
19
  const dscope = { tenantId: scope.tenantId, organizationId: scope.organizationId ?? null };
18
- if (externalIdentifier) {
19
- const normalized = externalIdentifier.toLowerCase();
20
+ if (effectiveExternalIdentifier) {
21
+ const normalized = effectiveExternalIdentifier.toLowerCase();
20
22
  const userChannels = await findWithDecryption(
21
23
  em,
22
24
  CommunicationChannel,
@@ -28,20 +30,26 @@ async function createConnectedChannelRow(args) {
28
30
  (existing) => existing.providerKey !== providerKey && typeof existing.externalIdentifier === "string" && existing.externalIdentifier.toLowerCase() === normalized
29
31
  );
30
32
  if (conflict) {
31
- throw new MailboxAlreadyConnectedError(externalIdentifier, conflict.providerKey);
33
+ throw new MailboxAlreadyConnectedError(effectiveExternalIdentifier, conflict.providerKey);
32
34
  }
33
35
  }
34
- const naturalKey = {
36
+ const dedupeFilter = isTenantWidePush ? {
37
+ tenantId: scope.tenantId,
38
+ userId: null,
39
+ providerKey,
40
+ channelType: "push",
41
+ deletedAt: null
42
+ } : effectiveExternalIdentifier ? {
35
43
  tenantId: scope.tenantId,
36
44
  userId,
37
45
  providerKey,
38
- externalIdentifier,
46
+ externalIdentifier: effectiveExternalIdentifier,
39
47
  deletedAt: null
40
- };
48
+ } : null;
41
49
  const applyConnectionState = (target) => {
42
50
  target.channelType = adapter.channelType;
43
51
  target.displayName = displayName;
44
- target.externalIdentifier = externalIdentifier ?? null;
52
+ target.externalIdentifier = effectiveExternalIdentifier ?? null;
45
53
  target.credentialsRef = credentialsRefId;
46
54
  target.capabilities = adapter.capabilities;
47
55
  target.isActive = credentialsAvailable;
@@ -49,8 +57,8 @@ async function createConnectedChannelRow(args) {
49
57
  target.status = credentialsAvailable ? "connected" : "requires_reauth";
50
58
  target.lastError = credentialsAvailable ? null : "credentials_persist_failed";
51
59
  };
52
- if (externalIdentifier) {
53
- const existing = await findOneWithDecryption(em, CommunicationChannel, naturalKey, void 0, dscope);
60
+ if (dedupeFilter) {
61
+ const existing = await findOneWithDecryption(em, CommunicationChannel, dedupeFilter, void 0, dscope);
54
62
  if (existing) {
55
63
  applyConnectionState(existing);
56
64
  await em.flush();
@@ -61,7 +69,7 @@ async function createConnectedChannelRow(args) {
61
69
  providerKey,
62
70
  channelType: adapter.channelType,
63
71
  displayName,
64
- externalIdentifier: externalIdentifier ?? null,
72
+ externalIdentifier: effectiveExternalIdentifier ?? null,
65
73
  credentialsRef: credentialsRefId,
66
74
  capabilities: adapter.capabilities,
67
75
  isActive: credentialsAvailable,
@@ -78,9 +86,9 @@ async function createConnectedChannelRow(args) {
78
86
  await em.flush();
79
87
  return channel;
80
88
  } catch (err) {
81
- if (!isUniqueViolation(err) || !externalIdentifier) throw err;
89
+ if (!isUniqueViolation(err) || !dedupeFilter) throw err;
82
90
  const reEm = em.fork();
83
- const winner = await findOneWithDecryption(reEm, CommunicationChannel, naturalKey, void 0, dscope);
91
+ const winner = await findOneWithDecryption(reEm, CommunicationChannel, dedupeFilter, void 0, dscope);
84
92
  if (!winner) throw err;
85
93
  applyConnectionState(winner);
86
94
  await reEm.flush();
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/communication_channels/lib/connect-channel.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { CommunicationChannel } from '../data/entities'\nimport type { ChannelAdapter } from './adapter'\nimport { isUniqueViolation } from './pg-errors'\n\n/**\n * Default poll cadence (seconds) for a polling-only channel \u2014 one whose adapter\n * declares `realtimePush: false`. Push-capable channels use `null` (push-driven,\n * no fixed poll). Shared so push teardown restores the same cadence connect uses.\n */\nexport const POLLING_ONLY_DEFAULT_INTERVAL_SECONDS = 300\n\n/**\n * Thrown by {@link createConnectedChannelRow} when the same mailbox\n * (`externalIdentifier`) is already connected for this user via a DIFFERENT\n * provider. Both channels would poll the same inbox, and the per-channel\n * `(channel_id, external_message_id)` dedup cannot dedupe the same email across\n * channels \u2014 so every message would be ingested (and threaded) twice.\n */\nexport class MailboxAlreadyConnectedError extends Error {\n readonly externalIdentifier: string\n readonly existingProviderKey: string\n constructor(externalIdentifier: string, existingProviderKey: string) {\n super(`Mailbox ${externalIdentifier} is already connected via ${existingProviderKey}`)\n this.name = 'MailboxAlreadyConnectedError'\n this.externalIdentifier = externalIdentifier\n this.existingProviderKey = existingProviderKey\n }\n}\n\nexport interface CreateConnectedChannelRowArgs {\n em: EntityManager\n adapter: Pick<ChannelAdapter, 'channelType' | 'capabilities'>\n providerKey: string\n displayName: string\n externalIdentifier: string | null\n credentialsRefId: string | null\n userId: string\n scope: { tenantId: string; organizationId: string | null }\n /**\n * Explicit poll-interval override (seconds). When omitted, it is derived from\n * the adapter's push capability (push-capable \u2192 null, polling-only \u2192 300).\n */\n pollIntervalSeconds?: number | null\n}\n\n/**\n * Create + persist the per-user `CommunicationChannel` row for a connect flow.\n * Shared by the credential-connect command and the OAuth callback so both entry\n * points use one channel-shape implementation instead of duplicating `em.create`.\n *\n * When credentials could not be persisted (`credentialsRefId === null`) the row\n * is created in `requires_reauth` + `isActive=false` so workers don't poll a\n * credential-less channel; the user reconnects to recover.\n */\nexport async function createConnectedChannelRow(\n args: CreateConnectedChannelRowArgs,\n): Promise<CommunicationChannel> {\n const { em, adapter, providerKey, displayName, externalIdentifier, credentialsRefId, userId, scope } = args\n const credentialsAvailable = credentialsRefId !== null\n const pollIntervalSeconds =\n args.pollIntervalSeconds !== undefined\n ? args.pollIntervalSeconds\n : adapter.capabilities?.realtimePush === false\n ? POLLING_ONLY_DEFAULT_INTERVAL_SECONDS\n : null\n const dscope = { tenantId: scope.tenantId, organizationId: scope.organizationId ?? null }\n\n // Cross-provider duplicate guard: the same mailbox must not be connected via\n // two providers for one user. Both channels would poll the same inbox, and the\n // per-channel `(channel_id, external_message_id)` dedup cannot dedupe the same\n // email across channels \u2014 so every message would be ingested (and threaded)\n // twice. Reconnecting the SAME provider/mailbox is fine (healed below); this\n // only blocks a DIFFERENT provider for an already-connected address.\n if (externalIdentifier) {\n const normalized = externalIdentifier.toLowerCase()\n const userChannels = (await findWithDecryption(\n em,\n CommunicationChannel,\n { tenantId: scope.tenantId, userId, deletedAt: null },\n undefined,\n dscope,\n )) as CommunicationChannel[]\n const conflict = userChannels.find(\n (existing) =>\n existing.providerKey !== providerKey &&\n typeof existing.externalIdentifier === 'string' &&\n existing.externalIdentifier.toLowerCase() === normalized,\n )\n if (conflict) {\n throw new MailboxAlreadyConnectedError(externalIdentifier, conflict.providerKey)\n }\n }\n\n const naturalKey = {\n tenantId: scope.tenantId,\n userId,\n providerKey,\n externalIdentifier,\n deletedAt: null,\n }\n\n // Heal-on-reconnect: a channel for the same (tenant, user, provider, mailbox)\n // already exists when the user re-runs OAuth / reconnects after a\n // `requires_reauth`. Update it in place rather than inserting a duplicate row \u2014\n // a duplicate would stay `isActive` and keep polling + re-emitting reauth\n // banners, and register a second competing push subscription. Only mailboxes\n // with a known `externalIdentifier` participate (the unique index is partial).\n const applyConnectionState = (target: CommunicationChannel): void => {\n target.channelType = adapter.channelType\n target.displayName = displayName\n target.externalIdentifier = externalIdentifier ?? null\n target.credentialsRef = credentialsRefId\n target.capabilities = adapter.capabilities as unknown as Record<string, unknown>\n target.isActive = credentialsAvailable\n target.pollIntervalSeconds = pollIntervalSeconds\n target.status = credentialsAvailable ? 'connected' : 'requires_reauth'\n target.lastError = credentialsAvailable ? null : 'credentials_persist_failed'\n }\n\n if (externalIdentifier) {\n const existing = await findOneWithDecryption(em, CommunicationChannel, naturalKey, undefined, dscope)\n if (existing) {\n applyConnectionState(existing)\n await em.flush()\n return existing\n }\n }\n\n const channel = em.create(CommunicationChannel, {\n providerKey,\n channelType: adapter.channelType,\n displayName,\n externalIdentifier: externalIdentifier ?? null,\n credentialsRef: credentialsRefId,\n capabilities: adapter.capabilities as unknown as Record<string, unknown>,\n isActive: credentialsAvailable,\n userId,\n isPrimary: false,\n pollIntervalSeconds,\n status: credentialsAvailable ? 'connected' : 'requires_reauth',\n lastError: credentialsAvailable ? null : 'credentials_persist_failed',\n tenantId: scope.tenantId,\n organizationId: scope.organizationId ?? null,\n })\n em.persist(channel)\n try {\n await em.flush()\n return channel\n } catch (err) {\n // Concurrent connect for the same mailbox won the race (partial unique index\n // rejected ours). Re-select the winner on a clean fork and heal it so the\n // caller still gets a single, connected channel.\n if (!isUniqueViolation(err) || !externalIdentifier) throw err\n const reEm = em.fork()\n const winner = await findOneWithDecryption(reEm, CommunicationChannel, naturalKey, undefined, dscope)\n if (!winner) throw err\n applyConnectionState(winner)\n await reEm.flush()\n return winner\n }\n}\n"],
5
- "mappings": "AACA,SAAS,uBAAuB,0BAA0B;AAC1D,SAAS,4BAA4B;AAErC,SAAS,yBAAyB;AAO3B,MAAM,wCAAwC;AAS9C,MAAM,qCAAqC,MAAM;AAAA,EAGtD,YAAY,oBAA4B,qBAA6B;AACnE,UAAM,WAAW,kBAAkB,6BAA6B,mBAAmB,EAAE;AACrF,SAAK,OAAO;AACZ,SAAK,qBAAqB;AAC1B,SAAK,sBAAsB;AAAA,EAC7B;AACF;AA2BA,eAAsB,0BACpB,MAC+B;AAC/B,QAAM,EAAE,IAAI,SAAS,aAAa,aAAa,oBAAoB,kBAAkB,QAAQ,MAAM,IAAI;AACvG,QAAM,uBAAuB,qBAAqB;AAClD,QAAM,sBACJ,KAAK,wBAAwB,SACzB,KAAK,sBACL,QAAQ,cAAc,iBAAiB,QACrC,wCACA;AACR,QAAM,SAAS,EAAE,UAAU,MAAM,UAAU,gBAAgB,MAAM,kBAAkB,KAAK;AAQxF,MAAI,oBAAoB;AACtB,UAAM,aAAa,mBAAmB,YAAY;AAClD,UAAM,eAAgB,MAAM;AAAA,MAC1B;AAAA,MACA;AAAA,MACA,EAAE,UAAU,MAAM,UAAU,QAAQ,WAAW,KAAK;AAAA,MACpD;AAAA,MACA;AAAA,IACF;AACA,UAAM,WAAW,aAAa;AAAA,MAC5B,CAAC,aACC,SAAS,gBAAgB,eACzB,OAAO,SAAS,uBAAuB,YACvC,SAAS,mBAAmB,YAAY,MAAM;AAAA,IAClD;AACA,QAAI,UAAU;AACZ,YAAM,IAAI,6BAA6B,oBAAoB,SAAS,WAAW;AAAA,IACjF;AAAA,EACF;AAEA,QAAM,aAAa;AAAA,IACjB,UAAU,MAAM;AAAA,IAChB;AAAA,IACA;AAAA,IACA;AAAA,IACA,WAAW;AAAA,EACb;AAQA,QAAM,uBAAuB,CAAC,WAAuC;AACnE,WAAO,cAAc,QAAQ;AAC7B,WAAO,cAAc;AACrB,WAAO,qBAAqB,sBAAsB;AAClD,WAAO,iBAAiB;AACxB,WAAO,eAAe,QAAQ;AAC9B,WAAO,WAAW;AAClB,WAAO,sBAAsB;AAC7B,WAAO,SAAS,uBAAuB,cAAc;AACrD,WAAO,YAAY,uBAAuB,OAAO;AAAA,EACnD;AAEA,MAAI,oBAAoB;AACtB,UAAM,WAAW,MAAM,sBAAsB,IAAI,sBAAsB,YAAY,QAAW,MAAM;AACpG,QAAI,UAAU;AACZ,2BAAqB,QAAQ;AAC7B,YAAM,GAAG,MAAM;AACf,aAAO;AAAA,IACT;AAAA,EACF;AAEA,QAAM,UAAU,GAAG,OAAO,sBAAsB;AAAA,IAC9C;AAAA,IACA,aAAa,QAAQ;AAAA,IACrB;AAAA,IACA,oBAAoB,sBAAsB;AAAA,IAC1C,gBAAgB;AAAA,IAChB,cAAc,QAAQ;AAAA,IACtB,UAAU;AAAA,IACV;AAAA,IACA,WAAW;AAAA,IACX;AAAA,IACA,QAAQ,uBAAuB,cAAc;AAAA,IAC7C,WAAW,uBAAuB,OAAO;AAAA,IACzC,UAAU,MAAM;AAAA,IAChB,gBAAgB,MAAM,kBAAkB;AAAA,EAC1C,CAAC;AACD,KAAG,QAAQ,OAAO;AAClB,MAAI;AACF,UAAM,GAAG,MAAM;AACf,WAAO;AAAA,EACT,SAAS,KAAK;AAIZ,QAAI,CAAC,kBAAkB,GAAG,KAAK,CAAC,mBAAoB,OAAM;AAC1D,UAAM,OAAO,GAAG,KAAK;AACrB,UAAM,SAAS,MAAM,sBAAsB,MAAM,sBAAsB,YAAY,QAAW,MAAM;AACpG,QAAI,CAAC,OAAQ,OAAM;AACnB,yBAAqB,MAAM;AAC3B,UAAM,KAAK,MAAM;AACjB,WAAO;AAAA,EACT;AACF;",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { CommunicationChannel } from '../data/entities'\nimport type { ChannelAdapter } from './adapter'\nimport { isUniqueViolation } from './pg-errors'\n\n/**\n * Default poll cadence (seconds) for a polling-only channel \u2014 one whose adapter\n * declares `realtimePush: false`. Push-capable channels use `null` (push-driven,\n * no fixed poll). Shared so push teardown restores the same cadence connect uses.\n */\nexport const POLLING_ONLY_DEFAULT_INTERVAL_SECONDS = 300\n\n/**\n * Thrown by {@link createConnectedChannelRow} when the same mailbox\n * (`externalIdentifier`) is already connected for this user via a DIFFERENT\n * provider. Both channels would poll the same inbox, and the per-channel\n * `(channel_id, external_message_id)` dedup cannot dedupe the same email across\n * channels \u2014 so every message would be ingested (and threaded) twice.\n */\nexport class MailboxAlreadyConnectedError extends Error {\n readonly externalIdentifier: string\n readonly existingProviderKey: string\n constructor(externalIdentifier: string, existingProviderKey: string) {\n super(`Mailbox ${externalIdentifier} is already connected via ${existingProviderKey}`)\n this.name = 'MailboxAlreadyConnectedError'\n this.externalIdentifier = externalIdentifier\n this.existingProviderKey = existingProviderKey\n }\n}\n\nexport interface CreateConnectedChannelRowArgs {\n em: EntityManager\n adapter: Pick<ChannelAdapter, 'channelType' | 'capabilities'>\n providerKey: string\n displayName: string\n externalIdentifier: string | null\n credentialsRefId: string | null\n /** Connecting user, or `null` for a tenant-wide channel (push: FCM/APNs/Expo). */\n userId: string | null\n scope: { tenantId: string; organizationId: string | null }\n /**\n * Explicit poll-interval override (seconds). When omitted, it is derived from\n * the adapter's push capability (push-capable \u2192 null, polling-only \u2192 300).\n */\n pollIntervalSeconds?: number | null\n}\n\n/**\n * Create + persist a `CommunicationChannel` row for a connect flow. `userId` is\n * the connecting user for per-user channels (Gmail/IMAP) or `null` for tenant-wide\n * channels (push: FCM/APNs/Expo). Shared by the credential-connect command and the\n * OAuth callback so both entry points use one channel-shape implementation instead\n * of duplicating `em.create`.\n *\n * When credentials could not be persisted (`credentialsRefId === null`) the row\n * is created in `requires_reauth` + `isActive=false` so workers don't poll a\n * credential-less channel; the user reconnects to recover.\n */\nexport async function createConnectedChannelRow(\n args: CreateConnectedChannelRowArgs,\n): Promise<CommunicationChannel> {\n const { em, adapter, providerKey, displayName, externalIdentifier, credentialsRefId, userId, scope } = args\n const credentialsAvailable = credentialsRefId !== null\n\n // A tenant-wide push channel (FCM/APNs/Expo, `user_id = NULL`) is a mailbox-less channel keyed on\n // (tenant, provider, channel_type='push', user_id NULL), enforced by\n // `communication_channels_tenant_push_provider_uq`. Push credential schemas are `.passthrough()`, so a\n // stray `email`/`username`/`fromAddress` key can leak through the connect command as an\n // `externalIdentifier`. Left in place it would send a push reconnect down the mailbox dedup branch\n // below \u2014 which never matches the existing push row \u2014 so the INSERT hits the tenant-push unique index,\n // and the mailbox recovery filter can't find the winner \u21D2 an unrecoverable 500. Drop the stray\n // identifier for tenant-wide push so the guard, the dedup key, and the stored row all stay consistent\n // with that index.\n const isTenantWidePush = adapter.channelType === 'push' && userId === null\n const effectiveExternalIdentifier = isTenantWidePush ? null : externalIdentifier\n const pollIntervalSeconds =\n args.pollIntervalSeconds !== undefined\n ? args.pollIntervalSeconds\n : adapter.capabilities?.realtimePush === false\n ? POLLING_ONLY_DEFAULT_INTERVAL_SECONDS\n : null\n const dscope = { tenantId: scope.tenantId, organizationId: scope.organizationId ?? null }\n\n // Cross-provider duplicate guard: the same mailbox must not be connected via\n // two providers for one user. Both channels would poll the same inbox, and the\n // per-channel `(channel_id, external_message_id)` dedup cannot dedupe the same\n // email across channels \u2014 so every message would be ingested (and threaded)\n // twice. Reconnecting the SAME provider/mailbox is fine (healed below); this\n // only blocks a DIFFERENT provider for an already-connected address.\n if (effectiveExternalIdentifier) {\n const normalized = effectiveExternalIdentifier.toLowerCase()\n const userChannels = (await findWithDecryption(\n em,\n CommunicationChannel,\n { tenantId: scope.tenantId, userId, deletedAt: null },\n undefined,\n dscope,\n )) as CommunicationChannel[]\n const conflict = userChannels.find(\n (existing) =>\n existing.providerKey !== providerKey &&\n typeof existing.externalIdentifier === 'string' &&\n existing.externalIdentifier.toLowerCase() === normalized,\n )\n if (conflict) {\n throw new MailboxAlreadyConnectedError(effectiveExternalIdentifier, conflict.providerKey)\n }\n }\n\n // Heal-on-reconnect dedup key. Two shapes:\n // - mailbox channels (Gmail/IMAP): keyed on (tenant, user, provider, mailbox);\n // only rows with a known `externalIdentifier` participate.\n // - tenant-wide push channels (FCM/APNs/Expo): no mailbox, `user_id = NULL`, so\n // keyed on (tenant, provider, channel_type='push', user_id NULL). Without this\n // every admin reconnect would insert a duplicate (fan-out takes the oldest per\n // provider, so duplicates are silent noise + a concurrent-connect race).\n // `null` \u21D2 no dedup key (identifier-less non-push channel): always insert.\n const dedupeFilter = isTenantWidePush\n ? {\n tenantId: scope.tenantId,\n userId: null,\n providerKey,\n channelType: 'push',\n deletedAt: null,\n }\n : effectiveExternalIdentifier\n ? {\n tenantId: scope.tenantId,\n userId,\n providerKey,\n externalIdentifier: effectiveExternalIdentifier,\n deletedAt: null,\n }\n : null\n\n // Heal-on-reconnect: a channel for the same dedup key already exists when the\n // user re-runs OAuth / reconnects after a `requires_reauth`, or an admin\n // re-submits a tenant push provider's credentials. Update it in place rather\n // than inserting a duplicate row \u2014 a duplicate would stay `isActive` and keep\n // polling + re-emitting reauth banners, and register a second competing push\n // subscription. Backed by the partial unique indexes\n // `communication_channels_user_provider_external_uq` (mailbox) and\n // `communication_channels_tenant_push_provider_uq` (tenant push).\n const applyConnectionState = (target: CommunicationChannel): void => {\n target.channelType = adapter.channelType\n target.displayName = displayName\n target.externalIdentifier = effectiveExternalIdentifier ?? null\n target.credentialsRef = credentialsRefId\n target.capabilities = adapter.capabilities as unknown as Record<string, unknown>\n target.isActive = credentialsAvailable\n target.pollIntervalSeconds = pollIntervalSeconds\n target.status = credentialsAvailable ? 'connected' : 'requires_reauth'\n target.lastError = credentialsAvailable ? null : 'credentials_persist_failed'\n }\n\n if (dedupeFilter) {\n const existing = await findOneWithDecryption(em, CommunicationChannel, dedupeFilter, undefined, dscope)\n if (existing) {\n applyConnectionState(existing)\n await em.flush()\n return existing\n }\n }\n\n const channel = em.create(CommunicationChannel, {\n providerKey,\n channelType: adapter.channelType,\n displayName,\n externalIdentifier: effectiveExternalIdentifier ?? null,\n credentialsRef: credentialsRefId,\n capabilities: adapter.capabilities as unknown as Record<string, unknown>,\n isActive: credentialsAvailable,\n userId,\n isPrimary: false,\n pollIntervalSeconds,\n status: credentialsAvailable ? 'connected' : 'requires_reauth',\n lastError: credentialsAvailable ? null : 'credentials_persist_failed',\n tenantId: scope.tenantId,\n organizationId: scope.organizationId ?? null,\n })\n em.persist(channel)\n try {\n await em.flush()\n return channel\n } catch (err) {\n // Concurrent connect for the same mailbox won the race (partial unique index\n // rejected ours). Re-select the winner on a clean fork and heal it so the\n // caller still gets a single, connected channel.\n if (!isUniqueViolation(err) || !dedupeFilter) throw err\n const reEm = em.fork()\n const winner = await findOneWithDecryption(reEm, CommunicationChannel, dedupeFilter, undefined, dscope)\n if (!winner) throw err\n applyConnectionState(winner)\n await reEm.flush()\n return winner\n }\n}\n"],
5
+ "mappings": "AACA,SAAS,uBAAuB,0BAA0B;AAC1D,SAAS,4BAA4B;AAErC,SAAS,yBAAyB;AAO3B,MAAM,wCAAwC;AAS9C,MAAM,qCAAqC,MAAM;AAAA,EAGtD,YAAY,oBAA4B,qBAA6B;AACnE,UAAM,WAAW,kBAAkB,6BAA6B,mBAAmB,EAAE;AACrF,SAAK,OAAO;AACZ,SAAK,qBAAqB;AAC1B,SAAK,sBAAsB;AAAA,EAC7B;AACF;AA8BA,eAAsB,0BACpB,MAC+B;AAC/B,QAAM,EAAE,IAAI,SAAS,aAAa,aAAa,oBAAoB,kBAAkB,QAAQ,MAAM,IAAI;AACvG,QAAM,uBAAuB,qBAAqB;AAWlD,QAAM,mBAAmB,QAAQ,gBAAgB,UAAU,WAAW;AACtE,QAAM,8BAA8B,mBAAmB,OAAO;AAC9D,QAAM,sBACJ,KAAK,wBAAwB,SACzB,KAAK,sBACL,QAAQ,cAAc,iBAAiB,QACrC,wCACA;AACR,QAAM,SAAS,EAAE,UAAU,MAAM,UAAU,gBAAgB,MAAM,kBAAkB,KAAK;AAQxF,MAAI,6BAA6B;AAC/B,UAAM,aAAa,4BAA4B,YAAY;AAC3D,UAAM,eAAgB,MAAM;AAAA,MAC1B;AAAA,MACA;AAAA,MACA,EAAE,UAAU,MAAM,UAAU,QAAQ,WAAW,KAAK;AAAA,MACpD;AAAA,MACA;AAAA,IACF;AACA,UAAM,WAAW,aAAa;AAAA,MAC5B,CAAC,aACC,SAAS,gBAAgB,eACzB,OAAO,SAAS,uBAAuB,YACvC,SAAS,mBAAmB,YAAY,MAAM;AAAA,IAClD;AACA,QAAI,UAAU;AACZ,YAAM,IAAI,6BAA6B,6BAA6B,SAAS,WAAW;AAAA,IAC1F;AAAA,EACF;AAUA,QAAM,eAAe,mBACjB;AAAA,IACE,UAAU,MAAM;AAAA,IAChB,QAAQ;AAAA,IACR;AAAA,IACA,aAAa;AAAA,IACb,WAAW;AAAA,EACb,IACA,8BACE;AAAA,IACE,UAAU,MAAM;AAAA,IAChB;AAAA,IACA;AAAA,IACA,oBAAoB;AAAA,IACpB,WAAW;AAAA,EACb,IACA;AAUN,QAAM,uBAAuB,CAAC,WAAuC;AACnE,WAAO,cAAc,QAAQ;AAC7B,WAAO,cAAc;AACrB,WAAO,qBAAqB,+BAA+B;AAC3D,WAAO,iBAAiB;AACxB,WAAO,eAAe,QAAQ;AAC9B,WAAO,WAAW;AAClB,WAAO,sBAAsB;AAC7B,WAAO,SAAS,uBAAuB,cAAc;AACrD,WAAO,YAAY,uBAAuB,OAAO;AAAA,EACnD;AAEA,MAAI,cAAc;AAChB,UAAM,WAAW,MAAM,sBAAsB,IAAI,sBAAsB,cAAc,QAAW,MAAM;AACtG,QAAI,UAAU;AACZ,2BAAqB,QAAQ;AAC7B,YAAM,GAAG,MAAM;AACf,aAAO;AAAA,IACT;AAAA,EACF;AAEA,QAAM,UAAU,GAAG,OAAO,sBAAsB;AAAA,IAC9C;AAAA,IACA,aAAa,QAAQ;AAAA,IACrB;AAAA,IACA,oBAAoB,+BAA+B;AAAA,IACnD,gBAAgB;AAAA,IAChB,cAAc,QAAQ;AAAA,IACtB,UAAU;AAAA,IACV;AAAA,IACA,WAAW;AAAA,IACX;AAAA,IACA,QAAQ,uBAAuB,cAAc;AAAA,IAC7C,WAAW,uBAAuB,OAAO;AAAA,IACzC,UAAU,MAAM;AAAA,IAChB,gBAAgB,MAAM,kBAAkB;AAAA,EAC1C,CAAC;AACD,KAAG,QAAQ,OAAO;AAClB,MAAI;AACF,UAAM,GAAG,MAAM;AACf,WAAO;AAAA,EACT,SAAS,KAAK;AAIZ,QAAI,CAAC,kBAAkB,GAAG,KAAK,CAAC,aAAc,OAAM;AACpD,UAAM,OAAO,GAAG,KAAK;AACrB,UAAM,SAAS,MAAM,sBAAsB,MAAM,sBAAsB,cAAc,QAAW,MAAM;AACtG,QAAI,CAAC,OAAQ,OAAM;AACnB,yBAAqB,MAAM;AAC3B,UAAM,KAAK,MAAM;AACjB,WAAO;AAAA,EACT;AACF;",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,68 @@
1
+ import { pushChannelCapabilities } from "./push-capabilities.js";
2
+ import { resolvePushCredentialIssue } from "./push-credential-errors.js";
3
+ const DEVICE_UNREGISTERED = "device_unregistered";
4
+ function deviceUnregisteredResult(metadata) {
5
+ return {
6
+ externalMessageId: "",
7
+ status: "failed",
8
+ error: DEVICE_UNREGISTERED,
9
+ metadata: { unregistered: true, ...metadata ?? {} }
10
+ };
11
+ }
12
+ const MISSING_PUSH_TOKEN_RESULT = {
13
+ externalMessageId: "",
14
+ status: "failed",
15
+ error: "missing_push_token"
16
+ };
17
+ function supportsReceiptChecking(adapter) {
18
+ return typeof adapter?.checkReceipts === "function";
19
+ }
20
+ function readPushToken(input) {
21
+ const token = typeof input.metadata?.pushToken === "string" ? input.metadata.pushToken : "";
22
+ return token.length > 0 ? token : null;
23
+ }
24
+ class BasePushChannelAdapter {
25
+ constructor() {
26
+ this.channelType = "push";
27
+ // Push credentials (service account / signing key) serve every device in the
28
+ // tenant, so a push channel is tenant-wide (user_id = NULL), connected by an
29
+ // admin — never a per-user channel. Covers fcm/apns/expo + the test stub.
30
+ this.channelScope = "tenant";
31
+ this.capabilities = pushChannelCapabilities;
32
+ }
33
+ async convertOutbound(input) {
34
+ return { content: { text: input.body, bodyFormat: input.bodyFormat }, metadata: input.channelMetadata ?? {} };
35
+ }
36
+ async verifyWebhook(_input) {
37
+ return { raw: {}, eventType: "other", metadata: { reason: `${this.providerKey}-no-webhook` } };
38
+ }
39
+ async getStatus(_input) {
40
+ return { status: "sent" };
41
+ }
42
+ async normalizeInbound(_raw) {
43
+ throw new Error(`[internal] ${this.providerKey} push adapter normalizeInbound is not used (push is outbound-only)`);
44
+ }
45
+ async validateCredentials(input) {
46
+ const parsed = this.credentialsSchema.safeParse(input.credentials);
47
+ if (parsed.success) return { ok: true };
48
+ const errors = {};
49
+ const errorCodes = {};
50
+ for (const issue of parsed.error.issues) {
51
+ const key = issue.path[0];
52
+ if (typeof key !== "string" || errors[key]) continue;
53
+ const resolved = resolvePushCredentialIssue(issue.message);
54
+ errors[key] = resolved.message;
55
+ errorCodes[key] = resolved.code;
56
+ }
57
+ return { ok: false, errors, errorCodes };
58
+ }
59
+ }
60
+ export {
61
+ BasePushChannelAdapter,
62
+ DEVICE_UNREGISTERED,
63
+ MISSING_PUSH_TOKEN_RESULT,
64
+ deviceUnregisteredResult,
65
+ readPushToken,
66
+ supportsReceiptChecking
67
+ };
68
+ //# sourceMappingURL=push-adapter.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/communication_channels/lib/push-adapter.ts"],
4
+ "sourcesContent": ["import type { ZodType } from 'zod'\nimport type {\n ChannelAdapter,\n ChannelCapabilities,\n ChannelNativeContent,\n ConvertOutboundInput,\n GetMessageStatusInput,\n InboundMessage,\n MessageStatus,\n NormalizedInboundMessage,\n SendMessageInput,\n SendMessageResult,\n ValidateCredentialsInput,\n ValidateCredentialsResult,\n VerifyWebhookInput,\n} from './adapter'\nimport { pushChannelCapabilities } from './push-capabilities'\nimport { resolvePushCredentialIssue } from './push-credential-errors'\n\n/**\n * Uniform sentinel a push adapter returns for a permanently-invalid device token.\n * The `push_notifications` worker checks `error === DEVICE_UNREGISTERED ||\n * metadata.unregistered === true` and soft-deletes the device \u2014 so every push\n * provider (fcm/apns/expo + the test stub) MUST use this exact shape.\n */\nexport const DEVICE_UNREGISTERED = 'device_unregistered'\n\n/** Build the uniform `device_unregistered` result. Extra metadata is merged in. */\nexport function deviceUnregisteredResult(metadata?: Record<string, unknown>): SendMessageResult {\n return {\n externalMessageId: '',\n status: 'failed',\n error: DEVICE_UNREGISTERED,\n metadata: { unregistered: true, ...(metadata ?? {}) },\n }\n}\n\n/** Standard result when a send is attempted without a device token. */\nexport const MISSING_PUSH_TOKEN_RESULT: SendMessageResult = {\n externalMessageId: '',\n status: 'failed',\n error: 'missing_push_token',\n}\n\n/**\n * Outcome of checking one provider delivery receipt for a previously-accepted push.\n * Emitted only for tickets whose receipt is already available (`resolved`); a ticket whose\n * receipt is not yet ready is omitted so the caller retries it on a later sweep.\n */\nexport interface PushReceiptOutcome {\n /** The provider ticket id returned by the synchronous send (persisted as `externalMessageId`). */\n ticketId: string\n /** The receipt reports the device token is permanently invalid \u21D2 soft-delete the device. */\n unregistered: boolean\n}\n\n/**\n * Optional capability for providers whose \"device unregistered\" signal arrives in an ASYNCHRONOUS\n * receipt phase rather than the synchronous send ticket (Expo). Implemented by the Expo adapter and\n * polled by the push_notifications receipt reaper; FCM/APNs prune synchronously in `sendMessage` and\n * do not implement it. Additive to `ChannelAdapter` \u2014 feature-detected via `supportsReceiptChecking`.\n */\nexport interface PushReceiptChecker {\n /**\n * Fetch delivery receipts for previously-accepted push tickets and report which map to a\n * permanently-unregistered device token. Return an entry ONLY for tickets whose receipt is ready;\n * omit tickets with no receipt yet so the caller re-checks them later. Reuses the same tenant\n * credentials as the send path.\n */\n checkReceipts(ticketIds: string[], credentials: unknown): Promise<PushReceiptOutcome[]>\n}\n\n/** Feature-detect the optional `PushReceiptChecker` capability on a resolved channel adapter. */\nexport function supportsReceiptChecking(adapter: unknown): adapter is PushReceiptChecker {\n return typeof (adapter as { checkReceipts?: unknown } | null | undefined)?.checkReceipts === 'function'\n}\n\n/** Extract the per-call push token from `SendMessageInput.metadata`, or null if absent. */\nexport function readPushToken(input: SendMessageInput): string | null {\n const token = typeof input.metadata?.pushToken === 'string' ? input.metadata.pushToken : ''\n return token.length > 0 ? token : null\n}\n\n/**\n * Shared base for outbound-only mobile-push `ChannelAdapter`s (fcm/apns/expo and\n * the test `push_stub`). Concrete adapters provide just `providerKey`,\n * `credentialsSchema`, and `sendMessage`; the push capabilities baseline, the\n * no-op inbound surface (push is outbound-only), the passthrough `convertOutbound`,\n * and schema-driven `validateCredentials` are shared so the push family stays\n * consistent. This is push-specific and does NOT change the per-package\n * standalone convention used by the email channel adapters (gmail/imap).\n */\nexport abstract class BasePushChannelAdapter implements ChannelAdapter {\n abstract readonly providerKey: string\n readonly channelType = 'push'\n // Push credentials (service account / signing key) serve every device in the\n // tenant, so a push channel is tenant-wide (user_id = NULL), connected by an\n // admin \u2014 never a per-user channel. Covers fcm/apns/expo + the test stub.\n readonly channelScope = 'tenant' as const\n readonly capabilities: ChannelCapabilities = pushChannelCapabilities\n\n /** Zod schema for this provider's tenant-level credentials; drives `validateCredentials`. */\n protected abstract readonly credentialsSchema: ZodType<unknown>\n\n abstract sendMessage(input: SendMessageInput): Promise<SendMessageResult>\n\n async convertOutbound(input: ConvertOutboundInput): Promise<ChannelNativeContent> {\n return { content: { text: input.body, bodyFormat: input.bodyFormat }, metadata: input.channelMetadata ?? {} }\n }\n\n async verifyWebhook(_input: VerifyWebhookInput): Promise<InboundMessage> {\n return { raw: {}, eventType: 'other', metadata: { reason: `${this.providerKey}-no-webhook` } }\n }\n\n async getStatus(_input: GetMessageStatusInput): Promise<MessageStatus> {\n return { status: 'sent' }\n }\n\n async normalizeInbound(_raw: InboundMessage): Promise<NormalizedInboundMessage> {\n throw new Error(`[internal] ${this.providerKey} push adapter normalizeInbound is not used (push is outbound-only)`)\n }\n\n async validateCredentials(input: ValidateCredentialsInput): Promise<ValidateCredentialsResult> {\n const parsed = this.credentialsSchema.safeParse(input.credentials)\n if (parsed.success) return { ok: true }\n const errors: Record<string, string> = {}\n const errorCodes: Record<string, string> = {}\n for (const issue of parsed.error.issues) {\n const key = issue.path[0]\n if (typeof key !== 'string' || errors[key]) continue\n // Schemas emit a stable code as the issue message; keep the English prose\n // for API consumers and hand the code to the UI so it can localize.\n const resolved = resolvePushCredentialIssue(issue.message)\n errors[key] = resolved.message\n errorCodes[key] = resolved.code\n }\n return { ok: false, errors, errorCodes }\n }\n}\n"],
5
+ "mappings": "AAgBA,SAAS,+BAA+B;AACxC,SAAS,kCAAkC;AAQpC,MAAM,sBAAsB;AAG5B,SAAS,yBAAyB,UAAuD;AAC9F,SAAO;AAAA,IACL,mBAAmB;AAAA,IACnB,QAAQ;AAAA,IACR,OAAO;AAAA,IACP,UAAU,EAAE,cAAc,MAAM,GAAI,YAAY,CAAC,EAAG;AAAA,EACtD;AACF;AAGO,MAAM,4BAA+C;AAAA,EAC1D,mBAAmB;AAAA,EACnB,QAAQ;AAAA,EACR,OAAO;AACT;AA+BO,SAAS,wBAAwB,SAAiD;AACvF,SAAO,OAAQ,SAA4D,kBAAkB;AAC/F;AAGO,SAAS,cAAc,OAAwC;AACpE,QAAM,QAAQ,OAAO,MAAM,UAAU,cAAc,WAAW,MAAM,SAAS,YAAY;AACzF,SAAO,MAAM,SAAS,IAAI,QAAQ;AACpC;AAWO,MAAe,uBAAiD;AAAA,EAAhE;AAEL,SAAS,cAAc;AAIvB;AAAA;AAAA;AAAA,SAAS,eAAe;AACxB,SAAS,eAAoC;AAAA;AAAA,EAO7C,MAAM,gBAAgB,OAA4D;AAChF,WAAO,EAAE,SAAS,EAAE,MAAM,MAAM,MAAM,YAAY,MAAM,WAAW,GAAG,UAAU,MAAM,mBAAmB,CAAC,EAAE;AAAA,EAC9G;AAAA,EAEA,MAAM,cAAc,QAAqD;AACvE,WAAO,EAAE,KAAK,CAAC,GAAG,WAAW,SAAS,UAAU,EAAE,QAAQ,GAAG,KAAK,WAAW,cAAc,EAAE;AAAA,EAC/F;AAAA,EAEA,MAAM,UAAU,QAAuD;AACrE,WAAO,EAAE,QAAQ,OAAO;AAAA,EAC1B;AAAA,EAEA,MAAM,iBAAiB,MAAyD;AAC9E,UAAM,IAAI,MAAM,cAAc,KAAK,WAAW,oEAAoE;AAAA,EACpH;AAAA,EAEA,MAAM,oBAAoB,OAAqE;AAC7F,UAAM,SAAS,KAAK,kBAAkB,UAAU,MAAM,WAAW;AACjE,QAAI,OAAO,QAAS,QAAO,EAAE,IAAI,KAAK;AACtC,UAAM,SAAiC,CAAC;AACxC,UAAM,aAAqC,CAAC;AAC5C,eAAW,SAAS,OAAO,MAAM,QAAQ;AACvC,YAAM,MAAM,MAAM,KAAK,CAAC;AACxB,UAAI,OAAO,QAAQ,YAAY,OAAO,GAAG,EAAG;AAG5C,YAAM,WAAW,2BAA2B,MAAM,OAAO;AACzD,aAAO,GAAG,IAAI,SAAS;AACvB,iBAAW,GAAG,IAAI,SAAS;AAAA,IAC7B;AACA,WAAO,EAAE,IAAI,OAAO,QAAQ,WAAW;AAAA,EACzC;AACF;",
6
+ "names": []
7
+ }
@@ -0,0 +1,31 @@
1
+ const pushChannelCapabilities = {
2
+ // Core
3
+ threading: false,
4
+ richText: false,
5
+ fileSharing: false,
6
+ readReceipts: false,
7
+ deliveryReceipts: false,
8
+ typingIndicators: false,
9
+ // Extended
10
+ reactions: false,
11
+ multiReactionPerUser: false,
12
+ editMessage: false,
13
+ deleteMessage: false,
14
+ presence: false,
15
+ richBlocks: false,
16
+ interactiveComponents: false,
17
+ inlineImages: false,
18
+ conversationHistory: false,
19
+ contactCards: false,
20
+ locationSharing: false,
21
+ voiceNotes: false,
22
+ stickers: false,
23
+ // Body formats
24
+ supportedBodyFormats: ["text"],
25
+ // Push is real-time; no polling.
26
+ realtimePush: true
27
+ };
28
+ export {
29
+ pushChannelCapabilities
30
+ };
31
+ //# sourceMappingURL=push-capabilities.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/communication_channels/lib/push-capabilities.ts"],
4
+ "sourcesContent": ["import type { ChannelCapabilities } from './adapter'\n\n/**\n * Baseline capability profile shared by every mobile-push channel provider\n * (FCM / APNs / Expo) and the test-only `push_stub` adapter. Mirrors the\n * `baseEmailCapabilities` pattern so the push contract has a single source of\n * truth: push is a fire-and-forget outbound channel with no threading, history,\n * reactions, or rich content, and it is real-time (`realtimePush: true`) so the\n * hub never schedules polling for it.\n *\n * Providers spread this and override only what genuinely differs.\n */\nexport const pushChannelCapabilities: ChannelCapabilities = {\n // Core\n threading: false,\n richText: false,\n fileSharing: false,\n readReceipts: false,\n deliveryReceipts: false,\n typingIndicators: false,\n\n // Extended\n reactions: false,\n multiReactionPerUser: false,\n editMessage: false,\n deleteMessage: false,\n presence: false,\n richBlocks: false,\n interactiveComponents: false,\n inlineImages: false,\n conversationHistory: false,\n contactCards: false,\n locationSharing: false,\n voiceNotes: false,\n stickers: false,\n\n // Body formats\n supportedBodyFormats: ['text'],\n\n // Push is real-time; no polling.\n realtimePush: true,\n}\n"],
5
+ "mappings": "AAYO,MAAM,0BAA+C;AAAA;AAAA,EAE1D,WAAW;AAAA,EACX,UAAU;AAAA,EACV,aAAa;AAAA,EACb,cAAc;AAAA,EACd,kBAAkB;AAAA,EAClB,kBAAkB;AAAA;AAAA,EAGlB,WAAW;AAAA,EACX,sBAAsB;AAAA,EACtB,aAAa;AAAA,EACb,eAAe;AAAA,EACf,UAAU;AAAA,EACV,YAAY;AAAA,EACZ,uBAAuB;AAAA,EACvB,cAAc;AAAA,EACd,qBAAqB;AAAA,EACrB,cAAc;AAAA,EACd,iBAAiB;AAAA,EACjB,YAAY;AAAA,EACZ,UAAU;AAAA;AAAA,EAGV,sBAAsB,CAAC,MAAM;AAAA;AAAA,EAG7B,cAAc;AAChB;",
6
+ "names": []
7
+ }
@@ -0,0 +1,20 @@
1
+ function resolvePushConnectErrorMessage(translate, body) {
2
+ const fallback = translate("communication_channels.push.connect.failed", "Could not connect push provider.");
3
+ if (!body?.code) return fallback;
4
+ return translate(`communication_channels.push.connect.errors.${body.code}`, fallback);
5
+ }
6
+ function resolvePushConnectFieldErrors(translate, body) {
7
+ const fieldErrors = body?.fieldErrors ?? {};
8
+ const fieldErrorCodes = body?.fieldErrorCodes ?? {};
9
+ const resolved = {};
10
+ for (const [field, message] of Object.entries(fieldErrors)) {
11
+ const code = fieldErrorCodes[field];
12
+ resolved[field] = code ? translate(`communication_channels.push.connect.errors.fields.${code}`, message) : message;
13
+ }
14
+ return resolved;
15
+ }
16
+ export {
17
+ resolvePushConnectErrorMessage,
18
+ resolvePushConnectFieldErrors
19
+ };
20
+ //# sourceMappingURL=push-connect-error.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/communication_channels/lib/push-connect-error.ts"],
4
+ "sourcesContent": ["export type PushConnectErrorBody = {\n code?: string | null\n fieldErrors?: Record<string, string> | null\n fieldErrorCodes?: Record<string, string> | null\n}\n\nexport type PushConnectTranslate = (key: string, fallback: string) => string\n\n/**\n * Map a push connect route's structured error `code`\n * (`provider_not_tenant_scoped`, `mailbox_already_connected`,\n * `wrong_scope_for_route`, \u2026) to a localized\n * `communication_channels.push.connect.errors.<code>` message, falling back to\n * the generic `push.connect.failed` when the response carries no code or no\n * matching translation exists. The raw English `error` string the route also\n * returns stays reserved for logs/API consumers so the shipped locale files\n * apply on the failure path.\n */\nexport function resolvePushConnectErrorMessage(\n translate: PushConnectTranslate,\n body: PushConnectErrorBody | undefined,\n): string {\n const fallback = translate('communication_channels.push.connect.failed', 'Could not connect push provider.')\n if (!body?.code) return fallback\n return translate(`communication_channels.push.connect.errors.${body.code}`, fallback)\n}\n\n/**\n * Localize the per-field credential-validation errors a 422 connect response\n * carries. Each field's `fieldErrorCodes` entry maps to\n * `communication_channels.push.connect.errors.fields.<code>`; when a field has\n * no code (an adapter that predates the code contract) or the locale ships no\n * matching key, the raw English `fieldErrors` string is used so the operator\n * still sees something actionable rather than a blank field.\n *\n * Returns a map ready for a widget's `setFieldErrors`.\n */\nexport function resolvePushConnectFieldErrors(\n translate: PushConnectTranslate,\n body: PushConnectErrorBody | undefined,\n): Record<string, string> {\n const fieldErrors = body?.fieldErrors ?? {}\n const fieldErrorCodes = body?.fieldErrorCodes ?? {}\n const resolved: Record<string, string> = {}\n for (const [field, message] of Object.entries(fieldErrors)) {\n const code = fieldErrorCodes[field]\n resolved[field] = code\n ? translate(`communication_channels.push.connect.errors.fields.${code}`, message)\n : message\n }\n return resolved\n}\n"],
5
+ "mappings": "AAkBO,SAAS,+BACd,WACA,MACQ;AACR,QAAM,WAAW,UAAU,8CAA8C,kCAAkC;AAC3G,MAAI,CAAC,MAAM,KAAM,QAAO;AACxB,SAAO,UAAU,8CAA8C,KAAK,IAAI,IAAI,QAAQ;AACtF;AAYO,SAAS,8BACd,WACA,MACwB;AACxB,QAAM,cAAc,MAAM,eAAe,CAAC;AAC1C,QAAM,kBAAkB,MAAM,mBAAmB,CAAC;AAClD,QAAM,WAAmC,CAAC;AAC1C,aAAW,CAAC,OAAO,OAAO,KAAK,OAAO,QAAQ,WAAW,GAAG;AAC1D,UAAM,OAAO,gBAAgB,KAAK;AAClC,aAAS,KAAK,IAAI,OACd,UAAU,qDAAqD,IAAI,IAAI,OAAO,IAC9E;AAAA,EACN;AACA,SAAO;AACT;",
6
+ "names": []
7
+ }
@@ -0,0 +1,41 @@
1
+ const PUSH_CREDENTIAL_ERROR_REQUIRED = "required";
2
+ const PUSH_CREDENTIAL_ERROR_INVALID = "invalid";
3
+ const PUSH_CREDENTIAL_ERROR_INVALID_JSON = "invalid_json";
4
+ const PUSH_CREDENTIAL_ERROR_MISSING_FIELDS = "missing_fields";
5
+ const PUSH_CREDENTIAL_ERROR_INVALID_P8 = "invalid_p8";
6
+ const PUSH_CREDENTIAL_ERROR_INVALID_KEY_ID = "invalid_key_id";
7
+ const PUSH_CREDENTIAL_ERROR_INVALID_TEAM_ID = "invalid_team_id";
8
+ const PUSH_CREDENTIAL_ERROR_INVALID_BUNDLE_ID = "invalid_bundle_id";
9
+ const PUSH_CREDENTIAL_ERROR_MESSAGES = {
10
+ [PUSH_CREDENTIAL_ERROR_REQUIRED]: "This field is required.",
11
+ [PUSH_CREDENTIAL_ERROR_INVALID]: "This value is invalid.",
12
+ [PUSH_CREDENTIAL_ERROR_INVALID_JSON]: "This is not valid JSON. Paste the service account file exactly as Firebase generated it.",
13
+ [PUSH_CREDENTIAL_ERROR_MISSING_FIELDS]: "The service account JSON is missing required fields (project_id, client_email, private_key).",
14
+ [PUSH_CREDENTIAL_ERROR_INVALID_P8]: "This is not a readable APNs .p8 signing key. Paste the whole file including the BEGIN and END lines.",
15
+ [PUSH_CREDENTIAL_ERROR_INVALID_KEY_ID]: "An APNs Key ID is 10 alphanumeric characters.",
16
+ [PUSH_CREDENTIAL_ERROR_INVALID_TEAM_ID]: "An Apple Team ID is 10 alphanumeric characters.",
17
+ [PUSH_CREDENTIAL_ERROR_INVALID_BUNDLE_ID]: "A Bundle ID looks like com.example.app."
18
+ };
19
+ function isPushCredentialErrorCode(value) {
20
+ return Object.prototype.hasOwnProperty.call(PUSH_CREDENTIAL_ERROR_MESSAGES, value);
21
+ }
22
+ function resolvePushCredentialIssue(issueMessage) {
23
+ if (isPushCredentialErrorCode(issueMessage)) {
24
+ return { code: issueMessage, message: PUSH_CREDENTIAL_ERROR_MESSAGES[issueMessage] };
25
+ }
26
+ return { code: PUSH_CREDENTIAL_ERROR_INVALID, message: issueMessage };
27
+ }
28
+ export {
29
+ PUSH_CREDENTIAL_ERROR_INVALID,
30
+ PUSH_CREDENTIAL_ERROR_INVALID_BUNDLE_ID,
31
+ PUSH_CREDENTIAL_ERROR_INVALID_JSON,
32
+ PUSH_CREDENTIAL_ERROR_INVALID_KEY_ID,
33
+ PUSH_CREDENTIAL_ERROR_INVALID_P8,
34
+ PUSH_CREDENTIAL_ERROR_INVALID_TEAM_ID,
35
+ PUSH_CREDENTIAL_ERROR_MESSAGES,
36
+ PUSH_CREDENTIAL_ERROR_MISSING_FIELDS,
37
+ PUSH_CREDENTIAL_ERROR_REQUIRED,
38
+ isPushCredentialErrorCode,
39
+ resolvePushCredentialIssue
40
+ };
41
+ //# sourceMappingURL=push-credential-errors.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/communication_channels/lib/push-credential-errors.ts"],
4
+ "sourcesContent": ["/**\n * Stable field-error codes for push credential validation (fcm/apns/expo).\n *\n * Push credential schemas emit one of these codes as the zod issue `message`\n * instead of prose. {@link BasePushChannelAdapter.validateCredentials} then\n * splits each issue into two channels:\n *\n * - `errors[field]` \u2014 English prose from {@link PUSH_CREDENTIAL_ERROR_MESSAGES},\n * reserved for logs and API consumers.\n * - `errorCodes[field]` \u2014 the code, which the connect widgets map to\n * `communication_channels.push.connect.errors.fields.<code>`\n * so operators see a localized message.\n *\n * This mirrors the route-level split already used by `push-connect-error.ts`.\n * An issue whose message is not a known code degrades to {@link PUSH_CREDENTIAL_ERROR_INVALID}\n * with the raw zod message kept in `errors`, so a schema change can never leak\n * an untranslated internal string into the UI.\n */\n\nexport const PUSH_CREDENTIAL_ERROR_REQUIRED = 'required'\nexport const PUSH_CREDENTIAL_ERROR_INVALID = 'invalid'\nexport const PUSH_CREDENTIAL_ERROR_INVALID_JSON = 'invalid_json'\nexport const PUSH_CREDENTIAL_ERROR_MISSING_FIELDS = 'missing_fields'\nexport const PUSH_CREDENTIAL_ERROR_INVALID_P8 = 'invalid_p8'\nexport const PUSH_CREDENTIAL_ERROR_INVALID_KEY_ID = 'invalid_key_id'\nexport const PUSH_CREDENTIAL_ERROR_INVALID_TEAM_ID = 'invalid_team_id'\nexport const PUSH_CREDENTIAL_ERROR_INVALID_BUNDLE_ID = 'invalid_bundle_id'\n\n/**\n * English prose per code. Kept as the `errors` payload so existing API\n * consumers and log readers still get a human-readable string.\n */\nexport const PUSH_CREDENTIAL_ERROR_MESSAGES: Record<string, string> = {\n [PUSH_CREDENTIAL_ERROR_REQUIRED]: 'This field is required.',\n [PUSH_CREDENTIAL_ERROR_INVALID]: 'This value is invalid.',\n [PUSH_CREDENTIAL_ERROR_INVALID_JSON]: 'This is not valid JSON. Paste the service account file exactly as Firebase generated it.',\n [PUSH_CREDENTIAL_ERROR_MISSING_FIELDS]: 'The service account JSON is missing required fields (project_id, client_email, private_key).',\n [PUSH_CREDENTIAL_ERROR_INVALID_P8]: 'This is not a readable APNs .p8 signing key. Paste the whole file including the BEGIN and END lines.',\n [PUSH_CREDENTIAL_ERROR_INVALID_KEY_ID]: 'An APNs Key ID is 10 alphanumeric characters.',\n [PUSH_CREDENTIAL_ERROR_INVALID_TEAM_ID]: 'An Apple Team ID is 10 alphanumeric characters.',\n [PUSH_CREDENTIAL_ERROR_INVALID_BUNDLE_ID]: 'A Bundle ID looks like com.example.app.',\n}\n\nexport function isPushCredentialErrorCode(value: string): boolean {\n return Object.prototype.hasOwnProperty.call(PUSH_CREDENTIAL_ERROR_MESSAGES, value)\n}\n\n/**\n * Split a zod issue message into `{ code, message }`. Known codes resolve to\n * their English prose; anything else is reported as {@link PUSH_CREDENTIAL_ERROR_INVALID}\n * while preserving the original text for logs.\n */\nexport function resolvePushCredentialIssue(issueMessage: string): { code: string; message: string } {\n if (isPushCredentialErrorCode(issueMessage)) {\n return { code: issueMessage, message: PUSH_CREDENTIAL_ERROR_MESSAGES[issueMessage] }\n }\n return { code: PUSH_CREDENTIAL_ERROR_INVALID, message: issueMessage }\n}\n"],
5
+ "mappings": "AAmBO,MAAM,iCAAiC;AACvC,MAAM,gCAAgC;AACtC,MAAM,qCAAqC;AAC3C,MAAM,uCAAuC;AAC7C,MAAM,mCAAmC;AACzC,MAAM,uCAAuC;AAC7C,MAAM,wCAAwC;AAC9C,MAAM,0CAA0C;AAMhD,MAAM,iCAAyD;AAAA,EACpE,CAAC,8BAA8B,GAAG;AAAA,EAClC,CAAC,6BAA6B,GAAG;AAAA,EACjC,CAAC,kCAAkC,GAAG;AAAA,EACtC,CAAC,oCAAoC,GAAG;AAAA,EACxC,CAAC,gCAAgC,GAAG;AAAA,EACpC,CAAC,oCAAoC,GAAG;AAAA,EACxC,CAAC,qCAAqC,GAAG;AAAA,EACzC,CAAC,uCAAuC,GAAG;AAC7C;AAEO,SAAS,0BAA0B,OAAwB;AAChE,SAAO,OAAO,UAAU,eAAe,KAAK,gCAAgC,KAAK;AACnF;AAOO,SAAS,2BAA2B,cAAyD;AAClG,MAAI,0BAA0B,YAAY,GAAG;AAC3C,WAAO,EAAE,MAAM,cAAc,SAAS,+BAA+B,YAAY,EAAE;AAAA,EACrF;AACA,SAAO,EAAE,MAAM,+BAA+B,SAAS,aAAa;AACtE;",
6
+ "names": []
7
+ }
@@ -0,0 +1,46 @@
1
+ function toStringRecord(value) {
2
+ if (!value || typeof value !== "object") return {};
3
+ const out = {};
4
+ for (const [key, raw] of Object.entries(value)) {
5
+ if (raw == null) continue;
6
+ out[key] = typeof raw === "string" ? raw : String(raw);
7
+ }
8
+ return out;
9
+ }
10
+ const KNOWN_PUSH_OPTION_KEYS = /* @__PURE__ */ new Set(["sound", "badge", "image", "priority", "channelId", "body"]);
11
+ function toPushOptions(value) {
12
+ if (!value || typeof value !== "object") return {};
13
+ const raw = value;
14
+ const options = {};
15
+ for (const [key, entry] of Object.entries(raw)) {
16
+ if (!KNOWN_PUSH_OPTION_KEYS.has(key)) options[key] = entry;
17
+ }
18
+ if (typeof raw.sound === "string") options.sound = raw.sound;
19
+ if (typeof raw.image === "string") options.image = raw.image;
20
+ if (typeof raw.channelId === "string") options.channelId = raw.channelId;
21
+ if (typeof raw.body === "string") options.body = raw.body;
22
+ if (typeof raw.badge === "number" && Number.isFinite(raw.badge)) options.badge = raw.badge;
23
+ if (raw.priority === "high" || raw.priority === "normal") options.priority = raw.priority;
24
+ return options;
25
+ }
26
+ function readPushEnvelope(content) {
27
+ const raw = content?.raw ?? {};
28
+ const title = typeof raw.title === "string" ? raw.title : "";
29
+ const body = typeof raw.body === "string" ? raw.body : content?.text ?? "";
30
+ return {
31
+ title,
32
+ body,
33
+ data: toStringRecord(raw.data),
34
+ options: toPushOptions(raw.options),
35
+ silent: raw.silent === true
36
+ };
37
+ }
38
+ function resolvePushBody(envelope) {
39
+ const override = envelope.options.body;
40
+ return typeof override === "string" && override.length > 0 ? override : envelope.body;
41
+ }
42
+ export {
43
+ readPushEnvelope,
44
+ resolvePushBody
45
+ };
46
+ //# sourceMappingURL=push-envelope.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/communication_channels/lib/push-envelope.ts"],
4
+ "sourcesContent": ["import type { MessageContent } from './adapter'\n\n/**\n * Flat, provider-agnostic push customization a caller can attach to a\n * notification (via `Notification.pushOptions`) or a silent push. Each push\n * adapter maps the recognized keys onto its own provider message shape (see\n * {@link readPushOptions}); unknown keys are ignored by the adapters but kept on\n * the envelope so a provider that understands them can still read them.\n */\nexport interface PushOptions {\n /** Notification sound (provider default when omitted). */\n sound?: string\n /** App icon badge count (iOS / supported Android launchers). */\n badge?: number\n /** Rich image URL shown in the expanded notification. */\n image?: string\n /** Delivery priority. Maps to FCM `android.priority` / `apns-priority` / Expo `priority`. */\n priority?: 'high' | 'normal'\n /** Android notification channel id (FCM `android.notification.channel_id` / Expo `channelId`). */\n channelId?: string\n /** Overrides the push body text without changing the in-app notification body. */\n body?: string\n [key: string]: unknown\n}\n\n/**\n * The push payload the `push_notifications` worker packs into\n * `SendMessageInput.content.raw` before calling a push adapter's `sendMessage`\n * (see `push_notifications/lib/push-delivery.ts`). Each provider adapter\n * (fcm/apns/expo) reads it the same way via {@link readPushEnvelope} so the\n * title/body/data contract stays uniform.\n */\nexport interface PushEnvelope {\n title: string\n body: string\n data: Record<string, string>\n /** Flat per-provider customization; see {@link PushOptions}. */\n options: PushOptions\n /**\n * When `true`, deliver as a silent / content-available wake-up: no visible\n * alert (title/body omitted), data-only payload. Adapters branch on this.\n */\n silent: boolean\n}\n\nfunction toStringRecord(value: unknown): Record<string, string> {\n if (!value || typeof value !== 'object') return {}\n const out: Record<string, string> = {}\n for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {\n if (raw == null) continue\n out[key] = typeof raw === 'string' ? raw : String(raw)\n }\n return out\n}\n\nconst KNOWN_PUSH_OPTION_KEYS = new Set(['sound', 'badge', 'image', 'priority', 'channelId', 'body'])\n\n/**\n * Normalize caller-supplied push options. The recognized keys are coerced to their declared types and a\n * malformed value is DROPPED rather than passed through: a bad `priority` (e.g. `'urgent'`) would reach\n * the provider SDK as an invalid enum and fail every delivery retry with a misleading error. Unknown\n * keys are preserved verbatim so a provider that understands them can still read them (see PushOptions).\n */\nfunction toPushOptions(value: unknown): PushOptions {\n if (!value || typeof value !== 'object') return {}\n const raw = value as Record<string, unknown>\n const options: PushOptions = {}\n for (const [key, entry] of Object.entries(raw)) {\n if (!KNOWN_PUSH_OPTION_KEYS.has(key)) options[key] = entry\n }\n if (typeof raw.sound === 'string') options.sound = raw.sound\n if (typeof raw.image === 'string') options.image = raw.image\n if (typeof raw.channelId === 'string') options.channelId = raw.channelId\n if (typeof raw.body === 'string') options.body = raw.body\n if (typeof raw.badge === 'number' && Number.isFinite(raw.badge)) options.badge = raw.badge\n if (raw.priority === 'high' || raw.priority === 'normal') options.priority = raw.priority\n return options\n}\n\n/** Read the normalized push envelope from a hub `MessageContent`. Defensive against missing fields. */\nexport function readPushEnvelope(content: MessageContent | undefined): PushEnvelope {\n const raw = (content?.raw ?? {}) as {\n title?: unknown\n body?: unknown\n data?: unknown\n options?: unknown\n silent?: unknown\n }\n const title = typeof raw.title === 'string' ? raw.title : ''\n const body = typeof raw.body === 'string' ? raw.body : content?.text ?? ''\n return {\n title,\n body,\n data: toStringRecord(raw.data),\n options: toPushOptions(raw.options),\n silent: raw.silent === true,\n }\n}\n\n/** The push body text after applying a non-empty `pushOptions.body` override, if present. */\nexport function resolvePushBody(envelope: PushEnvelope): string {\n const override = envelope.options.body\n return typeof override === 'string' && override.length > 0 ? override : envelope.body\n}\n"],
5
+ "mappings": "AA6CA,SAAS,eAAe,OAAwC;AAC9D,MAAI,CAAC,SAAS,OAAO,UAAU,SAAU,QAAO,CAAC;AACjD,QAAM,MAA8B,CAAC;AACrC,aAAW,CAAC,KAAK,GAAG,KAAK,OAAO,QAAQ,KAAgC,GAAG;AACzE,QAAI,OAAO,KAAM;AACjB,QAAI,GAAG,IAAI,OAAO,QAAQ,WAAW,MAAM,OAAO,GAAG;AAAA,EACvD;AACA,SAAO;AACT;AAEA,MAAM,yBAAyB,oBAAI,IAAI,CAAC,SAAS,SAAS,SAAS,YAAY,aAAa,MAAM,CAAC;AAQnG,SAAS,cAAc,OAA6B;AAClD,MAAI,CAAC,SAAS,OAAO,UAAU,SAAU,QAAO,CAAC;AACjD,QAAM,MAAM;AACZ,QAAM,UAAuB,CAAC;AAC9B,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,GAAG,GAAG;AAC9C,QAAI,CAAC,uBAAuB,IAAI,GAAG,EAAG,SAAQ,GAAG,IAAI;AAAA,EACvD;AACA,MAAI,OAAO,IAAI,UAAU,SAAU,SAAQ,QAAQ,IAAI;AACvD,MAAI,OAAO,IAAI,UAAU,SAAU,SAAQ,QAAQ,IAAI;AACvD,MAAI,OAAO,IAAI,cAAc,SAAU,SAAQ,YAAY,IAAI;AAC/D,MAAI,OAAO,IAAI,SAAS,SAAU,SAAQ,OAAO,IAAI;AACrD,MAAI,OAAO,IAAI,UAAU,YAAY,OAAO,SAAS,IAAI,KAAK,EAAG,SAAQ,QAAQ,IAAI;AACrF,MAAI,IAAI,aAAa,UAAU,IAAI,aAAa,SAAU,SAAQ,WAAW,IAAI;AACjF,SAAO;AACT;AAGO,SAAS,iBAAiB,SAAmD;AAClF,QAAM,MAAO,SAAS,OAAO,CAAC;AAO9B,QAAM,QAAQ,OAAO,IAAI,UAAU,WAAW,IAAI,QAAQ;AAC1D,QAAM,OAAO,OAAO,IAAI,SAAS,WAAW,IAAI,OAAO,SAAS,QAAQ;AACxE,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,MAAM,eAAe,IAAI,IAAI;AAAA,IAC7B,SAAS,cAAc,IAAI,OAAO;AAAA,IAClC,QAAQ,IAAI,WAAW;AAAA,EACzB;AACF;AAGO,SAAS,gBAAgB,UAAgC;AAC9D,QAAM,WAAW,SAAS,QAAQ;AAClC,SAAO,OAAO,aAAa,YAAY,SAAS,SAAS,IAAI,WAAW,SAAS;AACnF;",
6
+ "names": []
7
+ }
@@ -0,0 +1,64 @@
1
+ function createRefCountedClientCache(options) {
2
+ const entries = /* @__PURE__ */ new Map();
3
+ const scheduleDispose = (entry) => {
4
+ if (entry.disposed) return;
5
+ entry.disposed = true;
6
+ void entry.client.then((client) => options.dispose(client)).catch(() => {
7
+ });
8
+ };
9
+ const releaseEntry = (entry) => {
10
+ if (entry.refs <= 0) return;
11
+ entry.refs -= 1;
12
+ if (entry.refs === 0 && entry.evicted) scheduleDispose(entry);
13
+ };
14
+ const enforceMax = () => {
15
+ while (entries.size > options.max) {
16
+ const oldest = entries.values().next().value;
17
+ if (!oldest) break;
18
+ entries.delete(oldest.key);
19
+ oldest.evicted = true;
20
+ if (oldest.refs === 0) scheduleDispose(oldest);
21
+ }
22
+ };
23
+ const obtain = (key, factory) => {
24
+ const existing = entries.get(key);
25
+ if (existing) {
26
+ entries.delete(key);
27
+ entries.set(key, existing);
28
+ existing.refs += 1;
29
+ return existing;
30
+ }
31
+ const entry = { key, client: factory(), refs: 1, evicted: false, disposed: false };
32
+ entries.set(key, entry);
33
+ entry.client.catch(() => {
34
+ if (entries.get(key) === entry) entries.delete(key);
35
+ });
36
+ enforceMax();
37
+ return entry;
38
+ };
39
+ return {
40
+ async acquire(key, factory) {
41
+ const entry = obtain(key, factory);
42
+ try {
43
+ const client = await entry.client;
44
+ let released = false;
45
+ return {
46
+ client,
47
+ release: () => {
48
+ if (released) return;
49
+ released = true;
50
+ releaseEntry(entry);
51
+ }
52
+ };
53
+ } catch (err) {
54
+ releaseEntry(entry);
55
+ throw err;
56
+ }
57
+ },
58
+ size: () => entries.size
59
+ };
60
+ }
61
+ export {
62
+ createRefCountedClientCache
63
+ };
64
+ //# sourceMappingURL=refcounted-client-cache.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/communication_channels/lib/refcounted-client-cache.ts"],
4
+ "sourcesContent": ["/**\n * A bounded, LRU client cache whose disposal is fenced on in-flight use.\n *\n * Push adapters cache one live provider client (a firebase-admin App carrying a\n * background OAuth-refresh timer, or a node-apn Provider holding an HTTP/2 socket)\n * per credential identity, and LRU-evict the least-recently-used one when the cache\n * grows past `max`. The hazard the previous inline caches had: eviction disposed the\n * client (`app.delete()` / `provider.shutdown()`) immediately, even while a concurrent\n * `sendMessage` still held it \u2014 closing the socket mid-stream. Above the cap that is a\n * steady-state error rate, not a rare race.\n *\n * This cache reference-counts every borrow and DEFERS disposal of an evicted client\n * until its last in-flight borrow releases, so a send never loses its client\n * underneath it. A client that is never evicted is never disposed.\n */\nexport type RefCountedClientCacheOptions<TClient> = {\n /** Maximum number of live clients kept at once; the least-recently-used is evicted past this. */\n max: number\n /**\n * Release the underlying resource. Called at most once per created client, and only after the client\n * has been evicted AND every borrow of it has been released. Must not throw synchronously.\n */\n dispose: (client: TClient) => void | Promise<void>\n}\n\nexport type ClientLease<TClient> = {\n client: TClient\n /**\n * Release this borrow. Idempotent. Once the last borrow of an already-evicted client releases, the\n * client is disposed. Callers MUST call this exactly once per successful `acquire`, in a `finally`.\n */\n release: () => void\n}\n\ntype CacheEntry<TClient> = {\n key: string\n client: Promise<TClient>\n refs: number\n evicted: boolean\n disposed: boolean\n}\n\nexport type RefCountedClientCache<TClient> = {\n /**\n * Borrow the client for `key`, creating it via `factory` on a miss. On a hit the entry is promoted to\n * most-recently-used. A rejected factory result is dropped from the cache (never disposed \u2014 nothing was\n * constructed) so the next borrow re-creates. The returned lease MUST be released by the caller.\n */\n acquire: (key: string, factory: () => Promise<TClient>) => Promise<ClientLease<TClient>>\n /** Number of live (non-evicted) cached entries. Test/introspection only. */\n size: () => number\n}\n\nexport function createRefCountedClientCache<TClient>(\n options: RefCountedClientCacheOptions<TClient>,\n): RefCountedClientCache<TClient> {\n const entries = new Map<string, CacheEntry<TClient>>()\n\n const scheduleDispose = (entry: CacheEntry<TClient>): void => {\n if (entry.disposed) return\n entry.disposed = true\n void entry.client.then((client) => options.dispose(client)).catch(() => {})\n }\n\n const releaseEntry = (entry: CacheEntry<TClient>): void => {\n if (entry.refs <= 0) return\n entry.refs -= 1\n if (entry.refs === 0 && entry.evicted) scheduleDispose(entry)\n }\n\n const enforceMax = (): void => {\n while (entries.size > options.max) {\n const oldest = entries.values().next().value as CacheEntry<TClient> | undefined\n if (!oldest) break\n entries.delete(oldest.key)\n oldest.evicted = true\n // Defer disposal while borrows remain; releaseEntry disposes when the last one returns.\n if (oldest.refs === 0) scheduleDispose(oldest)\n }\n }\n\n const obtain = (key: string, factory: () => Promise<TClient>): CacheEntry<TClient> => {\n const existing = entries.get(key)\n if (existing) {\n // Refresh recency: delete + re-insert moves the key to the newest position.\n entries.delete(key)\n entries.set(key, existing)\n existing.refs += 1\n return existing\n }\n const entry: CacheEntry<TClient> = { key, client: factory(), refs: 1, evicted: false, disposed: false }\n entries.set(key, entry)\n // Drop a rejected init (e.g. invalid credentials) so the next borrow re-creates instead of forever\n // returning the cached rejection. Nothing was constructed, so there is nothing to dispose.\n entry.client.catch(() => {\n if (entries.get(key) === entry) entries.delete(key)\n })\n enforceMax()\n return entry\n }\n\n return {\n async acquire(key, factory) {\n const entry = obtain(key, factory)\n try {\n const client = await entry.client\n let released = false\n return {\n client,\n release: () => {\n if (released) return\n released = true\n releaseEntry(entry)\n },\n }\n } catch (err) {\n releaseEntry(entry)\n throw err\n }\n },\n size: () => entries.size,\n }\n}\n"],
5
+ "mappings": "AAqDO,SAAS,4BACd,SACgC;AAChC,QAAM,UAAU,oBAAI,IAAiC;AAErD,QAAM,kBAAkB,CAAC,UAAqC;AAC5D,QAAI,MAAM,SAAU;AACpB,UAAM,WAAW;AACjB,SAAK,MAAM,OAAO,KAAK,CAAC,WAAW,QAAQ,QAAQ,MAAM,CAAC,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AAAA,EAC5E;AAEA,QAAM,eAAe,CAAC,UAAqC;AACzD,QAAI,MAAM,QAAQ,EAAG;AACrB,UAAM,QAAQ;AACd,QAAI,MAAM,SAAS,KAAK,MAAM,QAAS,iBAAgB,KAAK;AAAA,EAC9D;AAEA,QAAM,aAAa,MAAY;AAC7B,WAAO,QAAQ,OAAO,QAAQ,KAAK;AACjC,YAAM,SAAS,QAAQ,OAAO,EAAE,KAAK,EAAE;AACvC,UAAI,CAAC,OAAQ;AACb,cAAQ,OAAO,OAAO,GAAG;AACzB,aAAO,UAAU;AAEjB,UAAI,OAAO,SAAS,EAAG,iBAAgB,MAAM;AAAA,IAC/C;AAAA,EACF;AAEA,QAAM,SAAS,CAAC,KAAa,YAAyD;AACpF,UAAM,WAAW,QAAQ,IAAI,GAAG;AAChC,QAAI,UAAU;AAEZ,cAAQ,OAAO,GAAG;AAClB,cAAQ,IAAI,KAAK,QAAQ;AACzB,eAAS,QAAQ;AACjB,aAAO;AAAA,IACT;AACA,UAAM,QAA6B,EAAE,KAAK,QAAQ,QAAQ,GAAG,MAAM,GAAG,SAAS,OAAO,UAAU,MAAM;AACtG,YAAQ,IAAI,KAAK,KAAK;AAGtB,UAAM,OAAO,MAAM,MAAM;AACvB,UAAI,QAAQ,IAAI,GAAG,MAAM,MAAO,SAAQ,OAAO,GAAG;AAAA,IACpD,CAAC;AACD,eAAW;AACX,WAAO;AAAA,EACT;AAEA,SAAO;AAAA,IACL,MAAM,QAAQ,KAAK,SAAS;AAC1B,YAAM,QAAQ,OAAO,KAAK,OAAO;AACjC,UAAI;AACF,cAAM,SAAS,MAAM,MAAM;AAC3B,YAAI,WAAW;AACf,eAAO;AAAA,UACL;AAAA,UACA,SAAS,MAAM;AACb,gBAAI,SAAU;AACd,uBAAW;AACX,yBAAa,KAAK;AAAA,UACpB;AAAA,QACF;AAAA,MACF,SAAS,KAAK;AACZ,qBAAa,KAAK;AAClB,cAAM;AAAA,MACR;AAAA,IACF;AAAA,IACA,MAAM,MAAM,QAAQ;AAAA,EACtB;AACF;",
6
+ "names": []
7
+ }
@@ -0,0 +1,13 @@
1
+ import { Migration } from "@mikro-orm/migrations";
2
+ class Migration20260703123630_communication_channels extends Migration {
3
+ up() {
4
+ this.addSql(`create unique index "communication_channels_tenant_push_provider_uq" on "communication_channels" ("tenant_id", "provider_key") where "channel_type" = 'push' and "user_id" is null and "deleted_at" is null;`);
5
+ }
6
+ down() {
7
+ this.addSql(`drop index if exists "communication_channels_tenant_push_provider_uq";`);
8
+ }
9
+ }
10
+ export {
11
+ Migration20260703123630_communication_channels
12
+ };
13
+ //# sourceMappingURL=Migration20260703123630_communication_channels.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/communication_channels/migrations/Migration20260703123630_communication_channels.ts"],
4
+ "sourcesContent": ["import { Migration } from '@mikro-orm/migrations';\n\nexport class Migration20260703123630_communication_channels extends Migration {\n\n override up(): void | Promise<void> {\n // Enforce one tenant-wide push channel per (tenant, provider). Push providers\n // (FCM/APNs/Expo) have no external_identifier and user_id IS NULL, so the\n // mailbox unique index (communication_channels_user_provider_external_uq) does\n // not cover them. This keeps an admin reconnect healing the single shared row\n // (createConnectedChannelRow) instead of inserting duplicates the push fan-out\n // would silently ignore. Only covers user_id IS NULL rows, so existing per-user\n // channels are unaffected.\n this.addSql(`create unique index \"communication_channels_tenant_push_provider_uq\" on \"communication_channels\" (\"tenant_id\", \"provider_key\") where \"channel_type\" = 'push' and \"user_id\" is null and \"deleted_at\" is null;`);\n }\n\n override down(): void | Promise<void> {\n this.addSql(`drop index if exists \"communication_channels_tenant_push_provider_uq\";`);\n }\n\n}\n"],
5
+ "mappings": "AAAA,SAAS,iBAAiB;AAEnB,MAAM,uDAAuD,UAAU;AAAA,EAEnE,KAA2B;AAQlC,SAAK,OAAO,8MAA8M;AAAA,EAC5N;AAAA,EAES,OAA6B;AACpC,SAAK,OAAO,wEAAwE;AAAA,EACtF;AAEF;",
6
+ "names": []
7
+ }