@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
@@ -0,0 +1,237 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { EntityName } from '@mikro-orm/core'
3
+ import { sql } from 'kysely'
4
+ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
+ import type { PushOptions } from '@open-mercato/core/modules/communication_channels/lib/push-envelope'
6
+ import type { UserDevice } from '@open-mercato/core/modules/devices/data/entities'
7
+ import type { CommunicationChannel } from '@open-mercato/core/modules/communication_channels/data/entities'
8
+ import { defaultLocale } from '@open-mercato/shared/lib/i18n/config'
9
+ import { resolveSupportedLocale } from '@open-mercato/shared/lib/i18n/locale'
10
+ import { createLogger } from '@open-mercato/shared/lib/logger'
11
+ import {
12
+ resolveNotificationCopy,
13
+ type NotificationCopySource,
14
+ } from '@open-mercato/core/modules/notifications/lib/notificationCopy'
15
+ import { enqueuePushDelivery } from './queue'
16
+
17
+ const logger = createLogger('push_notifications')
18
+
19
+ export const PUSH_CHANNEL = 'push'
20
+
21
+ type Resolve = <T = unknown>(name: string) => T
22
+
23
+ /** The push payload persisted on each delivery row and unpacked by the worker into the send envelope. */
24
+ export interface PushFanoutPayload {
25
+ title?: string
26
+ body?: string | null
27
+ data: Record<string, string>
28
+ options?: PushOptions
29
+ silent?: boolean
30
+ }
31
+
32
+ export interface FanOutPushDeliveriesArgs {
33
+ em: EntityManager
34
+ resolve: Resolve
35
+ scope: { tenantId: string; organizationId: string | null }
36
+ userId: string
37
+ /**
38
+ * Restrict the fan-out to a single one of the user's devices (`UserDevice.id`). Still scoped to the
39
+ * same (tenant, org, user), so an id that isn't the user's own matches nothing. Omit to fan out to
40
+ * all of the user's push-capable devices (the default).
41
+ */
42
+ userDeviceId?: string
43
+ /** Source in-app notification id, or `null` for a silent push (no Notification row). */
44
+ notificationId: string | null
45
+ notificationTypeId: string
46
+ payload: PushFanoutPayload
47
+ /**
48
+ * Per-device localizable copy for visible notifications. When present, `title`/`body` are
49
+ * resolved per device using its `locale`. Omit for silent pushes (no user-facing copy).
50
+ */
51
+ copy?: NotificationCopySource
52
+ }
53
+
54
+ function tokenSnapshot(token: string): string {
55
+ // Persist at most the last 8 chars of the (long) provider token — never the full secret.
56
+ return token.slice(-8)
57
+ }
58
+
59
+ /**
60
+ * Resolve a recipient's push-capable devices, route each to its provider's tenant push
61
+ * `CommunicationChannel`, persist one `pending` push delivery row per device, and enqueue a send
62
+ * job per row. Shared by the `push` delivery strategy (visible + silent notifications) and
63
+ * `sendCustomPush` (admin one-off pushes); it is preference-agnostic — the caller decides whether
64
+ * to consult per-channel preferences before fanning out.
65
+ *
66
+ * Cross-module entities are resolved via DI tokens (registered `asValue` by their owning modules)
67
+ * so this stays decoupled from those modules' internals. Returns the number of jobs enqueued.
68
+ */
69
+ export async function fanOutPushDeliveries(args: FanOutPushDeliveriesArgs): Promise<{ enqueued: number }> {
70
+ const { em, resolve, scope, userId, userDeviceId, notificationId, notificationTypeId, payload, copy } = args
71
+ const { tenantId, organizationId } = scope
72
+ const silent = payload.silent === true
73
+
74
+ // Require at least one active push CommunicationChannel for the tenant (push not configured ⇒ skip).
75
+ // This is the cheapest, most selective short-circuit (most tenants have no push channel), so it runs
76
+ // before the per-recipient device lookup. Channels are indexed by providerKey so each device can be
77
+ // routed to its matching provider below (ios→apns, android→fcm, expo→expo). Oldest-first so that when
78
+ // a tenant has more than one active channel for the same provider, the pick is deterministic.
79
+ const ChannelRef = resolve('CommunicationChannel') as EntityName<CommunicationChannel>
80
+ const channels = await em.find(
81
+ ChannelRef,
82
+ {
83
+ tenantId,
84
+ channelType: PUSH_CHANNEL,
85
+ isActive: true,
86
+ deletedAt: null,
87
+ },
88
+ { orderBy: { createdAt: 'asc' } },
89
+ )
90
+ if (channels.length === 0) return { enqueued: 0 }
91
+ const channelsByProvider = new Map<string, CommunicationChannel>()
92
+ for (const channel of channels) {
93
+ if (!channelsByProvider.has(channel.providerKey)) channelsByProvider.set(channel.providerKey, channel)
94
+ }
95
+
96
+ // Load the recipient's devices that can receive push (active + has a token). Scoped to the
97
+ // notification's organization so an org-scoped notification never fans out to a device the user
98
+ // registered under a different org — device identity is per (tenant, org, user, device) (see devices
99
+ // module). A tenant-level (null-org) notification targets the user's tenant-level (null-org) devices.
100
+ // `push_token` is encrypted at rest; decrypt on read (no-op when encryption is disabled).
101
+ const DeviceRef = resolve('UserDevice') as EntityName<UserDevice>
102
+ const devices = await findWithDecryption(
103
+ em,
104
+ DeviceRef,
105
+ {
106
+ tenantId,
107
+ organizationId,
108
+ userId,
109
+ // Optionally restrict to a single device; still scoped to this user so a foreign id matches none.
110
+ ...(userDeviceId ? { id: userDeviceId } : {}),
111
+ deletedAt: null,
112
+ pushToken: { $ne: null },
113
+ },
114
+ undefined,
115
+ { tenantId, organizationId },
116
+ )
117
+ if (devices.length === 0) return { enqueued: 0 }
118
+
119
+ // Resolve the per-device localized copy. The default-locale case (the common one: device has no
120
+ // locale, an unsupported one, or already speaks the default) reuses the copy already resolved
121
+ // upstream — no dictionary load. Other locales translate via the shared notification-copy helper
122
+ // (dictionaries are memoized by loadDictionary). Silent pushes carry no copy and skip this.
123
+ // Memoized by resolved locale so a user with many same-locale devices derives copy once
124
+ // (O(distinct locales) instead of O(devices)).
125
+ const localizedByLocale = new Map<string, Promise<PushFanoutPayload>>()
126
+ function resolveLocalizedPayload(deviceLocale?: string | null): Promise<PushFanoutPayload> {
127
+ if (!copy) return Promise.resolve(payload)
128
+ const locale = resolveSupportedLocale(deviceLocale) ?? defaultLocale
129
+ let cached = localizedByLocale.get(locale)
130
+ if (!cached) {
131
+ cached = (async () => {
132
+ if (locale === defaultLocale) {
133
+ return { ...payload, title: copy.title, body: copy.body ?? null }
134
+ }
135
+ const { title, body } = await resolveNotificationCopy(copy, locale)
136
+ return { ...payload, title, body }
137
+ })()
138
+ localizedByLocale.set(locale, cached)
139
+ }
140
+ return cached
141
+ }
142
+
143
+ // Insert one pending delivery row per device, routing each device to the push channel whose
144
+ // providerKey matches the device's pushProvider. Devices with no provider, or no matching configured
145
+ // channel, are skipped. Insert via INSERT ... ON CONFLICT DO NOTHING on the (notification_id,
146
+ // user_device_id) partial unique index: the `push` strategy runs inside the at-least-once persistent
147
+ // `notifications:deliver` subscriber, so a redelivered event re-runs it — the conflict clause makes
148
+ // the re-fan-out a no-op instead of inserting a duplicate set of rows (and duplicate pushes). Silent
149
+ // pushes carry a null notification_id and are excluded from the partial index (they are triggered
150
+ // explicitly, not via at-least-once redelivery). Only the rows actually inserted are enqueued.
151
+ const builtRows = await Promise.all(
152
+ devices.map(async (device) => {
153
+ const providerKey = device.pushProvider
154
+ if (!providerKey) return null
155
+ const channel = channelsByProvider.get(providerKey)
156
+ if (!channel) return null
157
+ const rowPayload = await resolveLocalizedPayload(device.locale)
158
+ return {
159
+ tenant_id: tenantId,
160
+ organization_id: organizationId,
161
+ notification_id: notificationId,
162
+ notification_type_id: notificationTypeId,
163
+ user_device_id: device.id,
164
+ user_id: userId,
165
+ provider: channel.providerKey,
166
+ token_snapshot: tokenSnapshot(device.pushToken as string),
167
+ silent,
168
+ status: 'pending',
169
+ attempts: 0,
170
+ payload: sql`${JSON.stringify(rowPayload)}::jsonb`,
171
+ created_at: sql`now()`,
172
+ updated_at: sql`now()`,
173
+ }
174
+ }),
175
+ )
176
+ const rows = builtRows.filter((row): row is NonNullable<typeof row> => row !== null)
177
+ // A registered device whose pushProvider has no matching active channel (e.g. an Expo device on an
178
+ // FCM-only tenant) is correctly skipped above — but it produces no delivery row and would otherwise
179
+ // be an invisible no-op. Surface a count so a provider-config gap is diagnosable.
180
+ const skippedNoChannel = devices.length - rows.length
181
+ if (skippedNoChannel > 0) {
182
+ logger.warn('Skipped devices with no matching push channel', {
183
+ tenantId,
184
+ userId,
185
+ notificationId,
186
+ skipped: skippedNoChannel,
187
+ })
188
+ }
189
+ if (rows.length === 0) return { enqueued: 0 }
190
+
191
+ const db = em.getKysely<any>()
192
+ const inserted = (await db
193
+ .insertInto('push_notification_deliveries')
194
+ .values(rows)
195
+ .onConflict((oc: any) =>
196
+ oc.columns(['notification_id', 'user_device_id']).where('notification_id', 'is not', null).doNothing(),
197
+ )
198
+ .returning(['id'])
199
+ .execute()) as Array<{ id: string }>
200
+
201
+ // Enqueue one send job per inserted row (per-device retry isolation, idempotent on delivery id).
202
+ // Enqueue all rows concurrently — the jobs are independent, so there is no reason to serialize the
203
+ // queue round-trips. If an enqueue fails, mark that row failed instead of leaving it orphaned in
204
+ // `pending` forever (the worker only ever processes rows it receives a job for). Failed rows are
205
+ // grouped by reason and marked in one UPDATE per reason to keep DB round-trips minimal.
206
+ const outcomes = await Promise.allSettled(
207
+ inserted.map((row) => enqueuePushDelivery({ deliveryId: row.id, tenantId, organizationId })),
208
+ )
209
+ const failedIdsByReason = new Map<string, string[]>()
210
+ let enqueued = 0
211
+ outcomes.forEach((outcome, index) => {
212
+ if (outcome.status === 'fulfilled') {
213
+ enqueued += 1
214
+ return
215
+ }
216
+ const error = outcome.reason
217
+ const reason = error instanceof Error ? `enqueue_failed: ${error.message}` : 'enqueue_failed'
218
+ const ids = failedIdsByReason.get(reason) ?? []
219
+ ids.push(inserted[index].id)
220
+ failedIdsByReason.set(reason, ids)
221
+ })
222
+ if (failedIdsByReason.size > 0) {
223
+ await Promise.allSettled(
224
+ Array.from(failedIdsByReason.entries()).map(([reason, ids]) =>
225
+ db
226
+ .updateTable('push_notification_deliveries')
227
+ .set({ status: 'failed', last_error: reason, updated_at: sql`now()` })
228
+ .where('id', 'in', ids)
229
+ // Guard on `pending`: if the enqueue reported failure yet the job actually landed and a
230
+ // worker already claimed/finished the row, don't clobber its later status back to `failed`.
231
+ .where('status', '=', 'pending')
232
+ .execute(),
233
+ ),
234
+ )
235
+ }
236
+ return { enqueued }
237
+ }
@@ -0,0 +1,47 @@
1
+ import type { ZodType } from 'zod'
2
+ import type { EmailHealthCheck } from '@open-mercato/core/modules/communication_channels/lib/provider-health'
3
+ import { isPushFakeProvidersEnabled, PUSH_FAKE_PROVIDERS_ENV } from './fake-provider-recorder'
4
+
5
+ /**
6
+ * Liveness probe for a push provider (FCM/APNs/Expo).
7
+ *
8
+ * A push provider has NO OAuth client — its credentials are a service-account JSON, a `.p8` signing
9
+ * key, or an Expo access token. So this probe validates the tenant-scoped credentials against the
10
+ * provider schema and reports push-appropriate copy, rather than the OAuth-client wording the shared
11
+ * `makeClientConfigHealthCheck` emits (which is correct for the Gmail-style channels but misleading
12
+ * here). It additionally surfaces fake-provider mode: when `OM_PUSH_FAKE_PROVIDERS` is set every send is
13
+ * short-circuited to a recorded no-op, so a plain "credentials valid → healthy" probe would report green
14
+ * while nothing is delivered — this reports `degraded` instead. Pairs with `warnPushFakeProvidersActive`
15
+ * (a one-time log at registration).
16
+ */
17
+ export function makePushClientConfigHealthCheck<T>(options: {
18
+ schema: ZodType<T>
19
+ providerLabel: string
20
+ healthyDetails?: (parsed: T) => Record<string, unknown>
21
+ }): EmailHealthCheck {
22
+ return {
23
+ async check(credentials) {
24
+ if (isPushFakeProvidersEnabled()) {
25
+ return {
26
+ status: 'degraded',
27
+ message: `${options.providerLabel} is running in FAKE mode (${PUSH_FAKE_PROVIDERS_ENV} is set): pushes are recorded, not delivered. Unset this flag in production.`,
28
+ details: { fakeProviders: true, env: PUSH_FAKE_PROVIDERS_ENV },
29
+ }
30
+ }
31
+ const parsed = options.schema.safeParse(credentials ?? {})
32
+ if (!parsed.success) {
33
+ const first = parsed.error.issues[0]
34
+ return {
35
+ status: 'unhealthy',
36
+ message: `${options.providerLabel} credentials invalid: ${first?.message ?? 'unknown validation error'}`,
37
+ details: { reason: 'invalid_credentials' },
38
+ }
39
+ }
40
+ return {
41
+ status: 'healthy',
42
+ message: `${options.providerLabel} credentials configured`,
43
+ details: { credentialsConfigured: true, ...(options.healthyDetails?.(parsed.data) ?? {}) },
44
+ }
45
+ },
46
+ }
47
+ }
@@ -0,0 +1,140 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { PushNotificationDelivery } from '../data/entities'
3
+ import { emitPushNotificationsEvent } from '../events'
4
+ import { MAX_ATTEMPTS } from './push-delivery'
5
+ import { resolveStuckThresholdMs } from './reclaim-window'
6
+ import { enqueuePushDelivery } from './queue'
7
+
8
+ // The stuck-reclaim window (minutes a row may sit in `sending`/`pending` before a dead-worker reclaim)
9
+ // lives in ./reclaim-window so the send-path timeout shares the same source of truth.
10
+ //
11
+ // INVARIANT: this window MUST exceed the worst-case single provider send time. `updated_at` is stamped
12
+ // once when the row is claimed (`pending` → `sending`) and is NOT refreshed mid-send (no heartbeat), so
13
+ // a legitimate send that runs longer than the window would be reclaimed and re-enqueued → a duplicate
14
+ // push. The send path caps each provider send below this window (see push-delivery.ts
15
+ // `resolvePushSendTimeoutMs`), and duplicates are otherwise bounded by MAX_ATTEMPTS (incremented at
16
+ // claim time) and inherent to at-least-once delivery.
17
+
18
+ // Bound the per-tick scan so a stranded backlog (provider/queue outage) cannot load an unbounded row
19
+ // set into memory and re-enqueue it serially in one tick. Oldest-stuck rows are drained first; the
20
+ // remainder is picked up by subsequent ticks. Tunable via OM_PUSH_STUCK_RECLAIM_BATCH_LIMIT (values
21
+ // below MIN_RECLAIM_BATCH_LIMIT, negative, or non-numeric → default). Mirrors the receipt reaper's
22
+ // OM_PUSH_RECEIPT_BATCH_LIMIT.
23
+ const DEFAULT_RECLAIM_BATCH_LIMIT = 500
24
+ const MIN_RECLAIM_BATCH_LIMIT = 1
25
+
26
+ export type ReclaimStuckResult = { reEnqueued: number; expired: number }
27
+
28
+ function resolveReclaimBatchLimit(): number {
29
+ const raw = Number.parseInt(process.env.OM_PUSH_STUCK_RECLAIM_BATCH_LIMIT ?? '', 10)
30
+ return Number.isFinite(raw) && raw >= MIN_RECLAIM_BATCH_LIMIT ? raw : DEFAULT_RECLAIM_BATCH_LIMIT
31
+ }
32
+
33
+ async function emitFailed(delivery: PushNotificationDelivery, willRetry: boolean): Promise<void> {
34
+ await emitPushNotificationsEvent(
35
+ 'push_notifications.delivery.failed',
36
+ {
37
+ deliveryId: delivery.id,
38
+ tenantId: delivery.tenantId,
39
+ organizationId: delivery.organizationId ?? null,
40
+ userId: delivery.userId,
41
+ provider: delivery.provider,
42
+ status: delivery.status,
43
+ ...(willRetry ? { willRetry: true } : {}),
44
+ },
45
+ { persistent: true },
46
+ )
47
+ }
48
+
49
+ /**
50
+ * Recover push delivery rows stranded by a worker (or an enqueue) that never finished:
51
+ *
52
+ * - `sending` rows — a worker crashed between claiming the row (`pending` → `sending`) and finalizing
53
+ * it. Such a row has no outstanding job the claim can re-match.
54
+ * - `pending` rows — the fan-out committed the delivery row (a plain INSERT, auto-committed) but the
55
+ * subsequent enqueue never landed (process died, or the queue dropped the job). The row then sits
56
+ * `pending` forever: the send-path claim only runs when a job fires, so no job ⇒ no claim ⇒ the row
57
+ * is never retried, expired, nor surfaced as failed. Only a *synchronous* enqueue throw is handled
58
+ * inline by the fan-out; a lost job after the INSERT committed is invisible without this sweep.
59
+ *
60
+ * Both are recovered identically: re-enqueue if retry budget remains, else finalize `expired`. Driven
61
+ * by the `push_notifications:reclaim-stuck` scheduler tick (one per tenant), so the query is scoped to
62
+ * the tenant only (covers org-bound and tenant-level rows alike); there is no cross-tenant read.
63
+ *
64
+ * The stale-`updated_at < cutoff` guard is what makes sweeping `pending` safe: a freshly fan-outed row
65
+ * (with a live job in flight) is younger than the cutoff and is left alone; only a row that has sat
66
+ * past the reclaim window — long after any real job would have claimed it — is re-enqueued. A rare
67
+ * duplicate job is a no-op because the send-path claim is atomic (`pending` → `sending`, exactly one
68
+ * winner), and provider-send duplicates are bounded by MAX_ATTEMPTS.
69
+ *
70
+ * Each transition is an atomic `nativeUpdate` guarded on the row's observed `status` AND still-stale
71
+ * `updated_at < cutoff`, so overlapping ticks (or a worker that re-claimed the row in the meantime)
72
+ * can never re-open an actively-processing delivery — exactly one actor wins each row.
73
+ */
74
+ export async function reclaimStuckPushDeliveries(
75
+ em: EntityManager,
76
+ scope: { tenantId: string },
77
+ now: Date = new Date(),
78
+ ): Promise<ReclaimStuckResult> {
79
+ const cutoff = new Date(now.getTime() - resolveStuckThresholdMs())
80
+ const stuck = await em.find(
81
+ PushNotificationDelivery,
82
+ {
83
+ tenantId: scope.tenantId,
84
+ status: { $in: ['sending', 'pending'] },
85
+ updatedAt: { $lt: cutoff },
86
+ },
87
+ { limit: resolveReclaimBatchLimit(), orderBy: { updatedAt: 'asc' } },
88
+ )
89
+
90
+ let reEnqueued = 0
91
+ let expired = 0
92
+ for (const delivery of stuck) {
93
+ const claimGuard = { id: delivery.id, tenantId: scope.tenantId, status: delivery.status, updatedAt: { $lt: cutoff } }
94
+
95
+ if (delivery.attempts >= MAX_ATTEMPTS) {
96
+ const claimed = await em.nativeUpdate(PushNotificationDelivery, claimGuard, {
97
+ status: 'expired',
98
+ lastError: 'stuck_reclaimed',
99
+ nextRetryAt: null,
100
+ updatedAt: new Date(),
101
+ })
102
+ if (claimed === 0) continue
103
+ delivery.status = 'expired'
104
+ delivery.lastError = 'stuck_reclaimed'
105
+ await emitFailed(delivery, false)
106
+ expired += 1
107
+ continue
108
+ }
109
+
110
+ const claimed = await em.nativeUpdate(PushNotificationDelivery, claimGuard, {
111
+ status: 'pending',
112
+ nextRetryAt: null,
113
+ updatedAt: new Date(),
114
+ })
115
+ if (claimed === 0) continue
116
+ delivery.status = 'pending'
117
+
118
+ try {
119
+ await enqueuePushDelivery({
120
+ deliveryId: delivery.id,
121
+ tenantId: scope.tenantId,
122
+ organizationId: delivery.organizationId ?? null,
123
+ })
124
+ reEnqueued += 1
125
+ } catch (error) {
126
+ // Re-enqueue failed: don't leave the row pending with no job. Fail it terminally instead.
127
+ const reason = error instanceof Error ? `reclaim_enqueue_failed: ${error.message}` : 'reclaim_enqueue_failed'
128
+ await em.nativeUpdate(
129
+ PushNotificationDelivery,
130
+ { id: delivery.id, tenantId: scope.tenantId, status: 'pending' },
131
+ { status: 'failed', lastError: reason, nextRetryAt: null, updatedAt: new Date() },
132
+ )
133
+ delivery.status = 'failed'
134
+ delivery.lastError = reason
135
+ await emitFailed(delivery, false)
136
+ }
137
+ }
138
+
139
+ return { reEnqueued, expired }
140
+ }
@@ -0,0 +1,210 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { raw, type EntityName } from '@mikro-orm/core'
3
+ import type { ChannelAdapter } from '@open-mercato/core/modules/communication_channels/lib/adapter'
4
+ import {
5
+ DEVICE_UNREGISTERED,
6
+ supportsReceiptChecking,
7
+ } from '@open-mercato/core/modules/communication_channels/lib/push-adapter'
8
+ import type { CommunicationChannel } from '@open-mercato/core/modules/communication_channels/data/entities'
9
+ import { createLogger } from '@open-mercato/shared/lib/logger'
10
+ import { PushNotificationDelivery } from '../data/entities'
11
+ import { softDeleteUnregisteredDevice } from './push-delivery'
12
+
13
+ const logger = createLogger('push_notifications')
14
+
15
+ type Resolve = <T = unknown>(name: string) => T
16
+
17
+ interface ChannelAdapterRegistryLike {
18
+ get(providerKey: string): ChannelAdapter | undefined
19
+ }
20
+
21
+ interface CredentialsServiceLike {
22
+ resolve(
23
+ integrationId: string,
24
+ scope: { tenantId: string; organizationId: string; userId?: string | null },
25
+ ): Promise<Record<string, unknown> | null>
26
+ }
27
+
28
+ export type ReceiptReaperResult = { checked: number; unregistered: number }
29
+
30
+ // A receipt only exists after the provider has attempted handoff to FCM/APNs, so a just-`sent` row has
31
+ // nothing to poll yet. Wait MIN_AGE before the first check (Expo recommends ~15m) and stop looking after
32
+ // MAX_AGE — a row not resolved within the window is left as `sent` (best-effort hygiene, not a
33
+ // correctness guarantee). Both tunable; MAX is clamped above MIN so the window is always non-empty.
34
+ // Parse an int env var, falling back to `fallback` only when it is unset/non-numeric — a legitimately
35
+ // configured `0` is preserved (`|| fallback` would coerce it, since `0` is falsy).
36
+ function envInt(name: string, fallback: number): number {
37
+ const parsed = Number.parseInt(process.env[name] ?? '', 10)
38
+ return Number.isFinite(parsed) ? parsed : fallback
39
+ }
40
+ // A negative value must NOT collapse to 0 (that would poll before any receipt exists and mark the
41
+ // delivery `receiptChecked` forever). Preserve a legitimately-configured 0 but floor a negative back to
42
+ // the default — mirroring the reaper's `raw >= MIN ? raw : DEFAULT` guard (resolveReclaimBatchLimit).
43
+ const RECEIPT_MIN_AGE_MINUTES_RAW = envInt('OM_PUSH_RECEIPT_MIN_AGE_MINUTES', 15)
44
+ const RECEIPT_MIN_AGE_MS = (RECEIPT_MIN_AGE_MINUTES_RAW >= 0 ? RECEIPT_MIN_AGE_MINUTES_RAW : 15) * 60 * 1000
45
+ const RECEIPT_MAX_AGE_MS = Math.max(
46
+ RECEIPT_MIN_AGE_MS + 60 * 1000,
47
+ envInt('OM_PUSH_RECEIPT_MAX_AGE_MINUTES', 60) * 60 * 1000,
48
+ )
49
+ // Bound the per-tick scan so a busy tenant's `sent` backlog cannot fetch an unbounded row set.
50
+ const RECEIPT_BATCH_LIMIT = Math.max(1, envInt('OM_PUSH_RECEIPT_BATCH_LIMIT', 500))
51
+
52
+ /** The provider ticket id persisted by the send path, or null when there is nothing to poll. */
53
+ function ticketIdOf(delivery: PushNotificationDelivery): string | null {
54
+ const id = (delivery.providerResponse as { externalMessageId?: unknown } | null | undefined)?.externalMessageId
55
+ return typeof id === 'string' && id.length > 0 ? id : null
56
+ }
57
+
58
+ /** A row whose receipt was already resolved on a prior sweep — skip it (marker is JSON, no migration). */
59
+ function isReceiptChecked(delivery: PushNotificationDelivery): boolean {
60
+ return (delivery.providerResponse as { receiptChecked?: unknown } | null | undefined)?.receiptChecked === true
61
+ }
62
+
63
+ function markReceiptChecked(delivery: PushNotificationDelivery, unregistered: boolean): void {
64
+ delivery.providerResponse = {
65
+ ...(delivery.providerResponse ?? {}),
66
+ receiptChecked: true,
67
+ ...(unregistered ? { unregistered: true } : {}),
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Second-pass hygiene for push providers whose "device unregistered" signal arrives in an ASYNCHRONOUS
73
+ * receipt phase rather than the synchronous send ticket (Expo). Runs on the per-tenant reclaim tick.
74
+ *
75
+ * For each `sent` delivery old enough to have a receipt, group ticket ids by provider, and for every
76
+ * provider whose adapter implements the optional `PushReceiptChecker` capability, poll receipts using
77
+ * the same channel credentials as the send path. A `DeviceNotRegistered` receipt is routed through the
78
+ * SAME `softDeleteUnregisteredDevice` contract the synchronous path uses — no separate deletion path.
79
+ * `MessageRateExceeded` and other transient receipt errors resolve the receipt but never kill the token.
80
+ *
81
+ * Idempotent: each resolved row is marked (`providerResponse.receiptChecked`) so it is never re-polled,
82
+ * and `devices.deactivate` is itself idempotent, so an overlapping tick cannot double-delete.
83
+ */
84
+ export async function checkPushReceipts(
85
+ em: EntityManager,
86
+ scope: { tenantId: string },
87
+ resolve: Resolve,
88
+ now: Date = new Date(),
89
+ ): Promise<ReceiptReaperResult> {
90
+ const registry = resolve('channelAdapterRegistry') as ChannelAdapterRegistryLike | undefined
91
+ if (!registry) return { checked: 0, unregistered: 0 }
92
+
93
+ // Exclude non-workable rows in SQL, not in memory. A resolved row keeps `status: 'sent'` (the
94
+ // `receiptChecked` marker lives in the `provider_response` JSON, not the status column), and rows
95
+ // with no pollable ticket id (fcm/apns report unregistered synchronously and persist an empty
96
+ // `externalMessageId`) can never be receipt-checked. Filtering those in memory after a
97
+ // `LIMIT … ORDER BY sent_at ASC` fetch lets an oldest-first backlog of them fill the bounded batch
98
+ // and starve every still-unchecked Expo row in the tenant — the batch does zero work forever until
99
+ // the backlog ages past MAX_AGE. Push both predicates into the query so the budget only ever loads
100
+ // rows that can actually make progress. (`provider_response` is `json`; `->>` reads it as text.)
101
+ const workableRow = raw(
102
+ `(nullif(provider_response->>'externalMessageId', '') is not null and coalesce((provider_response->>'receiptChecked')::boolean, false) = false)`,
103
+ )
104
+ const candidates = await em.find(
105
+ PushNotificationDelivery,
106
+ {
107
+ tenantId: scope.tenantId,
108
+ status: 'sent',
109
+ sentAt: { $gte: new Date(now.getTime() - RECEIPT_MAX_AGE_MS), $lte: new Date(now.getTime() - RECEIPT_MIN_AGE_MS) },
110
+ [workableRow as unknown as string]: true,
111
+ },
112
+ { limit: RECEIPT_BATCH_LIMIT, orderBy: { sentAt: 'asc' } },
113
+ )
114
+
115
+ // Group unchecked rows that have a real ticket id by provider. Providers whose adapter doesn't
116
+ // implement receipt checking (fcm/apns) never issue a network call — they are dropped below.
117
+ const byProvider = new Map<string, { ticketId: string; delivery: PushNotificationDelivery }[]>()
118
+ for (const delivery of candidates) {
119
+ if (isReceiptChecked(delivery)) continue
120
+ const ticketId = ticketIdOf(delivery)
121
+ if (!ticketId) continue
122
+ const list = byProvider.get(delivery.provider) ?? []
123
+ list.push({ ticketId, delivery })
124
+ byProvider.set(delivery.provider, list)
125
+ }
126
+ if (byProvider.size === 0) return { checked: 0, unregistered: 0 }
127
+
128
+ let credentialsService: CredentialsServiceLike | undefined
129
+ try {
130
+ credentialsService = resolve('integrationCredentialsService') as CredentialsServiceLike | undefined
131
+ } catch {
132
+ credentialsService = undefined
133
+ }
134
+ const ChannelRef = resolve('CommunicationChannel') as EntityName<CommunicationChannel>
135
+
136
+ let checked = 0
137
+ let unregistered = 0
138
+
139
+ for (const [provider, entries] of byProvider) {
140
+ const adapter = registry.get(provider)
141
+ if (!adapter || !supportsReceiptChecking(adapter)) continue
142
+
143
+ const channel = await em.findOne(ChannelRef, {
144
+ tenantId: scope.tenantId,
145
+ providerKey: provider,
146
+ channelType: 'push',
147
+ isActive: true,
148
+ deletedAt: null,
149
+ })
150
+ if (!channel) continue
151
+
152
+ // Credentials are keyed by the CHANNEL's org context, mirroring the send path (see push-delivery).
153
+ const credentialScope = {
154
+ tenantId: scope.tenantId,
155
+ organizationId: channel.organizationId ?? scope.tenantId,
156
+ userId: channel.userId ?? null,
157
+ }
158
+ let credentials: Record<string, unknown> = {}
159
+ if (channel.credentialsRef && credentialsService) {
160
+ credentials = (await credentialsService.resolve(`channel_${provider}`, credentialScope).catch(() => null)) ?? {}
161
+ }
162
+
163
+ const byTicket = new Map<string, PushNotificationDelivery[]>()
164
+ for (const entry of entries) {
165
+ const list = byTicket.get(entry.ticketId) ?? []
166
+ list.push(entry.delivery)
167
+ byTicket.set(entry.ticketId, list)
168
+ }
169
+
170
+ let outcomes
171
+ try {
172
+ outcomes = await adapter.checkReceipts([...byTicket.keys()], credentials)
173
+ } catch (error) {
174
+ logger.error('Push receipt check failed', {
175
+ tenantId: scope.tenantId,
176
+ provider,
177
+ error: error instanceof Error ? error.message : String(error),
178
+ })
179
+ continue
180
+ }
181
+
182
+ // Persist the receipt-checked marks first, then run the device deactivations AFTER the flush so a
183
+ // command that reads on the same EntityManager can never discard the pending scalar marks.
184
+ const toDeactivate: Array<{ id: string; tenantId: string; userId: string; organizationId: string | null }> = []
185
+ for (const outcome of outcomes) {
186
+ const deliveries = byTicket.get(outcome.ticketId)
187
+ if (!deliveries) continue
188
+ for (const delivery of deliveries) {
189
+ markReceiptChecked(delivery, outcome.unregistered)
190
+ checked += 1
191
+ if (outcome.unregistered) {
192
+ delivery.lastError = DEVICE_UNREGISTERED
193
+ toDeactivate.push({
194
+ id: delivery.userDeviceId,
195
+ tenantId: delivery.tenantId,
196
+ userId: delivery.userId,
197
+ organizationId: delivery.organizationId ?? null,
198
+ })
199
+ unregistered += 1
200
+ }
201
+ }
202
+ }
203
+ await em.flush()
204
+ for (const input of toDeactivate) {
205
+ await softDeleteUnregisteredDevice(resolve, input)
206
+ }
207
+ }
208
+
209
+ return { checked, unregistered }
210
+ }