ghost 6.54.1 → 6.55.0

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 (247) hide show
  1. package/components/tryghost-adapter-base-scheduling-0.2.0.tgz +0 -0
  2. package/components/tryghost-custom-field-types-0.0.0.tgz +0 -0
  3. package/components/tryghost-kg-default-cards-10.3.3.tgz +0 -0
  4. package/components/tryghost-kg-default-nodes-2.1.3.tgz +0 -0
  5. package/core/boot.js +3 -6
  6. package/core/built/admin/assets/{account-migration-BklF2I_I.js → account-migration-sjHLx5dy.js} +1 -1
  7. package/core/built/admin/assets/activitypub/{account-migration-CNovr6W-.js → account-migration-DoYXtnZ8.js} +4 -4
  8. package/core/built/admin/assets/activitypub/activitypub.js +1 -1
  9. package/core/built/admin/assets/activitypub/{bluesky-sharing-Ceslb1_M.js → bluesky-sharing-Bxeh6MKW.js} +4 -4
  10. package/core/built/admin/assets/activitypub/{dist-DHAjBcV3.js → dist-D8sFfdpC.js} +2 -2
  11. package/core/built/admin/assets/activitypub/{domain-C1qQvJ8C.js → domain-CWueFW6q.js} +5 -5
  12. package/core/built/admin/assets/activitypub/{edit-profile-BcH-jEfM.js → edit-profile-BGxPU7N5.js} +3 -3
  13. package/core/built/admin/assets/activitypub/{explore-AxwBh9dI.js → explore-DBK-u0Ch.js} +3 -3
  14. package/core/built/admin/assets/activitypub/{feed-BaOlbh7a.js → feed-Bcir5RQZ.js} +3 -3
  15. package/core/built/admin/assets/activitypub/{field-B7mYYQpN.js → field-BV-CIT3d.js} +2 -2
  16. package/core/built/admin/assets/activitypub/{inbox-vXez-5uJ.js → inbox-yC0nfvzl.js} +4 -4
  17. package/core/built/admin/assets/activitypub/{label-Chv5T8bw.js → label-DTRS9TDk.js} +2 -2
  18. package/core/built/admin/assets/activitypub/layout-qx-bUL1k.js +1 -0
  19. package/core/built/admin/assets/activitypub/{moderation-DtSM2i1I.js → moderation-Dwl9t0pb.js} +4 -4
  20. package/core/built/admin/assets/activitypub/{note-Dckp4S-2.js → note-B-zKsZ7r.js} +3 -3
  21. package/core/built/admin/assets/activitypub/{notifications-CG5T22vg.js → notifications-DjJZD54t.js} +3 -3
  22. package/core/built/admin/assets/activitypub/{preferences-D_799jdl.js → preferences-CoU8vs6d.js} +6 -6
  23. package/core/built/admin/assets/activitypub/{profile-D2JfXBQl.js → profile-BAsaJekx.js} +6 -6
  24. package/core/built/admin/assets/activitypub/{routes-CCHe2dQp.js → routes-CLH3OVTP.js} +18 -18
  25. package/core/built/admin/assets/activitypub/{settings-kx-9Enkz.js → settings-DA6pNJ-Q.js} +3 -3
  26. package/core/built/admin/assets/activitypub/{site-x4_IjVJ0.js → site-CQD42Pw5.js} +2 -2
  27. package/core/built/admin/assets/activitypub/{tabs-CyPRQQ1N.js → tabs-Cg0saow1.js} +7 -7
  28. package/core/built/admin/assets/activitypub/{use-reply-chain-data-BAB6fNGt.js → use-reply-chain-data-Br4aOWOh.js} +2 -2
  29. package/core/built/admin/assets/audience-DjEWNhp3.js +1 -0
  30. package/core/built/admin/assets/{automated-emails-of1MvVsv.js → automated-emails-DRy7qY-J.js} +2 -2
  31. package/core/built/admin/assets/{automations-DRFjW5fw.js → automations-Bx3VcFIL.js} +1 -1
  32. package/core/built/admin/assets/{automations-B7-y3dXt.js → automations-DhKeS_U5.js} +1 -1
  33. package/core/built/admin/assets/{bluesky-sharing-BbbjWdzs.js → bluesky-sharing-ClMd0-Ft.js} +1 -1
  34. package/core/built/admin/assets/{breadcrumb-B-M9Ip_2.js → breadcrumb-BD0U2ygg.js} +1 -1
  35. package/core/built/admin/assets/{chart-helpers-xiSsW8r0.js → chart-helpers-fjQKRVyY.js} +1 -1
  36. package/core/built/admin/assets/{checkbox-EhJKHFdZ.js → checkbox-CWa28lgO.js} +1 -1
  37. package/core/built/admin/assets/{chunk.361.3cdb584b973959e52ccc.js → chunk.481.6c7395a49484bb4583aa.js} +41 -38
  38. package/core/built/admin/assets/{chunk.524.8365cd9280405c0f6216.js → chunk.524.cffd399783a519e404a9.js} +4 -4
  39. package/core/built/admin/assets/{chunk.582.61d078937e6102769f2c.js → chunk.582.eebf0d0b2d202bb746a5.js} +6 -6
  40. package/core/built/admin/assets/{code-editor-view-CZtJFGhR.js → code-editor-view-CCr0rBWS.js} +1 -1
  41. package/core/built/admin/assets/comments-DyUSMUdj.js +1 -0
  42. package/core/built/admin/assets/{date-range-select-CTlyo8zW.js → date-range-select-BFp58EMk.js} +1 -1
  43. package/core/built/admin/assets/{disabled-sources-indicator-qs2GfLvl.js → disabled-sources-indicator-CUxMfiPa.js} +1 -1
  44. package/core/built/admin/assets/domain-C0bV2UV8.js +1 -0
  45. package/core/built/admin/assets/{dropzone-BmCXnw6J.js → dropzone-DlK-K2DQ.js} +1 -1
  46. package/core/built/admin/assets/{edit-profile-SGrJve5M.js → edit-profile-BKT-REJw.js} +1 -1
  47. package/core/built/admin/assets/editor-Dp865O-4.js +8 -0
  48. package/core/built/admin/assets/{explore-CUYLv9G9.js → explore-ClUpJZOp.js} +1 -1
  49. package/core/built/admin/assets/{feed-CPdJMttL.js → feed-DF92dUMP.js} +1 -1
  50. package/core/built/admin/assets/field-Do7lieBA.js +1 -0
  51. package/core/built/admin/assets/{filter-sources-Bieps75g.js → filter-sources-BV2rnpfA.js} +1 -1
  52. package/core/built/admin/assets/filters-CUXJ2Nm9.js +1 -0
  53. package/core/built/admin/assets/{filters-CuB-A7ma.js → filters-DfbIWc_l.js} +1 -1
  54. package/core/built/admin/assets/{ghost-3063eae29e6c9cc6d6e7131928a27da0.css → ghost-a9c788f6f0b1ea6cf0f14ca4b13f4496.css} +1 -1
  55. package/core/built/admin/assets/{ghost-05f9a7182ed56903dba05878a5b8422b.js → ghost-c4057474943effc568c5659ccc4909d7.js} +190 -190
  56. package/core/built/admin/assets/{ghost-dark-c8d0ab7f515b2010c173e23e9b32ea8a.css → ghost-dark-db36d795d21c61426837df40af270966.css} +1 -1
  57. package/core/built/admin/assets/gift-link-modal-f9VIMNDv.js +1 -0
  58. package/core/built/admin/assets/{gift-links-nBxJjZiQ.js → gift-links-CsUhefWG.js} +1 -1
  59. package/core/built/admin/assets/growth-B3I6gNLm.js +1 -0
  60. package/core/built/admin/assets/growth-CVw1My4S.js +3 -0
  61. package/core/built/admin/assets/{hooks-BApwwBE0.js → hooks-CixO01lL.js} +1 -1
  62. package/core/built/admin/assets/{inbox-Xx1CdnfO.js → inbox-CFBSu4Ed.js} +13 -13
  63. package/core/built/admin/assets/index-9Cw8z3rA.css +1 -0
  64. package/core/built/admin/assets/index-BGG7NckO.js +67 -0
  65. package/core/built/admin/assets/{input-group-CGOXcy4l.js → input-group-BSgG_lVw.js} +1 -1
  66. package/core/built/admin/assets/koenig-lexical/koenig-lexical.js +974 -937
  67. package/core/built/admin/assets/koenig-lexical/koenig-lexical.umd.js +28 -28
  68. package/core/built/admin/assets/koenig-lexical/style.css +1 -1
  69. package/core/built/admin/assets/{koenig-lexical-B61IALf5.js → koenig-lexical-f8vHRYvz.js} +28 -28
  70. package/core/built/admin/assets/{kpi-card-DCdlyaSS.js → kpi-card-DSap9rAy.js} +1 -1
  71. package/core/built/admin/assets/{kpi-card-BlZaG1Lo.js → kpi-card-DU0rn9Cj.js} +1 -1
  72. package/core/built/admin/assets/{kpi-helpers-DpAuS1Nb.js → kpi-helpers-Dz33AV6h.js} +1 -1
  73. package/core/built/admin/assets/{kpi-tabs-Dme44bQS.js → kpi-tabs-BtTTrKvJ.js} +1 -1
  74. package/core/built/admin/assets/{label-dSs5aepE.js → label-DS8Mi6G5.js} +1 -1
  75. package/core/built/admin/assets/label-picker-1DvSuLA1.js +1 -0
  76. package/core/built/admin/assets/labels-CDSUt4J_.js +1 -0
  77. package/core/built/admin/assets/layout-yQEi2qDM.js +1 -0
  78. package/core/built/admin/assets/{link-helpers-tOlxmuH5.js → link-helpers-7EYqvvHs.js} +1 -1
  79. package/core/built/admin/assets/member-custom-fields-yOMHyAg9.js +1 -0
  80. package/core/built/admin/assets/member-detail-BAq7gDaa.js +1 -0
  81. package/core/built/admin/assets/members-CQpVFJu4.js +10 -0
  82. package/core/built/admin/assets/modals-ReORvWnu.js +31 -0
  83. package/core/built/admin/assets/moderation-DySbgyUS.js +1 -0
  84. package/core/built/admin/assets/newsletter-CJQvtHqN.js +1 -0
  85. package/core/built/admin/assets/newsletters-B3QQXfGT.js +1 -0
  86. package/core/built/admin/assets/{newsletters-D1jbtV-h.js → newsletters-BXJbJgA-.js} +1 -1
  87. package/core/built/admin/assets/note-C8kxrz6w.js +1 -0
  88. package/core/built/admin/assets/notifications-CgtSfX7t.js +1 -0
  89. package/core/built/admin/assets/{offers-CrY6nSRR.js → offers-DEniVRD5.js} +1 -1
  90. package/core/built/admin/assets/onboarding-route-4qtziA9d.js +1 -0
  91. package/core/built/admin/assets/overview-Bf2sbznq.js +1 -0
  92. package/core/built/admin/assets/overview-CBfePxcr.js +1 -0
  93. package/core/built/admin/assets/{page-header-op0eN36I.js → page-header-B3Wb9Jqv.js} +1 -1
  94. package/core/built/admin/assets/{pages-BbNWFcGj.js → pages-B4goRPUn.js} +1 -1
  95. package/core/built/admin/assets/{post-analytics-WwRDEnrV.js → post-analytics-We-ko5Cb.js} +1 -1
  96. package/core/built/admin/assets/post-analytics-header-Bmc9su6q.js +1 -0
  97. package/core/built/admin/assets/post-analytics-provider-B6UynTGb.js +1 -0
  98. package/core/built/admin/assets/{post-share-modal-Dx-MEjhI.js → post-share-modal-DFfO93_0.js} +1 -1
  99. package/core/built/admin/assets/{posts-EWpZIjkr.js → posts-DhXE7b6r.js} +1 -1
  100. package/core/built/admin/assets/preferences-DJPfHEGu.js +5 -0
  101. package/core/built/admin/assets/profile-DqBQp-gT.js +1 -0
  102. package/core/built/admin/assets/{referrers-BCfFYSHc.js → referrers-pmJT4k3-.js} +1 -1
  103. package/core/built/admin/assets/{select-C1qDZnW4.js → select-C5_xEt5b.js} +1 -1
  104. package/core/built/admin/assets/settings-BwiVIXau.js +4 -0
  105. package/core/built/admin/assets/{settings-H6HxFgfI.js → settings-WXNeWyU1.js} +1 -1
  106. package/core/built/admin/assets/{share-modal-BJCadJyp.js → share-modal-DsgW3wbg.js} +1 -1
  107. package/core/built/admin/assets/social-urls-BQPFEk1_.js +8 -0
  108. package/core/built/admin/assets/{sort-button-DyYlfYp7.js → sort-button-Bb_FCl7e.js} +1 -1
  109. package/core/built/admin/assets/{source-icon-_ubRDbg_.js → source-icon-BeaRxUn9.js} +1 -1
  110. package/core/built/admin/assets/{stats-BQZMGMvn.js → stats-Bjj_kmJ1.js} +1 -1
  111. package/core/built/admin/assets/{stats-view-Doei-qCX.js → stats-view-BxPbW6K1.js} +1 -1
  112. package/core/built/admin/assets/{step-1-IKiLBPoC.js → step-1-B1e3-1F8.js} +1 -1
  113. package/core/built/admin/assets/{step-2-jJ41PsDN.js → step-2-C5WaeBJo.js} +1 -1
  114. package/core/built/admin/assets/{step-3-CaqFAEqf.js → step-3-B46iVPwu.js} +2 -2
  115. package/core/built/admin/assets/{switch-t-WyENU2.js → switch-z5Y6Uzt6.js} +1 -1
  116. package/core/built/admin/assets/{table-DxUL78jU.js → table-CB-4_HBJ.js} +1 -1
  117. package/core/built/admin/assets/tabs-CCEgKYzq.js +1 -0
  118. package/core/built/admin/assets/{tags-CNujGwkT.js → tags-Cg2VJGmG.js} +1 -1
  119. package/core/built/admin/assets/{tiers-BgYLWRJv.js → tiers-DYsa4DCo.js} +1 -1
  120. package/core/built/admin/assets/{toggle-group-BeEpQ69p.js → toggle-group-b3IlxvhV.js} +1 -1
  121. package/core/built/admin/assets/{topic-filter-Bb5MOD-1.js → topic-filter-DAFT87tQ.js} +1 -1
  122. package/core/built/admin/assets/{use-analytics-data-Cds6XDjg.js → use-analytics-data-S0FTd43s.js} +1 -1
  123. package/core/built/admin/assets/use-filter-params-Gpoq2E1L.js +1 -0
  124. package/core/built/admin/assets/{use-growth-stats-D8uf98-G.js → use-growth-stats-CIaiHOVb.js} +1 -1
  125. package/core/built/admin/assets/{use-post-referrers-CRtgyuXB.js → use-post-referrers-Cq2vBPrA.js} +1 -1
  126. package/core/built/admin/assets/{use-reply-chain-data-DbKYX50v.js → use-reply-chain-data-DBDRtGu-.js} +1 -1
  127. package/core/built/admin/assets/{use-simple-pagination-DC01kpo0.js → use-simple-pagination-CjYNxdwe.js} +1 -1
  128. package/core/built/admin/assets/{use-tinybird-query-C7bQRJWw.js → use-tinybird-query-falHAtuK.js} +1 -1
  129. package/core/built/admin/assets/{use-tinybird-token-Az4tam7_.js → use-tinybird-token-D7-8ND1y.js} +1 -1
  130. package/core/built/admin/assets/{users-CcSCQFcQ.js → users-Dkv8NQZe.js} +1 -1
  131. package/core/built/admin/assets/{virtual-list-Dhuh9fGx.js → virtual-list-CvnC9zF2.js} +1 -1
  132. package/core/built/admin/assets/web-B_5GZRvH.js +1 -0
  133. package/core/built/admin/assets/web-jFYWCKDn.js +2 -0
  134. package/core/built/admin/index.html +9 -9
  135. package/core/frontend/services/proxy.js +7 -8
  136. package/core/frontend/services/routing/router-manager.js +14 -14
  137. package/core/frontend/services/routing/static-routes-router.js +8 -10
  138. package/core/server/api/endpoints/automation-action-links.js +68 -0
  139. package/core/server/api/endpoints/automation-action-links.ts +46 -0
  140. package/core/server/api/endpoints/index.js +4 -0
  141. package/core/server/api/endpoints/settings.js +0 -2
  142. package/core/server/api/endpoints/utils/serializers/output/utils/url.js +11 -1
  143. package/core/server/data/migrations/versions/6.55/2026-07-27-11-25-44-add-to-hash-to-redirects.js +51 -0
  144. package/core/server/data/migrations/versions/6.55/2026-07-28-12-08-00-remove-routes-hash-from-settings.js +5 -0
  145. package/core/server/data/schema/commands.js +5 -0
  146. package/core/server/data/schema/default-settings/default-settings.json +0 -4
  147. package/core/server/data/schema/schema.js +5 -1
  148. package/core/server/services/automations/automations-api.js +15 -0
  149. package/core/server/services/automations/automations-api.ts +19 -0
  150. package/core/server/services/automations/automations-repository.ts +18 -0
  151. package/core/server/services/automations/database-automations-repository.js +109 -26
  152. package/core/server/services/automations/database-automations-repository.ts +110 -7
  153. package/core/server/services/automations/database-date.js +10 -0
  154. package/core/server/services/automations/database-date.ts +5 -0
  155. package/core/server/services/automations/poll.js +5 -2
  156. package/core/server/services/automations/poll.ts +7 -2
  157. package/core/server/services/automations/scheduler-poll-time.js +40 -0
  158. package/core/server/services/automations/scheduler-poll-time.ts +38 -0
  159. package/core/server/services/automations/service.js +15 -3
  160. package/core/server/services/automations/service.ts +16 -3
  161. package/core/server/services/comments/comments-service-emails.js +10 -7
  162. package/core/server/services/comments/comments-service.js +9 -4
  163. package/core/server/services/email-rendering/partials/email-wrapper.hbs +3 -1
  164. package/core/server/services/email-service/email-service-wrapper.js +6 -1
  165. package/core/server/services/email-service/email-templates/partials/styles.hbs +8 -0
  166. package/core/server/services/email-service/newsletter-email-event-storage.js +41 -10
  167. package/core/server/services/lib/dynamic-redirect-manager.js +12 -3
  168. package/core/server/services/link-redirection/link-redirect-repository.js +41 -4
  169. package/core/server/services/link-redirection/link-redirect.js +2 -0
  170. package/core/server/services/link-redirection/link-redirect.ts +3 -1
  171. package/core/server/services/link-redirection/link-redirects-service.js +38 -2
  172. package/core/server/services/link-tracking/click-event.js +3 -0
  173. package/core/server/services/link-tracking/index.js +38 -6
  174. package/core/server/services/link-tracking/link-click-repository.js +30 -9
  175. package/core/server/services/link-tracking/link-click-tracking-service.js +44 -3
  176. package/core/server/services/member-attribution/outbound-link-tagger.js +3 -1
  177. package/core/server/services/member-welcome-emails/member-welcome-email-renderer.js +19 -4
  178. package/core/server/services/member-welcome-emails/service.js +11 -3
  179. package/core/server/services/members/import-export/csv/formula.js +16 -0
  180. package/core/server/services/members/import-export/csv/formula.ts +14 -0
  181. package/core/server/services/members/import-export/csv/index.js +3 -1
  182. package/core/server/services/members/import-export/csv/index.ts +1 -0
  183. package/core/server/services/members/import-export/import/completion-email.js +22 -3
  184. package/core/server/services/members/import-export/import/completion-email.ts +29 -7
  185. package/core/server/services/members/import-export/import/importer.js +25 -4
  186. package/core/server/services/members/import-export/import/importer.ts +42 -4
  187. package/core/server/services/members/import-export/index.js +9 -0
  188. package/core/server/services/members/import-export/index.ts +20 -1
  189. package/core/server/services/members/service.js +8 -1
  190. package/core/server/services/members-custom-fields/values-service.js +19 -7
  191. package/core/server/services/members-custom-fields/values-service.ts +19 -7
  192. package/core/server/services/mentions/service.js +5 -1
  193. package/core/server/services/route-settings/activation-bridge.js +59 -49
  194. package/core/server/services/route-settings/activation-bridge.ts +91 -51
  195. package/core/server/services/route-settings/dynamic-routing-service.js +22 -51
  196. package/core/server/services/route-settings/index.js +1 -16
  197. package/core/server/services/settings/settings-service.js +0 -18
  198. package/core/server/services/url/url-service-facade.js +1 -1
  199. package/core/server/services/url/url-service-facade.ts +8 -1
  200. package/core/server/web/api/endpoints/admin/routes.js +1 -0
  201. package/package.json +7 -7
  202. package/pnpm-lock.yaml +8 -8
  203. package/pnpm-workspace.yaml +5 -4
  204. package/core/built/admin/assets/Growth-BTP1LthE.js +0 -3
  205. package/core/built/admin/assets/Newsletters-CSEX0myc.js +0 -1
  206. package/core/built/admin/assets/Overview-BjACEsJF.js +0 -1
  207. package/core/built/admin/assets/Web-CxxxjCcv.js +0 -2
  208. package/core/built/admin/assets/activitypub/layout-CziktJXo.js +0 -1
  209. package/core/built/admin/assets/audience-X_XsZivI.js +0 -1
  210. package/core/built/admin/assets/comments-CvyXxOyN.js +0 -1
  211. package/core/built/admin/assets/constants-c-JNF9c2.js +0 -1
  212. package/core/built/admin/assets/domain-CPNXKVOV.js +0 -1
  213. package/core/built/admin/assets/editor-BOG2YPUm.js +0 -8
  214. package/core/built/admin/assets/field-D0XISCK2.js +0 -1
  215. package/core/built/admin/assets/filters-bXkkWro8.js +0 -1
  216. package/core/built/admin/assets/gift-link-modal-su8MsRdg.js +0 -1
  217. package/core/built/admin/assets/growth-Ba6S5FQX.js +0 -1
  218. package/core/built/admin/assets/index-D8PbzuT9.css +0 -1
  219. package/core/built/admin/assets/index-ukW_0Uie.js +0 -67
  220. package/core/built/admin/assets/label-picker-Uq91atex.js +0 -1
  221. package/core/built/admin/assets/labels-Cyayfd-e.js +0 -1
  222. package/core/built/admin/assets/layout-WUcz6tu1.js +0 -1
  223. package/core/built/admin/assets/member-custom-fields-NCzrMuba.js +0 -1
  224. package/core/built/admin/assets/member-detail-DPWDtYnN.js +0 -1
  225. package/core/built/admin/assets/members-B9rO7qr4.js +0 -10
  226. package/core/built/admin/assets/modals-CxRaan1J.js +0 -31
  227. package/core/built/admin/assets/moderation-D8zon60E.js +0 -1
  228. package/core/built/admin/assets/multi-select-combobox-1wYm3nle.js +0 -1
  229. package/core/built/admin/assets/newsletter-DbYMRxRS.js +0 -1
  230. package/core/built/admin/assets/note-PiWPnnrh.js +0 -1
  231. package/core/built/admin/assets/notifications-DEgqqB9G.js +0 -1
  232. package/core/built/admin/assets/onboarding-route-BzF6HeVn.js +0 -1
  233. package/core/built/admin/assets/overview-DixLeIqY.js +0 -1
  234. package/core/built/admin/assets/post-analytics-header-DsMoLx0R.js +0 -1
  235. package/core/built/admin/assets/post-analytics-provider-BJCPLMIb.js +0 -1
  236. package/core/built/admin/assets/preferences-D7-j6A4C.js +0 -5
  237. package/core/built/admin/assets/profile-Dl69QPp0.js +0 -1
  238. package/core/built/admin/assets/settings-BbmC_Iqq.js +0 -4
  239. package/core/built/admin/assets/social-urls-BPL1nADp.js +0 -8
  240. package/core/built/admin/assets/tabs-BQcJQ1ke.js +0 -1
  241. package/core/built/admin/assets/use-filter-params-DjXkhO54.js +0 -1
  242. package/core/built/admin/assets/web-LtHNiNBV.js +0 -1
  243. package/core/server/services/route-settings/default-settings-manager.js +0 -63
  244. package/core/server/services/route-settings/route-settings.js +0 -170
  245. package/core/server/services/route-settings/settings-loader.js +0 -60
  246. package/core/server/services/route-settings/settings-path-manager.js +0 -47
  247. package/core/server/services/route-settings/validate.js +0 -440
@@ -43,6 +43,7 @@ const poll_1 = require("./poll");
43
43
  const automationsApi = __importStar(require("./automations-api"));
44
44
  const promises_1 = require("node:timers/promises");
45
45
  const soonest_timer_1 = require("../../lib/soonest-timer");
46
+ const scheduler_poll_time_1 = require("./scheduler-poll-time");
46
47
  // @ts-expect-error This module currently lacks type definitions.
47
48
  const jobs_1 = __importDefault(require("../email-analytics/jobs"));
48
49
  const urlUtils = require('../../../shared/url-utils').default;
@@ -54,11 +55,13 @@ const memberWelcomeEmailService = require('../member-welcome-emails/service');
54
55
  const scheduleAutomationEmailAnalyticsJob = () => (jobs_1.default.scheduleRecurringAutomationsJob(true));
55
56
  class AutomationsService {
56
57
  #enqueuePollAt;
57
- init({ domainEvents, apiUrl, schedulerAdapter, internalKeys }) {
58
+ init({ domainEvents, apiUrl, schedulerAdapter, internalKeys, siteUuid }) {
58
59
  const isInitialized = Boolean(this.#enqueuePollAt);
59
60
  if (isInitialized) {
60
61
  return;
61
62
  }
63
+ // If we don't get a valid site UUID for some reason, compute jitter with the API URL.
64
+ const siteIdentifier = typeof siteUuid === 'string' && siteUuid.length ? siteUuid : apiUrl;
62
65
  const enqueuePollNow = () => domainEvents.dispatch(StartAutomationsPollEvent.create());
63
66
  const soonestTimer = new soonest_timer_1.SoonestTimer(enqueuePollNow);
64
67
  /**
@@ -86,11 +89,20 @@ class AutomationsService {
86
89
  }
87
90
  soonestTimer.scheduleAt(date);
88
91
  try {
92
+ const schedulerPollTime = (0, scheduler_poll_time_1.getSchedulerPollTime)(date, siteIdentifier);
89
93
  const key = await internalKeys.get('ghost-scheduler');
90
- const signedAdminToken = getSignedAdminToken({ publishedAt: date.toISOString(), apiUrl, key });
94
+ const signedAdminToken = getSignedAdminToken({
95
+ publishedAt: schedulerPollTime.toISOString(),
96
+ apiUrl,
97
+ key
98
+ });
91
99
  const url = new URL(urlUtils.urlJoin(apiUrl, 'automations', 'poll'));
92
100
  url.searchParams.set('token', signedAdminToken);
93
- schedulerAdapter.schedule({ time: date.getTime(), url: url.toString(), extra: { httpMethod: 'PUT' } });
101
+ schedulerAdapter.schedule({
102
+ time: schedulerPollTime.getTime(),
103
+ url: url.toString(),
104
+ extra: { httpMethod: 'PUT' }
105
+ });
94
106
  }
95
107
  catch (err) {
96
108
  logging.error({ event: { name: 'automations.enqueue-poll.error' }, err, at: date.toISOString() }, 'Failed to enqueue automations poll');
@@ -8,6 +8,7 @@ import {poll} from './poll';
8
8
  import * as automationsApi from './automations-api';
9
9
  import {setImmediate as flushEventLoop} from 'node:timers/promises';
10
10
  import {SoonestTimer} from '../../lib/soonest-timer';
11
+ import {getSchedulerPollTime} from './scheduler-poll-time';
11
12
  // @ts-expect-error This module currently lacks type definitions.
12
13
  import emailAnalyticsJobs from '../email-analytics/jobs';
13
14
 
@@ -23,6 +24,7 @@ type AutomationsServiceOptions = {
23
24
  domainEvents: Pick<DomainEvents, 'dispatch' | 'subscribe'>;
24
25
  internalKeys: InternalKeys;
25
26
  schedulerAdapter: Pick<SchedulerAdapter, 'schedule' | 'register'>;
27
+ siteUuid: unknown;
26
28
  };
27
29
 
28
30
  const scheduleAutomationEmailAnalyticsJob = () => (
@@ -32,12 +34,14 @@ const scheduleAutomationEmailAnalyticsJob = () => (
32
34
  export class AutomationsService {
33
35
  #enqueuePollAt: undefined | ((date: Readonly<Date>) => Promise<void>);
34
36
 
35
- init({domainEvents, apiUrl, schedulerAdapter, internalKeys}: AutomationsServiceOptions): void {
37
+ init({domainEvents, apiUrl, schedulerAdapter, internalKeys, siteUuid}: AutomationsServiceOptions): void {
36
38
  const isInitialized = Boolean(this.#enqueuePollAt);
37
39
  if (isInitialized) {
38
40
  return;
39
41
  }
40
42
 
43
+ // If we don't get a valid site UUID for some reason, compute jitter with the API URL.
44
+ const siteIdentifier = typeof siteUuid === 'string' && siteUuid.length ? siteUuid : apiUrl;
41
45
  const enqueuePollNow = () => domainEvents.dispatch(StartAutomationsPollEvent.create());
42
46
 
43
47
  const soonestTimer = new SoonestTimer(enqueuePollNow);
@@ -69,11 +73,20 @@ export class AutomationsService {
69
73
  soonestTimer.scheduleAt(date);
70
74
 
71
75
  try {
76
+ const schedulerPollTime = getSchedulerPollTime(date, siteIdentifier);
72
77
  const key = await internalKeys.get('ghost-scheduler');
73
- const signedAdminToken = getSignedAdminToken({publishedAt: date.toISOString(), apiUrl, key});
78
+ const signedAdminToken = getSignedAdminToken({
79
+ publishedAt: schedulerPollTime.toISOString(),
80
+ apiUrl,
81
+ key
82
+ });
74
83
  const url = new URL(urlUtils.urlJoin(apiUrl, 'automations', 'poll'));
75
84
  url.searchParams.set('token', signedAdminToken);
76
- schedulerAdapter.schedule({time: date.getTime(), url: url.toString(), extra: {httpMethod: 'PUT'}});
85
+ schedulerAdapter.schedule({
86
+ time: schedulerPollTime.getTime(),
87
+ url: url.toString(),
88
+ extra: {httpMethod: 'PUT'}
89
+ });
77
90
  } catch (err) {
78
91
  logging.error({event: {name: 'automations.enqueue-poll.error'}, err, at: date.toISOString()}, 'Failed to enqueue automations poll');
79
92
  }
@@ -87,12 +87,13 @@ class CommentsServiceEmails {
87
87
  }
88
88
  }
89
89
 
90
- async notifyParentCommentAuthor(reply, {type = 'parent'} = {}) {
91
- let parent;
92
- if (type === 'in_reply_to') {
93
- parent = await this.models.Comment.findOne({id: reply.get('in_reply_to_id')});
94
- } else {
95
- parent = await this.models.Comment.findOne({id: reply.get('parent_id')});
90
+ /**
91
+ * @returns {Promise<string|undefined>} The id of the member that was notified, or undefined if no email was sent
92
+ */
93
+ async notifyParentCommentAuthor(reply, {type = 'parent', parent = null} = {}) {
94
+ if (!parent) {
95
+ const parentId = type === 'in_reply_to' ? reply.get('in_reply_to_id') : reply.get('parent_id');
96
+ parent = await this.models.Comment.findOne({id: parentId});
96
97
  }
97
98
  const parentMember = parent.related('member');
98
99
 
@@ -140,12 +141,14 @@ class CommentsServiceEmails {
140
141
  text
141
142
  } = await this.commentsServiceEmailRenderer.renderEmailTemplate('new-comment-reply', templateData);
142
143
 
143
- return this.sendMail({
144
+ await this.sendMail({
144
145
  to: parentMemberEmail,
145
146
  subject,
146
147
  html,
147
148
  text
148
149
  });
150
+
151
+ return parentMember.id;
149
152
  }
150
153
 
151
154
  /**
@@ -378,11 +378,16 @@ class CommentsService {
378
378
  async sendNewCommentNotifications(comment) {
379
379
  await this.emails.notifyPostAuthors(comment);
380
380
 
381
- if (comment.get('parent_id')) {
382
- await this.emails.notifyParentCommentAuthor(comment, {type: 'parent'});
383
- }
381
+ const notifiedMemberId = comment.get('parent_id')
382
+ ? await this.emails.notifyParentCommentAuthor(comment, {type: 'parent'})
383
+ : null;
384
+
384
385
  if (comment.get('in_reply_to_id')) {
385
- await this.emails.notifyParentCommentAuthor(comment, {type: 'in_reply_to'});
386
+ const inReplyTo = await this.models.Comment.findOne({id: comment.get('in_reply_to_id')});
387
+
388
+ if (inReplyTo && (!notifiedMemberId || inReplyTo.get('member_id') !== notifiedMemberId)) {
389
+ await this.emails.notifyParentCommentAuthor(comment, {type: 'in_reply_to', parent: inReplyTo});
390
+ }
386
391
  }
387
392
  }
388
393
 
@@ -84,7 +84,9 @@
84
84
  <table role="presentation" border="0" cellpadding="0" cellspacing="0">
85
85
  <tr>
86
86
  <td>
87
- <a href="{{post.url}}" class="{{classes.titleLink}}">{{post.title}}</a>
87
+ <h1 class="post-title-heading">
88
+ <a href="{{post.url}}" class="{{classes.titleLink}}">{{post.title}}</a>
89
+ </h1>
88
90
  </td>
89
91
  </tr>
90
92
  </table>
@@ -6,7 +6,12 @@ const events = require('../../lib/common/events');
6
6
  class EmailServiceWrapper {
7
7
  getPostUrl(post) {
8
8
  const jsonModel = post.toJSON();
9
- url.forPost(post.id, jsonModel, {options: {}});
9
+ // The URL service routes by resource type. Pages and posts share the
10
+ // Post model, so the page's own type must reach forPost — otherwise it
11
+ // defaults to 'posts', matches no post collection, and 404s under the
12
+ // lazy service.
13
+ const type = jsonModel.type === 'page' ? 'pages' : 'posts';
14
+ url.forPost(post.id, jsonModel, {options: {}}, type);
10
15
  return jsonModel.url;
11
16
  }
12
17
 
@@ -161,6 +161,14 @@ img.is-dark-background {
161
161
  color: {{postTitleColor}};
162
162
  }
163
163
 
164
+ /* Outlook's Word engine ignores `inherit`, so repeat the `.post-title > table td` values */
165
+ .post-title-heading {
166
+ margin: 0;
167
+ font-size: 36px;
168
+ line-height: 1.1;
169
+ font-weight: {{titleWeight}};
170
+ }
171
+
164
172
  .post-title a {
165
173
  color: {{postTitleColor}} !important;
166
174
  }
@@ -1,4 +1,5 @@
1
1
  const moment = require('moment-timezone');
2
+ const errors = require('@tryghost/errors');
2
3
  const logging = require('@tryghost/logging');
3
4
  const config = require('../../../shared/config');
4
5
 
@@ -177,11 +178,20 @@ class NewsletterEmailEventStorage {
177
178
 
178
179
  async handleUnsubscribed(event) {
179
180
  try {
180
- // Unsubscribe member from the specific newsletter
181
- const newsletters = await this.findNewslettersToKeep(event);
182
- await this.#membersRepository.update({newsletters}, {id: event.memberId});
181
+ const result = await this.findNewslettersToKeep(event);
183
182
 
184
- // Remove member from Mailgun's suppression list
183
+ if (result.status === 'failed') {
184
+ // Leave Mailgun's suppression in place: these events are fetched
185
+ // once and never retried, so it is the only remaining protection.
186
+ return;
187
+ }
188
+
189
+ if (result.status === 'ok') {
190
+ await this.#membersRepository.update({newsletters: result.newsletters}, {id: event.memberId});
191
+ }
192
+
193
+ // Remove member from Mailgun's suppression list, only once the local
194
+ // record reflects the unsubscribe or there is no member left to protect
185
195
  await this.#emailSuppressionList.removeUnsubscribe(event.email);
186
196
  } catch (err) {
187
197
  logging.error(err);
@@ -205,22 +215,43 @@ class NewsletterEmailEventStorage {
205
215
  }
206
216
  }
207
217
 
218
+ /**
219
+ * @typedef {{status: 'ok', newsletters: {id: string}[]}
220
+ * | {status: 'no-member'}
221
+ * | {status: 'failed'}} FindNewslettersToKeepResult
222
+ */
223
+
224
+ /**
225
+ * @param {import('./events/email-unsubscribed-event')} event
226
+ * @returns {Promise<FindNewslettersToKeepResult>}
227
+ */
208
228
  async findNewslettersToKeep(event) {
209
229
  try {
210
- const member = await this.#membersRepository.get({email: event.email}, {
230
+ const member = await this.#membersRepository.get({id: event.memberId}, {
211
231
  withRelated: ['newsletters']
212
232
  });
233
+
234
+ if (!member) {
235
+ return {status: 'no-member'};
236
+ }
237
+
213
238
  const existingNewsletters = member.related('newsletters');
214
239
 
215
240
  const email = await this.#models.Email.findOne({id: event.emailId});
216
241
  const newsletterToRemove = email.get('newsletter_id');
217
242
 
218
- return existingNewsletters.models.filter(newsletter => newsletter.id !== newsletterToRemove).map((n) => {
219
- return {id: n.id};
220
- });
243
+ return {
244
+ status: 'ok',
245
+ newsletters: existingNewsletters.models.filter(newsletter => newsletter.id !== newsletterToRemove).map((n) => {
246
+ return {id: n.id};
247
+ })
248
+ };
221
249
  } catch (err) {
222
- logging.error(err);
223
- return [];
250
+ logging.error(new errors.InternalServerError({
251
+ message: `Could not resolve newsletters to keep for unsubscribe event (member ${event.memberId}, email ${event.emailId})`,
252
+ err
253
+ }));
254
+ return {status: 'failed'};
224
255
  }
225
256
  }
226
257
 
@@ -56,14 +56,23 @@ class DynamicRedirectManager {
56
56
 
57
57
  this.router.get(fromRegex, (req, res) => {
58
58
  const maxAge = permanent ? this.permanentMaxAge : 0;
59
- const toURL = parseURL(to);
60
- const toURLParams = parseQuerystring(toURL.query);
61
59
  const currentURL = parseURL(req.url);
62
60
  const currentURLParams = parseQuerystring(currentURL.query);
61
+
62
+ const toURL = parseURL(to);
63
+ toURL.pathname = currentURL.pathname.replace(fromRegex, toURL.pathname);
64
+
65
+ // Captures are percent-encoded path segments (Express rejects any it can't decode),
66
+ // so decode then re-encode for the query — reserved chars stay literal, no double-encoding
67
+ const captures = currentURL.pathname.match(fromRegex) || [];
68
+ const substitutedQuery = (toURL.query || '').replace(/\$(\d+)/g, (match, n) => (
69
+ captures[n] !== undefined ? encodeURIComponent(decodeURIComponent(captures[n])) : match
70
+ ));
71
+ const toURLParams = parseQuerystring(substitutedQuery);
72
+
63
73
  const params = Object.assign({}, currentURLParams, toURLParams);
64
74
  const search = formatQuerystring(params);
65
75
 
66
- toURL.pathname = currentURL.pathname.replace(fromRegex, toURL.pathname);
67
76
  toURL.search = search !== '' ? `?${search}` : null;
68
77
 
69
78
  /**
@@ -1,3 +1,4 @@
1
+ const crypto = require('crypto');
1
2
  const {LinkRedirect} = require('./link-redirect');
2
3
  const ObjectID = require('bson-objectid').default;
3
4
  const debug = require('@tryghost/debug')('LinkRedirectRepository');
@@ -49,7 +50,11 @@ module.exports = class LinkRedirectRepository {
49
50
  const model = await this.#LinkRedirect.add({
50
51
  // Only store the pathname (no support for variable query strings)
51
52
  from: this.stripSubdirectoryFromPath(linkRedirect.from.pathname),
52
- to: linkRedirect.to.href
53
+ to: linkRedirect.to.href,
54
+ ...(linkRedirect.automationActionRevisionId ? {
55
+ automation_action_revision_id: linkRedirect.automationActionRevisionId,
56
+ to_hash: this.#getToHash(linkRedirect.to)
57
+ } : {})
53
58
  }, {});
54
59
 
55
60
  linkRedirect.link_id = ObjectID.createFromHexString(model.id);
@@ -82,7 +87,8 @@ module.exports = class LinkRedirectRepository {
82
87
  id: model.id,
83
88
  from: new URL(this.#trimLeadingSlash(model.get('from')), this.#urlUtils.urlFor('home', true)),
84
89
  to: new URL(model.get('to')),
85
- edited
90
+ edited,
91
+ automationActionRevisionId: model.get('automation_action_revision_id')
86
92
  });
87
93
  }
88
94
 
@@ -93,6 +99,7 @@ module.exports = class LinkRedirectRepository {
93
99
  * @param {string} serialized.from - path of the URL
94
100
  * @param {string} serialized.to - URL to redirect to
95
101
  * @param {boolean} serialized.edited - whether the link has been edited
102
+ * @param {string} [serialized.automationActionRevisionId] - owning automation action revision
96
103
  * @returns {InstanceType<LinkRedirect>} LinkRedirect
97
104
  */
98
105
  #fromSerialized(serialized) {
@@ -100,7 +107,8 @@ module.exports = class LinkRedirectRepository {
100
107
  id: serialized.link_id,
101
108
  from: new URL(this.#trimLeadingSlash(serialized.from), this.#urlUtils.urlFor('home', true)),
102
109
  to: new URL(serialized.to),
103
- edited: serialized.edited
110
+ edited: serialized.edited,
111
+ automationActionRevisionId: serialized.automationActionRevisionId
104
112
  });
105
113
  }
106
114
 
@@ -114,7 +122,10 @@ module.exports = class LinkRedirectRepository {
114
122
  link_id: linkRedirect.link_id.toHexString(),
115
123
  from: linkRedirect.from.pathname,
116
124
  to: linkRedirect.to.href,
117
- edited: linkRedirect.edited
125
+ edited: linkRedirect.edited,
126
+ ...(linkRedirect.automationActionRevisionId ? {
127
+ automationActionRevisionId: linkRedirect.automationActionRevisionId
128
+ } : {})
118
129
  };
119
130
  }
120
131
 
@@ -180,6 +191,32 @@ module.exports = class LinkRedirectRepository {
180
191
  }
181
192
  }
182
193
 
194
+ /**
195
+ * Get an automation LinkRedirect by action revision and destination URL
196
+ * @param {string} automationActionRevisionId
197
+ * @param {Readonly<URL>} url
198
+ * @returns {Promise<InstanceType<LinkRedirect>|undefined>} LinkRedirect
199
+ */
200
+ async getByAutomationActionRevisionAndURL(automationActionRevisionId, url) {
201
+ const linkRedirectModel = await this.#LinkRedirect.findOne({
202
+ automation_action_revision_id: automationActionRevisionId,
203
+ to_hash: this.#getToHash(url)
204
+ }, {});
205
+
206
+ if (linkRedirectModel) {
207
+ return this.fromModel(linkRedirectModel);
208
+ }
209
+ }
210
+
211
+ /**
212
+ * The destination column is too long to index, so unique lookups use its SHA-256 digest
213
+ * @param {Readonly<URL>} url
214
+ * @returns {Buffer}
215
+ */
216
+ #getToHash(url) {
217
+ return crypto.createHash('sha256').update(url.href).digest();
218
+ }
219
+
183
220
  /**
184
221
  * Convert root relative URLs to subdirectory relative URLs
185
222
  * @param {string} path
@@ -10,6 +10,7 @@ class LinkRedirect {
10
10
  from;
11
11
  to;
12
12
  edited;
13
+ automationActionRevisionId;
13
14
  constructor(data) {
14
15
  if (!data.id) {
15
16
  this.link_id = new bson_objectid_1.default();
@@ -20,6 +21,7 @@ class LinkRedirect {
20
21
  this.from = data.from;
21
22
  this.to = data.to;
22
23
  this.edited = !!data.edited;
24
+ this.automationActionRevisionId = data.automationActionRevisionId ?? undefined;
23
25
  }
24
26
  }
25
27
  exports.LinkRedirect = LinkRedirect;
@@ -5,8 +5,9 @@ export class LinkRedirect {
5
5
  from: URL;
6
6
  to: URL;
7
7
  edited: boolean;
8
+ automationActionRevisionId: string | undefined;
8
9
 
9
- constructor(data: {id?: string; from: URL; to: URL; edited?: boolean}) {
10
+ constructor(data: {id?: string; from: URL; to: URL; edited?: boolean; automationActionRevisionId?: string | null}) {
10
11
  if (!data.id) {
11
12
  this.link_id = new ObjectID();
12
13
  }
@@ -18,5 +19,6 @@ export class LinkRedirect {
18
19
  this.from = data.from;
19
20
  this.to = data.to;
20
21
  this.edited = !!data.edited;
22
+ this.automationActionRevisionId = data.automationActionRevisionId ?? undefined;
21
23
  }
22
24
  }
@@ -6,6 +6,7 @@ const {LinkRedirect} = require('./link-redirect');
6
6
  /**
7
7
  * @typedef {object} ILinkRedirectRepository
8
8
  * @prop {(url: URL) => Promise<LinkRedirect|undefined>} getByURL
9
+ * @prop {(automationActionRevisionId: string, url: URL) => Promise<LinkRedirect|undefined>} getByAutomationActionRevisionAndURL
9
10
  * @prop {({filter: string}) => Promise<LinkRedirect[]>} getAll
10
11
  * @prop {({filter: string}) => Promise<String[]>} getFilteredIds
11
12
  * @prop {(linkRedirect: LinkRedirect) => Promise<void>} save
@@ -19,6 +20,8 @@ const MEMBER_UUID_PLACEHOLDER = '%%{uuid}%%';
19
20
  // Ghost uses UUID v4; this regex is not that strict
20
21
  const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
21
22
 
23
+ const isUniqueConstraintError = err => err?.code === 'ER_DUP_ENTRY' || err?.code?.startsWith?.('SQLITE_CONSTRAINT');
24
+
22
25
  class LinkRedirectsService {
23
26
  /** @type ILinkRedirectRepository */
24
27
  #linkRedirectRepository;
@@ -71,13 +74,15 @@ class LinkRedirectsService {
71
74
  /**
72
75
  * @param {URL} from
73
76
  * @param {URL} to
77
+ * @param {string} [automationActionRevisionId]
74
78
  *
75
79
  * @returns {Promise<LinkRedirect>}
76
80
  */
77
- async addRedirect(from, to) {
81
+ async addRedirect(from, to, automationActionRevisionId) {
78
82
  const link = new LinkRedirect({
79
83
  from,
80
- to
84
+ to,
85
+ automationActionRevisionId
81
86
  });
82
87
 
83
88
  await this.#linkRedirectRepository.save(link);
@@ -85,6 +90,37 @@ class LinkRedirectsService {
85
90
  return link;
86
91
  }
87
92
 
93
+ /**
94
+ * Get the shared redirect for a destination within an automation action revision,
95
+ * creating it on first use. A concurrent winning insert is returned after a
96
+ * duplicate-key failure.
97
+ * @param {string} automationActionRevisionId
98
+ * @param {Readonly<URL>} to
99
+ * @returns {Promise<LinkRedirect>}
100
+ */
101
+ async getOrAddAutomationRedirect(automationActionRevisionId, to) {
102
+ const existing = await this.#linkRedirectRepository.getByAutomationActionRevisionAndURL(automationActionRevisionId, to);
103
+ if (existing) {
104
+ return existing;
105
+ }
106
+
107
+ const from = await this.getSlugUrl();
108
+ try {
109
+ return await this.addRedirect(from, to, automationActionRevisionId);
110
+ } catch (err) {
111
+ if (!isUniqueConstraintError(err)) {
112
+ throw err;
113
+ }
114
+
115
+ const winner = await this.#linkRedirectRepository.getByAutomationActionRevisionAndURL(automationActionRevisionId, to);
116
+ if (winner) {
117
+ return winner;
118
+ }
119
+
120
+ throw err;
121
+ }
122
+ }
123
+
88
124
  /**
89
125
  * Returns the relative path prefix without subdirectory (e.g., /r/)
90
126
  * Used for Express route matching where subdirectory is already stripped
@@ -7,6 +7,8 @@ module.exports = class ClickEvent {
7
7
  member_uuid;
8
8
  /** @type {ObjectID} */
9
9
  link_id;
10
+ /** @type {Date | undefined} */
11
+ timestamp;
10
12
 
11
13
  constructor(data) {
12
14
  if (!data.id) {
@@ -21,5 +23,6 @@ module.exports = class ClickEvent {
21
23
 
22
24
  this.member_uuid = data.member_uuid;
23
25
  this.link_id = data.link_id;
26
+ this.timestamp = data.timestamp;
24
27
  }
25
28
  };
@@ -4,12 +4,37 @@ const errors = require('@tryghost/errors');
4
4
  const urlUtils = require('../../../shared/url-utils').default;
5
5
 
6
6
  class LinkTrackingServiceWrapper {
7
+ #initPromise;
8
+ #automationsApi;
9
+
10
+ constructor({
11
+ automationsApi = require('../automations/automations-api')
12
+ } = {}) {
13
+ this.#automationsApi = automationsApi;
14
+ }
15
+
7
16
  async init() {
8
17
  if (this.service) {
9
18
  // Already done
10
19
  return;
11
20
  }
12
21
 
22
+ if (!this.#initPromise) {
23
+ this.#initPromise = this.#initialise();
24
+ }
25
+
26
+ const initPromise = this.#initPromise;
27
+ try {
28
+ await initPromise;
29
+ } catch (error) {
30
+ if (this.#initPromise === initPromise) {
31
+ this.#initPromise = undefined;
32
+ }
33
+ throw error;
34
+ }
35
+ }
36
+
37
+ async #initialise() {
13
38
  const linkRedirection = require('../link-redirection');
14
39
  if (!linkRedirection.service) {
15
40
  throw new errors.InternalServerError({message: 'LinkRedirectionService should be initialised before LinkTrackingService'});
@@ -27,24 +52,31 @@ class LinkTrackingServiceWrapper {
27
52
  linkRedirectRepository: linkRedirection.linkRedirectRepository
28
53
  });
29
54
 
30
- this.linkClickRepository = new LinkClickRepository({
55
+ const linkClickRepository = new LinkClickRepository({
31
56
  MemberLinkClickEventModel: models.MemberClickEvent,
32
57
  Member: models.Member,
33
58
  MemberLinkClickEvent: MemberLinkClickEvent,
34
59
  DomainEvents
35
60
  });
36
61
 
37
- // Expose the service
38
- this.service = new LinkClickTrackingService({
62
+ const service = new LinkClickTrackingService({
39
63
  linkRedirectService: linkRedirection.service,
40
- linkClickRepository: this.linkClickRepository,
64
+ linkClickRepository,
41
65
  postLinkRepository,
42
66
  DomainEvents,
43
- urlUtils
67
+ urlUtils,
68
+ automationsApi: this.#automationsApi,
69
+ runInTransaction: callback => models.Base.transaction(callback)
44
70
  });
45
71
 
46
- await this.service.init();
72
+ await service.init();
73
+
74
+ // Expose the service only after it has finished initialising
75
+ this.linkClickRepository = linkClickRepository;
76
+ this.service = service;
47
77
  }
48
78
  }
49
79
 
50
80
  module.exports = new LinkTrackingServiceWrapper();
81
+ // Exposed for testing purposes only
82
+ module.exports.LinkTrackingServiceWrapper = LinkTrackingServiceWrapper;
@@ -31,8 +31,8 @@ module.exports = class LinkClickRepository {
31
31
  this.#DomainEvents = deps.DomainEvents;
32
32
 
33
33
  // Memoize the findOne function
34
- this.memoizedFindOne = _.memoize(async (uuid) => {
35
- return await this.#Member.findOne({uuid});
34
+ this.memoizedFindOne = _.memoize(async (uuid, options) => {
35
+ return await this.#Member.findOne({uuid}, options);
36
36
  });
37
37
  }
38
38
 
@@ -54,15 +54,18 @@ module.exports = class LinkClickRepository {
54
54
 
55
55
  /**
56
56
  * @param {LinkClick} linkClick
57
- * @returns {Promise<void>}
57
+ * @param {object} [options]
58
+ * @param {object} [options.transacting]
59
+ * @returns {Promise<string | undefined>}
58
60
  */
59
- async save(linkClick) {
61
+ async save(linkClick, options = {}) {
60
62
  let member;
63
+ const memberLookupOptions = options.transacting ? {transacting: options.transacting} : undefined;
61
64
 
62
65
  if (config && config.get('linkClickTrackingCacheMemberUuid')) {
63
- member = await this.memoizedFindOne(linkClick.member_uuid);
66
+ member = await this.memoizedFindOne(linkClick.member_uuid, memberLookupOptions);
64
67
  } else {
65
- member = await this.#Member.findOne({uuid: linkClick.member_uuid});
68
+ member = await this.#Member.findOne({uuid: linkClick.member_uuid}, memberLookupOptions);
66
69
  }
67
70
 
68
71
  if (!member) {
@@ -76,11 +79,29 @@ module.exports = class LinkClickRepository {
76
79
  // Only store the pathname (no support for variable query strings)
77
80
  redirect_id: linkClick.link_id.toHexString(),
78
81
  member_id: member.id
79
- }, {});
82
+ }, options);
80
83
 
81
84
  linkClick.event_id = ObjectID.createFromHexString(model.id);
85
+ const timestamp = linkClick.timestamp ?? new Date();
86
+
87
+ const event = this.#MemberLinkClickEvent.create({
88
+ memberId: member.id,
89
+ memberLastSeenAt: member.get('last_seen_at'),
90
+ linkId: linkClick.link_id.toHexString()
91
+ }, timestamp);
92
+
93
+ if (options.transacting) {
94
+ options.transacting.executionPromise.then(() => {
95
+ try {
96
+ this.#DomainEvents.dispatch(event);
97
+ } catch (error) {
98
+ sentry.captureException(error);
99
+ }
100
+ }, () => undefined);
101
+ } else {
102
+ this.#DomainEvents.dispatch(event);
103
+ }
82
104
 
83
- // Dispatch event
84
- this.#DomainEvents.dispatch(this.#MemberLinkClickEvent.create({memberId: member.id, memberLastSeenAt: member.get('last_seen_at'), linkId: linkClick.link_id.toHexString()}, new Date()));
105
+ return member.id;
85
106
  }
86
107
  };