ghost 6.54.1 → 6.56.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 (356) 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-nodes-2.1.4.tgz +0 -0
  4. package/components/tryghost-kg-default-transforms-1.3.3.tgz +0 -0
  5. package/components/tryghost-kg-html-to-lexical-1.3.3.tgz +0 -0
  6. package/components/tryghost-kg-lexical-html-renderer-1.4.3.tgz +0 -0
  7. package/components/tryghost-nql-string-0.0.0.tgz +0 -0
  8. package/core/boot.js +3 -6
  9. package/core/built/admin/assets/{Combination-CJG7Flp-.js → Combination-BCm9FL4o.js} +1 -1
  10. package/core/built/admin/assets/{PolarAngleAxis-BGjf7sBu.js → PolarAngleAxis-C4qtHy1B.js} +1 -1
  11. package/core/built/admin/assets/{RadialBarChart-zeFf9mBz.js → RadialBarChart-Bh09Stsj.js} +1 -1
  12. package/core/built/admin/assets/{account-migration-BklF2I_I.js → account-migration-D3O_yVck.js} +1 -1
  13. package/core/built/admin/assets/activitypub/{account-migration-CNovr6W-.js → account-migration-D8FbP-Z-.js} +7 -7
  14. package/core/built/admin/assets/activitypub/activitypub.js +6 -6
  15. package/core/built/admin/assets/activitypub/{avatar-B3DEDB1l.js → avatar-11EA1VSs.js} +2 -2
  16. package/core/built/admin/assets/activitypub/{avatar-flipboard-Cqrg0XMx.js → avatar-flipboard-jfbLG-xe.js} +2 -2
  17. package/core/built/admin/assets/activitypub/{bluesky-sharing-Ceslb1_M.js → bluesky-sharing-f1jTQe2P.js} +16 -16
  18. package/core/built/admin/assets/activitypub/{content-formatters-BpQARh28.js → content-formatters-nmTOPY9M.js} +3 -3
  19. package/core/built/admin/assets/activitypub/{copy-D9UWIAan.js → copy-BCwo9ep3.js} +2 -2
  20. package/core/built/admin/assets/activitypub/{dist-DHAjBcV3.js → dist-BEfX-0aW.js} +12 -12
  21. package/core/built/admin/assets/activitypub/{domain-C1qQvJ8C.js → domain-AaDlZlrA.js} +9 -9
  22. package/core/built/admin/assets/activitypub/{edit-profile-BcH-jEfM.js → edit-profile-DPi8Tl5M.js} +945 -945
  23. package/core/built/admin/assets/activitypub/{explore-AxwBh9dI.js → explore-CxTzjILQ.js} +8 -8
  24. package/core/built/admin/assets/activitypub/{feed-BaOlbh7a.js → feed-DPv5x2MX.js} +33 -33
  25. package/core/built/admin/assets/activitypub/{field-B7mYYQpN.js → field-DbgA8lat.js} +3 -3
  26. package/core/built/admin/assets/activitypub/{hash-BC9Q0N4c.js → hash-BWuZoYkA.js} +2 -2
  27. package/core/built/admin/assets/activitypub/{inbox-DQ4ZnC1m.js → inbox-35VDQ2K2.js} +2 -2
  28. package/core/built/admin/assets/activitypub/{inbox-vXez-5uJ.js → inbox-DSrF_i1O.js} +85 -85
  29. package/core/built/admin/assets/activitypub/{label-Chv5T8bw.js → label-CPjFBbo7.js} +3 -3
  30. package/core/built/admin/assets/activitypub/layout-CTgrezN7.js +1 -0
  31. package/core/built/admin/assets/activitypub/{moderation-DtSM2i1I.js → moderation-BkDC1XVZ.js} +11 -11
  32. package/core/built/admin/assets/activitypub/{note-Dckp4S-2.js → note-xEyH4xT-.js} +7 -7
  33. package/core/built/admin/assets/activitypub/{notifications-CG5T22vg.js → notifications-DClaL3ui.js} +9 -9
  34. package/core/built/admin/assets/activitypub/{preferences-D_799jdl.js → preferences-DpnOa-4Z.js} +20 -20
  35. package/core/built/admin/assets/activitypub/{profile-D2JfXBQl.js → profile-Dvi9mz-6.js} +237 -237
  36. package/core/built/admin/assets/activitypub/{reply-CpdMeAO5.js → reply-Cs9ySd6Z.js} +2 -2
  37. package/core/built/admin/assets/activitypub/{routes-CCHe2dQp.js → routes-DFBeTnfn.js} +1139 -1150
  38. package/core/built/admin/assets/activitypub/{separator-BI1FLsYM.js → separator-C5u_VjzY.js} +3 -3
  39. package/core/built/admin/assets/activitypub/{settings-kx-9Enkz.js → settings-CV2fFu6D.js} +13 -13
  40. package/core/built/admin/assets/activitypub/{site-x4_IjVJ0.js → site-_TgDd_EM.js} +81 -81
  41. package/core/built/admin/assets/activitypub/{step-1-DIDg4aHb.js → step-1-BfnNL2oF.js} +5 -5
  42. package/core/built/admin/assets/activitypub/{step-2-BfGe8BYt.js → step-2-DLndZkO-.js} +6 -6
  43. package/core/built/admin/assets/activitypub/{step-3-mE9spAqw.js → step-3-A-PZTtRE.js} +10 -10
  44. package/core/built/admin/assets/activitypub/{tabs-CyPRQQ1N.js → tabs-BUbz7tea.js} +321 -319
  45. package/core/built/admin/assets/activitypub/{topic-filter-DLHBZT93.js → topic-filter-BdX_xYYT.js} +3 -3
  46. package/core/built/admin/assets/activitypub/{use-activity-pub-queries-VzbN99u_.js → use-activity-pub-queries-EwuRemHh.js} +2 -2
  47. package/core/built/admin/assets/activitypub/{use-navigate-with-base-path-CAwXTmqH.js → use-navigate-with-base-path-cy4ACBc4.js} +92 -90
  48. package/core/built/admin/assets/activitypub/{use-reply-chain-data-BAB6fNGt.js → use-reply-chain-data-CyFcGfan.js} +5 -5
  49. package/core/built/admin/assets/activitypub/{x-C8tAnXly.js → x-7zk8mV2p.js} +2 -2
  50. package/core/built/admin/assets/audience-CkBb2LlO.js +1 -0
  51. package/core/built/admin/assets/{automated-emails-of1MvVsv.js → automated-emails-6t2bIRWf.js} +2 -2
  52. package/core/built/admin/assets/{automations-B7-y3dXt.js → automations-DhKeS_U5.js} +1 -1
  53. package/core/built/admin/assets/{automations-DRFjW5fw.js → automations-cMllU545.js} +1 -1
  54. package/core/built/admin/assets/{bluesky-sharing-BbbjWdzs.js → bluesky-sharing-DPGpyD-G.js} +1 -1
  55. package/core/built/admin/assets/{box-BWbhXh21.js → box-By_EgGtm.js} +1 -1
  56. package/core/built/admin/assets/{breadcrumb-B-M9Ip_2.js → breadcrumb-DIfcF5ow.js} +1 -1
  57. package/core/built/admin/assets/build-CsiH26uS.js +1 -0
  58. package/core/built/admin/assets/{card-DGoDJzrn.js → card-aUHX0hyR.js} +1 -1
  59. package/core/built/admin/assets/{chart-BuRT2Y6p.js → chart-BnXyfH11.js} +1 -1
  60. package/core/built/admin/assets/{chart-helpers-xiSsW8r0.js → chart-helpers-DV0FkdcD.js} +1 -1
  61. package/core/built/admin/assets/{checkbox-EhJKHFdZ.js → checkbox-BIl077a0.js} +1 -1
  62. package/core/built/admin/assets/{chunk.361.3cdb584b973959e52ccc.js → chunk.332.05991a101d67f732dec3.js} +16 -12
  63. package/core/built/admin/assets/chunk.524.87b52f4fada529df2c4c.js +25 -0
  64. package/core/built/admin/assets/chunk.582.340bcca873e806cbd4bb.js +27 -0
  65. package/core/built/admin/assets/{code-editor-view-CZtJFGhR.js → code-editor-view-C0aWNwdl.js} +1 -1
  66. package/core/built/admin/assets/comments-qwhIjH3c.js +1 -0
  67. package/core/built/admin/assets/{container-BYz2Ed39.js → container-LiZf3XdY.js} +1 -1
  68. package/core/built/admin/assets/{data-list-16rkJYYi.js → data-list-CupF7bjM.js} +1 -1
  69. package/core/built/admin/assets/{date-range-select-CTlyo8zW.js → date-range-select-dXc-GsVe.js} +1 -1
  70. package/core/built/admin/assets/{disabled-sources-indicator-qs2GfLvl.js → disabled-sources-indicator-DYAXW-6V.js} +1 -1
  71. package/core/built/admin/assets/dist-BUKmD51u.js +1 -0
  72. package/core/built/admin/assets/{dist-2Tnz2Yfq.js → dist-CNlMj7ML.js} +1 -1
  73. package/core/built/admin/assets/{dist-DGzPJ_bO.js → dist-Cb5RSHSg.js} +1 -1
  74. package/core/built/admin/assets/{dist-P_ohUlTi.js → dist-CqO2lWS0.js} +1 -1
  75. package/core/built/admin/assets/{domain-CPNXKVOV.js → domain-B5ogXUBa.js} +1 -1
  76. package/core/built/admin/assets/{dropzone-BmCXnw6J.js → dropzone-BnFG7dDP.js} +1 -1
  77. package/core/built/admin/assets/{edit-profile-SGrJve5M.js → edit-profile-B07jCZy9.js} +1 -1
  78. package/core/built/admin/assets/editor-DDYSOXGN.js +8 -0
  79. package/core/built/admin/assets/{empty-indicator-oX1u1Pkn.js → empty-indicator-WB1WgT9h.js} +1 -1
  80. package/core/built/admin/assets/{explore-CUYLv9G9.js → explore-Ca1y8HW_.js} +1 -1
  81. package/core/built/admin/assets/{feed-CPdJMttL.js → feed-x4iFMBP4.js} +1 -1
  82. package/core/built/admin/assets/field-ChCAabZg.js +1 -0
  83. package/core/built/admin/assets/filter-sources-LdtH1e-T.js +1 -0
  84. package/core/built/admin/assets/{filters-bXkkWro8.js → filters-CLehPo_D.js} +1 -1
  85. package/core/built/admin/assets/filters-CjVKRP8U.js +1 -0
  86. package/core/built/admin/assets/{ghost-05f9a7182ed56903dba05878a5b8422b.js → ghost-7f01131115186d1af84ead76e79646dd.js} +275 -255
  87. package/core/built/admin/assets/{ghost-3063eae29e6c9cc6d6e7131928a27da0.css → ghost-d38c30b866e59f2e06a357b5eb844fe5.css} +1 -1
  88. package/core/built/admin/assets/{ghost-dark-c8d0ab7f515b2010c173e23e9b32ea8a.css → ghost-dark-1bffd5099ba7a749303133efacf8480b.css} +1 -1
  89. package/core/built/admin/assets/gift-link-modal-Buqh2vXh.js +1 -0
  90. package/core/built/admin/assets/{gift-links-nBxJjZiQ.js → gift-links-CsUhefWG.js} +1 -1
  91. package/core/built/admin/assets/growth-DajOBdN0.js +1 -0
  92. package/core/built/admin/assets/growth-Dy2UOSay.js +3 -0
  93. package/core/built/admin/assets/{hooks-BApwwBE0.js → hooks-CixO01lL.js} +1 -1
  94. package/core/built/admin/assets/{inbox-Xx1CdnfO.js → inbox-BUOLDoji.js} +13 -13
  95. package/core/built/admin/assets/index-08n05-rc.js +67 -0
  96. package/core/built/admin/assets/index-DgUzgFvS.css +1 -0
  97. package/core/built/admin/assets/{inline-EMAm3okk.js → inline-Cw-gigDW.js} +1 -1
  98. package/core/built/admin/assets/input-group-uSK3XT7b.js +1 -0
  99. package/core/built/admin/assets/{input-surface-BpAjZXY5.js → input-surface-BdOe4KzO.js} +1 -1
  100. package/core/built/admin/assets/koenig-lexical/koenig-lexical.js +9132 -9087
  101. package/core/built/admin/assets/koenig-lexical/koenig-lexical.umd.js +121 -121
  102. package/core/built/admin/assets/koenig-lexical/style.css +1 -1
  103. package/core/built/admin/assets/{koenig-lexical-B61IALf5.js → koenig-lexical-BFc4yFed.js} +82 -82
  104. package/core/built/admin/assets/{kpi-card-DCdlyaSS.js → kpi-card-BN-E6WYX.js} +1 -1
  105. package/core/built/admin/assets/{kpi-card-BlZaG1Lo.js → kpi-card-CCt-qeWD.js} +1 -1
  106. package/core/built/admin/assets/{kpi-helpers-DpAuS1Nb.js → kpi-helpers-DlxTbTs4.js} +1 -1
  107. package/core/built/admin/assets/{kpi-tabs-Dme44bQS.js → kpi-tabs-BlcUttpn.js} +1 -1
  108. package/core/built/admin/assets/{label-dSs5aepE.js → label-AVrxwmsz.js} +1 -1
  109. package/core/built/admin/assets/label-picker-DVnpu_IF.js +1 -0
  110. package/core/built/admin/assets/labels-DAIqKed7.js +1 -0
  111. package/core/built/admin/assets/layout-B0YLh7R9.js +1 -0
  112. package/core/built/admin/assets/link-helpers-D9LsDOkG.js +1 -0
  113. package/core/built/admin/assets/{list-page-B6ANtzkd.js → list-page-CdsnQR9N.js} +1 -1
  114. package/core/built/admin/assets/member-custom-fields-C8NpZYza.js +1 -0
  115. package/core/built/admin/assets/member-detail-CIVAdKXf.js +1 -0
  116. package/core/built/admin/assets/members-oLPiSkXD.js +10 -0
  117. package/core/built/admin/assets/modals-CB7v-k3T.js +31 -0
  118. package/core/built/admin/assets/moderation-CQ3urTFE.js +1 -0
  119. package/core/built/admin/assets/newsletter-rbeDX9Wk.js +1 -0
  120. package/core/built/admin/assets/{newsletters-D1jbtV-h.js → newsletters-BXJbJgA-.js} +1 -1
  121. package/core/built/admin/assets/newsletters-vt3Ni1Ov.js +1 -0
  122. package/core/built/admin/assets/note-Dk877BTv.js +1 -0
  123. package/core/built/admin/assets/notifications-CPlLjPxO.js +1 -0
  124. package/core/built/admin/assets/{offers-CrY6nSRR.js → offers-DEniVRD5.js} +1 -1
  125. package/core/built/admin/assets/onboarding-route-DdStOtjy.js +1 -0
  126. package/core/built/admin/assets/overview--4V-kx9c.js +1 -0
  127. package/core/built/admin/assets/overview-DSLv30oz.js +1 -0
  128. package/core/built/admin/assets/{page-header-op0eN36I.js → page-header-pp4dWmOS.js} +1 -1
  129. package/core/built/admin/assets/{pages-BbNWFcGj.js → pages-B4goRPUn.js} +1 -1
  130. package/core/built/admin/assets/{post-analytics-WwRDEnrV.js → post-analytics-BMJrI81R.js} +1 -1
  131. package/core/built/admin/assets/post-analytics-header-pU2m9ZuV.js +1 -0
  132. package/core/built/admin/assets/post-analytics-provider-Btml5vJI.js +1 -0
  133. package/core/built/admin/assets/{post-share-modal-Dx-MEjhI.js → post-share-modal-AcUZkbfL.js} +1 -1
  134. package/core/built/admin/assets/{posts-EWpZIjkr.js → posts-DhXE7b6r.js} +1 -1
  135. package/core/built/admin/assets/preferences-Ru4CRPHC.js +5 -0
  136. package/core/built/admin/assets/profile-BNb1oj7D.js +1 -0
  137. package/core/built/admin/assets/{referrers-BCfFYSHc.js → referrers-pmJT4k3-.js} +1 -1
  138. package/core/built/admin/assets/{router-provider-C1R4AZ2o.js → router-provider-BmEZf6c6.js} +1 -1
  139. package/core/built/admin/assets/select-B0HteHrp.js +1 -0
  140. package/core/built/admin/assets/settings-B1C9Q3wo.js +4 -0
  141. package/core/built/admin/assets/{settings-H6HxFgfI.js → settings-BGgk7oCI.js} +1 -1
  142. package/core/built/admin/assets/{shade-app-B3heOAfz-CGXQwWeO.js → shade-app-B3heOAfz-BhQXxNkT.js} +1 -1
  143. package/core/built/admin/assets/{share-modal-BJCadJyp.js → share-modal-BeuydaBf.js} +1 -1
  144. package/core/built/admin/assets/social-urls-DA2lXrhW.js +8 -0
  145. package/core/built/admin/assets/{sort-button-DyYlfYp7.js → sort-button-Df4t2AL8.js} +1 -1
  146. package/core/built/admin/assets/{source-icon-_ubRDbg_.js → source-icon-ajoaKb41.js} +1 -1
  147. package/core/built/admin/assets/{stack-xiN0eQ6Z.js → stack-CIfOAAVf.js} +1 -1
  148. package/core/built/admin/assets/{stats-BQZMGMvn.js → stats-C_lfxZAw.js} +1 -1
  149. package/core/built/admin/assets/{stats-view-Doei-qCX.js → stats-view-Cxot1iD8.js} +1 -1
  150. package/core/built/admin/assets/{step-1-IKiLBPoC.js → step-1-ogAA4lDr.js} +1 -1
  151. package/core/built/admin/assets/{step-2-jJ41PsDN.js → step-2-C5Xini-7.js} +1 -1
  152. package/core/built/admin/assets/{step-3-CaqFAEqf.js → step-3-CZv1FV81.js} +2 -2
  153. package/core/built/admin/assets/{switch-t-WyENU2.js → switch-CyQcwMAv.js} +1 -1
  154. package/core/built/admin/assets/{table-DxUL78jU.js → table-DFj1wiYv.js} +1 -1
  155. package/core/built/admin/assets/tabs-D4y1gqUd.js +1 -0
  156. package/core/built/admin/assets/{tags-CNujGwkT.js → tags-Dmft-Y4O.js} +1 -1
  157. package/core/built/admin/assets/{text-DwtjwGkh.js → text-DU9Hv33_.js} +1 -1
  158. package/core/built/admin/assets/textarea-d9qEkMOP.js +1 -0
  159. package/core/built/admin/assets/{tiers-BgYLWRJv.js → tiers-DYsa4DCo.js} +1 -1
  160. package/core/built/admin/assets/{toggle-group-BeEpQ69p.js → toggle-group-R2QMFvXW.js} +1 -1
  161. package/core/built/admin/assets/{topic-filter-Bb5MOD-1.js → topic-filter-DGDxDalo.js} +1 -1
  162. package/core/built/admin/assets/{use-analytics-data-Cds6XDjg.js → use-analytics-data-C76v4LIp.js} +1 -1
  163. package/core/built/admin/assets/use-filter-params-WrdhAISv.js +1 -0
  164. package/core/built/admin/assets/{use-growth-stats-D8uf98-G.js → use-growth-stats-aNXJOmQr.js} +1 -1
  165. package/core/built/admin/assets/{use-post-referrers-CRtgyuXB.js → use-post-referrers-CG0SZioj.js} +1 -1
  166. package/core/built/admin/assets/{use-reply-chain-data-DbKYX50v.js → use-reply-chain-data-RLdPKRmS.js} +1 -1
  167. package/core/built/admin/assets/{use-simple-pagination-DC01kpo0.js → use-simple-pagination-DgcF69KI.js} +1 -1
  168. package/core/built/admin/assets/{use-tinybird-query-C7bQRJWw.js → use-tinybird-query-C1R_Kbmy.js} +1 -1
  169. package/core/built/admin/assets/{use-tinybird-token-Az4tam7_.js → use-tinybird-token-C9fPjVM_.js} +1 -1
  170. package/core/built/admin/assets/{users-CcSCQFcQ.js → users-Dkv8NQZe.js} +1 -1
  171. package/core/built/admin/assets/{virtual-list-Dhuh9fGx.js → virtual-list-BmTUzeoF.js} +1 -1
  172. package/core/built/admin/assets/web-BqW0b0_C.js +1 -0
  173. package/core/built/admin/assets/web-Cy5GKMWM.js +2 -0
  174. package/core/built/admin/index.html +18 -18
  175. package/core/frontend/services/data/fetch-data.js +10 -5
  176. package/core/frontend/services/proxy.js +7 -8
  177. package/core/frontend/services/routing/api-adapter.js +115 -0
  178. package/core/frontend/services/routing/api-adapter.ts +157 -0
  179. package/core/frontend/services/routing/collection-router.js +5 -3
  180. package/core/frontend/services/routing/controllers/index.js +1 -1
  181. package/core/frontend/services/routing/controllers/rss.js +26 -31
  182. package/core/frontend/services/routing/controllers/rss.ts +66 -0
  183. package/core/frontend/services/routing/controllers/static.js +8 -7
  184. package/core/frontend/services/routing/parent-router.js +17 -4
  185. package/core/frontend/services/routing/permalink-adapter.js +26 -0
  186. package/core/frontend/services/routing/permalink-adapter.ts +23 -0
  187. package/core/frontend/services/routing/registry.js +59 -33
  188. package/core/frontend/services/routing/router-manager.js +14 -18
  189. package/core/frontend/services/routing/static-routes-router.js +11 -13
  190. package/core/frontend/services/routing/taxonomy-router.js +6 -2
  191. package/core/server/api/endpoints/automation-action-links.js +68 -0
  192. package/core/server/api/endpoints/automation-action-links.ts +46 -0
  193. package/core/server/api/endpoints/index.js +4 -0
  194. package/core/server/api/endpoints/settings.js +0 -2
  195. package/core/server/api/endpoints/utils/serializers/output/utils/url.js +11 -1
  196. package/core/server/data/migrations/versions/6.55/2026-07-27-11-25-44-add-to-hash-to-redirects.js +51 -0
  197. package/core/server/data/migrations/versions/6.55/2026-07-28-12-08-00-remove-routes-hash-from-settings.js +5 -0
  198. package/core/server/data/migrations/versions/6.56/2026-08-03-19-27-51-add-automation-run-step-id-to-automated-email-recipients.js +8 -0
  199. package/core/server/data/migrations/versions/6.56/2026-08-04-14-42-39-restrict-notification-add-permission-to-administrators.js +12 -0
  200. package/core/server/data/schema/commands.js +5 -0
  201. package/core/server/data/schema/default-settings/default-settings.json +0 -4
  202. package/core/server/data/schema/fixtures/fixtures.json +2 -2
  203. package/core/server/data/schema/schema.js +6 -1
  204. package/core/server/models/post.js +10 -1
  205. package/core/server/services/automations/automations-api.js +15 -0
  206. package/core/server/services/automations/automations-api.ts +19 -0
  207. package/core/server/services/automations/automations-repository.ts +20 -0
  208. package/core/server/services/automations/database-automations-repository.js +132 -30
  209. package/core/server/services/automations/database-automations-repository.ts +141 -12
  210. package/core/server/services/automations/database-date.js +10 -0
  211. package/core/server/services/automations/database-date.ts +5 -0
  212. package/core/server/services/automations/get-scheduler-idempotency-key.js +13 -0
  213. package/core/server/services/automations/get-scheduler-idempotency-key.ts +8 -0
  214. package/core/server/services/automations/poll.js +7 -2
  215. package/core/server/services/automations/poll.ts +10 -2
  216. package/core/server/services/automations/scheduler-poll-time.js +40 -0
  217. package/core/server/services/automations/scheduler-poll-time.ts +38 -0
  218. package/core/server/services/automations/service.js +19 -3
  219. package/core/server/services/automations/service.ts +20 -3
  220. package/core/server/services/comments/comments-service-emails.js +10 -7
  221. package/core/server/services/comments/comments-service.js +9 -4
  222. package/core/server/services/email-rendering/partials/email-wrapper.hbs +3 -1
  223. package/core/server/services/email-service/email-service-wrapper.js +6 -1
  224. package/core/server/services/email-service/email-templates/partials/styles.hbs +8 -0
  225. package/core/server/services/email-service/newsletter-email-event-storage.js +41 -10
  226. package/core/server/services/gift-links/CONTEXT.md +9 -0
  227. package/core/server/services/gifts/CONTEXT.md +55 -0
  228. package/core/server/services/gifts/README.md +28 -0
  229. package/core/server/services/gifts/gift-bookshelf-repository.js +65 -45
  230. package/core/server/services/gifts/gift-bookshelf-repository.ts +203 -74
  231. package/core/server/services/gifts/gift-checkout-adapter.js +59 -0
  232. package/core/server/services/gifts/gift-checkout-offer.js +74 -0
  233. package/core/server/services/gifts/gift-checkout-offer.ts +127 -0
  234. package/core/server/services/gifts/gift-codec.js +63 -0
  235. package/core/server/services/gifts/gift-codec.ts +62 -0
  236. package/core/server/services/gifts/gift-controller.js +6 -31
  237. package/core/server/services/gifts/gift-controller.ts +6 -69
  238. package/core/server/services/gifts/gift-email-service.ts +3 -2
  239. package/core/server/services/gifts/gift-schema.js +34 -0
  240. package/core/server/services/gifts/gift-schema.ts +39 -0
  241. package/core/server/services/gifts/gift-service-wrapper.js +13 -11
  242. package/core/server/services/gifts/gift-service.js +265 -52
  243. package/core/server/services/gifts/gift-service.ts +431 -97
  244. package/core/server/services/gifts/gift.ts +16 -39
  245. package/core/server/services/lib/dynamic-redirect-manager.js +12 -3
  246. package/core/server/services/link-redirection/link-redirect-repository.js +41 -4
  247. package/core/server/services/link-redirection/link-redirect.js +2 -0
  248. package/core/server/services/link-redirection/link-redirect.ts +3 -1
  249. package/core/server/services/link-redirection/link-redirects-service.js +38 -2
  250. package/core/server/services/link-tracking/click-event.js +3 -0
  251. package/core/server/services/link-tracking/index.js +38 -6
  252. package/core/server/services/link-tracking/link-click-repository.js +35 -9
  253. package/core/server/services/link-tracking/link-click-tracking-service.js +54 -4
  254. package/core/server/services/member-attribution/outbound-link-tagger.js +3 -1
  255. package/core/server/services/member-welcome-emails/member-welcome-email-renderer.js +20 -4
  256. package/core/server/services/member-welcome-emails/service.js +15 -3
  257. package/core/server/services/members/api.js +1 -2
  258. package/core/server/services/members/import-export/csv/formula.js +16 -0
  259. package/core/server/services/members/import-export/csv/formula.ts +14 -0
  260. package/core/server/services/members/import-export/csv/index.js +3 -1
  261. package/core/server/services/members/import-export/csv/index.ts +1 -0
  262. package/core/server/services/members/import-export/import/completion-email.js +22 -3
  263. package/core/server/services/members/import-export/import/completion-email.ts +29 -7
  264. package/core/server/services/members/import-export/import/importer.js +25 -4
  265. package/core/server/services/members/import-export/import/importer.ts +44 -5
  266. package/core/server/services/members/import-export/index.js +18 -1
  267. package/core/server/services/members/import-export/index.ts +32 -3
  268. package/core/server/services/members/members-api/controllers/router-controller.js +38 -36
  269. package/core/server/services/members/members-api/members-api.js +13 -7
  270. package/core/server/services/members/members-api/repositories/event-repository.js +4 -94
  271. package/core/server/services/members/members-api/services/member-bread-service.js +3 -3
  272. package/core/server/services/members/members-api/services/payments-service.js +6 -68
  273. package/core/server/services/members/service.js +8 -1
  274. package/core/server/services/members-custom-fields/schema.js +8 -1
  275. package/core/server/services/members-custom-fields/schema.ts +8 -0
  276. package/core/server/services/members-custom-fields/values-service.js +136 -8
  277. package/core/server/services/members-custom-fields/values-service.ts +152 -10
  278. package/core/server/services/mentions/service.js +5 -1
  279. package/core/server/services/oembed/oembed-service.js +44 -3
  280. package/core/server/services/posts/posts-service.js +11 -0
  281. package/core/server/services/route-settings/dynamic-routing-service.js +21 -52
  282. package/core/server/services/route-settings/index.js +1 -16
  283. package/core/server/services/route-settings/route-settings-parser.js +25 -25
  284. package/core/server/services/route-settings/route-settings-parser.ts +27 -27
  285. package/core/server/services/route-settings/validation-errors.js +68 -4
  286. package/core/server/services/route-settings/validation-errors.ts +79 -4
  287. package/core/server/services/settings/settings-service.js +0 -18
  288. package/core/server/services/stats/posts-stats-service.js +27 -23
  289. package/core/server/services/stripe/services/webhook/charge-refunded-event-service.js +1 -1
  290. package/core/server/services/stripe/services/webhook/checkout-session-event-service.js +16 -4
  291. package/core/server/services/stripe/stripe-api.js +10 -0
  292. package/core/server/services/url/url-service-facade.js +1 -1
  293. package/core/server/services/url/url-service-facade.ts +8 -1
  294. package/core/server/web/api/endpoints/admin/routes.js +1 -0
  295. package/core/server/web/gift-preview/controller.js +10 -25
  296. package/core/shared/labs.js +2 -2
  297. package/package.json +8 -7
  298. package/pnpm-lock.yaml +28 -18
  299. package/pnpm-workspace.yaml +6 -4
  300. package/components/tryghost-kg-default-nodes-2.1.3.tgz +0 -0
  301. package/core/built/admin/assets/Growth-BTP1LthE.js +0 -3
  302. package/core/built/admin/assets/Newsletters-CSEX0myc.js +0 -1
  303. package/core/built/admin/assets/Overview-BjACEsJF.js +0 -1
  304. package/core/built/admin/assets/Web-CxxxjCcv.js +0 -2
  305. package/core/built/admin/assets/activitypub/layout-CziktJXo.js +0 -1
  306. package/core/built/admin/assets/audience-X_XsZivI.js +0 -1
  307. package/core/built/admin/assets/chunk.524.8365cd9280405c0f6216.js +0 -25
  308. package/core/built/admin/assets/chunk.582.61d078937e6102769f2c.js +0 -27
  309. package/core/built/admin/assets/comments-CvyXxOyN.js +0 -1
  310. package/core/built/admin/assets/constants-c-JNF9c2.js +0 -1
  311. package/core/built/admin/assets/dist-CjY-f5Z1.js +0 -1
  312. package/core/built/admin/assets/editor-BOG2YPUm.js +0 -8
  313. package/core/built/admin/assets/field-D0XISCK2.js +0 -1
  314. package/core/built/admin/assets/filter-sources-Bieps75g.js +0 -1
  315. package/core/built/admin/assets/filters-CuB-A7ma.js +0 -1
  316. package/core/built/admin/assets/gift-link-modal-su8MsRdg.js +0 -1
  317. package/core/built/admin/assets/growth-Ba6S5FQX.js +0 -1
  318. package/core/built/admin/assets/index-D8PbzuT9.css +0 -1
  319. package/core/built/admin/assets/index-ukW_0Uie.js +0 -67
  320. package/core/built/admin/assets/input-group-CGOXcy4l.js +0 -1
  321. package/core/built/admin/assets/label-picker-Uq91atex.js +0 -1
  322. package/core/built/admin/assets/labels-Cyayfd-e.js +0 -1
  323. package/core/built/admin/assets/layout-WUcz6tu1.js +0 -1
  324. package/core/built/admin/assets/link-helpers-tOlxmuH5.js +0 -1
  325. package/core/built/admin/assets/member-custom-fields-NCzrMuba.js +0 -1
  326. package/core/built/admin/assets/member-detail-DPWDtYnN.js +0 -1
  327. package/core/built/admin/assets/members-B9rO7qr4.js +0 -10
  328. package/core/built/admin/assets/modals-CxRaan1J.js +0 -31
  329. package/core/built/admin/assets/moderation-D8zon60E.js +0 -1
  330. package/core/built/admin/assets/multi-select-combobox-1wYm3nle.js +0 -1
  331. package/core/built/admin/assets/newsletter-DbYMRxRS.js +0 -1
  332. package/core/built/admin/assets/note-PiWPnnrh.js +0 -1
  333. package/core/built/admin/assets/notifications-DEgqqB9G.js +0 -1
  334. package/core/built/admin/assets/nql-BsgFMd93.js +0 -1
  335. package/core/built/admin/assets/onboarding-route-BzF6HeVn.js +0 -1
  336. package/core/built/admin/assets/overview-DixLeIqY.js +0 -1
  337. package/core/built/admin/assets/post-analytics-header-DsMoLx0R.js +0 -1
  338. package/core/built/admin/assets/post-analytics-provider-BJCPLMIb.js +0 -1
  339. package/core/built/admin/assets/preferences-D7-j6A4C.js +0 -5
  340. package/core/built/admin/assets/profile-Dl69QPp0.js +0 -1
  341. package/core/built/admin/assets/select-C1qDZnW4.js +0 -1
  342. package/core/built/admin/assets/settings-BbmC_Iqq.js +0 -4
  343. package/core/built/admin/assets/social-urls-BPL1nADp.js +0 -8
  344. package/core/built/admin/assets/tabs-BQcJQ1ke.js +0 -1
  345. package/core/built/admin/assets/textarea-Bjccvh_E.js +0 -1
  346. package/core/built/admin/assets/use-filter-params-DjXkhO54.js +0 -1
  347. package/core/built/admin/assets/web-LtHNiNBV.js +0 -1
  348. package/core/server/services/gifts/gift-repository.js +0 -2
  349. package/core/server/services/gifts/gift-repository.ts +0 -29
  350. package/core/server/services/route-settings/activation-bridge.js +0 -164
  351. package/core/server/services/route-settings/activation-bridge.ts +0 -210
  352. package/core/server/services/route-settings/default-settings-manager.js +0 -63
  353. package/core/server/services/route-settings/route-settings.js +0 -170
  354. package/core/server/services/route-settings/settings-loader.js +0 -60
  355. package/core/server/services/route-settings/settings-path-manager.js +0 -47
  356. package/core/server/services/route-settings/validate.js +0 -440
@@ -1,7 +1,7 @@
1
1
  import {GIFT_EXPIRY_DAYS} from './constants';
2
+ import type {GiftCadence, GiftData, GiftStatus} from './gift-schema';
2
3
 
3
- export type GiftStatus = 'purchased' | 'redeemed' | 'consumed' | 'expired' | 'refunded';
4
- export type GiftCadence = 'month' | 'year';
4
+ export type {GiftCadence, GiftStatus} from './gift-schema';
5
5
 
6
6
  export type RedeemableCheckFailureReason = 'redeemed' | 'consumed' | 'expired' | 'refunded' | 'paid-member';
7
7
  export type RedeemableCheckResult =
@@ -13,43 +13,20 @@ export type ReassignableCheckResult =
13
13
  | {reassignable: true}
14
14
  | {reassignable: false; reason: ReassignableCheckFailureReason};
15
15
 
16
- interface GiftData {
17
- token: string;
18
- buyerEmail: string;
19
- buyerMemberId: string | null;
20
- redeemerMemberId: string | null;
21
- tierId: string;
22
- cadence: GiftCadence;
23
- duration: number;
24
- currency: string;
25
- amount: number;
26
- stripeCheckoutSessionId: string;
27
- stripePaymentIntentId: string;
28
- consumesAt: Date | null;
29
- expiresAt: Date;
30
- status: GiftStatus;
31
- purchasedAt: Date;
32
- redeemedAt: Date | null;
33
- consumedAt: Date | null;
34
- expiredAt: Date | null;
35
- refundedAt: Date | null;
36
- consumesSoonReminderSentAt: Date | null;
37
- }
38
-
39
- export interface GiftFromPurchaseData {
40
- token: string;
41
- buyerEmail: string;
42
- buyerMemberId: string | null;
43
- tierId: string;
44
- cadence: GiftCadence;
45
- duration: number;
46
- currency: string;
47
- amount: number;
48
- stripeCheckoutSessionId: string;
49
- stripePaymentIntentId: string;
50
- }
51
-
52
- export class Gift {
16
+ export type GiftFromPurchaseData = Pick<GiftData,
17
+ | 'token'
18
+ | 'buyerEmail'
19
+ | 'buyerMemberId'
20
+ | 'tierId'
21
+ | 'cadence'
22
+ | 'duration'
23
+ | 'currency'
24
+ | 'amount'
25
+ | 'stripeCheckoutSessionId'
26
+ | 'stripePaymentIntentId'
27
+ >;
28
+
29
+ export class Gift implements GiftData {
53
30
  token: string;
54
31
  buyerEmail: string;
55
32
  buyerMemberId: string | null;
@@ -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,13 @@ 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
+ try {
36
+ return await this.#Member.findOne({uuid}, options);
37
+ } catch (error) {
38
+ this.memoizedFindOne.cache.delete(uuid);
39
+ throw error;
40
+ }
36
41
  });
37
42
  }
38
43
 
@@ -54,15 +59,18 @@ module.exports = class LinkClickRepository {
54
59
 
55
60
  /**
56
61
  * @param {LinkClick} linkClick
57
- * @returns {Promise<void>}
62
+ * @param {object} [options]
63
+ * @param {object} [options.transacting]
64
+ * @returns {Promise<string | undefined>}
58
65
  */
59
- async save(linkClick) {
66
+ async save(linkClick, options = {}) {
60
67
  let member;
68
+ const memberLookupOptions = options.transacting ? {transacting: options.transacting} : undefined;
61
69
 
62
70
  if (config && config.get('linkClickTrackingCacheMemberUuid')) {
63
- member = await this.memoizedFindOne(linkClick.member_uuid);
71
+ member = await this.memoizedFindOne(linkClick.member_uuid, memberLookupOptions);
64
72
  } else {
65
- member = await this.#Member.findOne({uuid: linkClick.member_uuid});
73
+ member = await this.#Member.findOne({uuid: linkClick.member_uuid}, memberLookupOptions);
66
74
  }
67
75
 
68
76
  if (!member) {
@@ -76,11 +84,29 @@ module.exports = class LinkClickRepository {
76
84
  // Only store the pathname (no support for variable query strings)
77
85
  redirect_id: linkClick.link_id.toHexString(),
78
86
  member_id: member.id
79
- }, {});
87
+ }, options);
80
88
 
81
89
  linkClick.event_id = ObjectID.createFromHexString(model.id);
90
+ const timestamp = linkClick.timestamp ?? new Date();
91
+
92
+ const event = this.#MemberLinkClickEvent.create({
93
+ memberId: member.id,
94
+ memberLastSeenAt: member.get('last_seen_at'),
95
+ linkId: linkClick.link_id.toHexString()
96
+ }, timestamp);
97
+
98
+ if (options.transacting) {
99
+ options.transacting.executionPromise.then(() => {
100
+ try {
101
+ this.#DomainEvents.dispatch(event);
102
+ } catch (error) {
103
+ sentry.captureException(error);
104
+ }
105
+ }, () => undefined);
106
+ } else {
107
+ this.#DomainEvents.dispatch(event);
108
+ }
82
109
 
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()));
110
+ return member.id;
85
111
  }
86
112
  };
@@ -4,13 +4,14 @@ const PostLink = require('./post-link');
4
4
  const ObjectID = require('bson-objectid').default;
5
5
  const errors = require('@tryghost/errors');
6
6
  const nql = require('@tryghost/nql');
7
+ const {escapeNqlString} = require('@tryghost/nql-string');
7
8
  const _ = require('lodash');
8
9
  const tpl = require('@tryghost/tpl');
9
10
  const moment = require('moment');
10
11
 
11
12
  /**
12
13
  * @typedef {object} ILinkClickRepository
13
- * @prop {(event: LinkClick) => Promise<void>} save
14
+ * @prop {(event: LinkClick, options?: {transacting?: object}) => Promise<string | undefined>} save
14
15
  * @prop {({filter: string}) => Promise<LinkClick[]>} getAll
15
16
  */
16
17
 
@@ -61,6 +62,10 @@ class LinkClickTrackingService {
61
62
  #LinkRedirect;
62
63
  /** @type {Object} */
63
64
  #urlUtils;
65
+ /** @type {object} */
66
+ #automationsApi;
67
+ /** @type {(callback: (transacting: object) => Promise<void>) => Promise<void>} */
68
+ #runInTransaction;
64
69
 
65
70
  /**
66
71
  * @param {object} deps
@@ -69,6 +74,8 @@ class LinkClickTrackingService {
69
74
  * @param {IPostLinkRepository} deps.postLinkRepository
70
75
  * @param {DomainEvents} deps.DomainEvents
71
76
  * @param {urlUtils} deps.urlUtils
77
+ * @param {object} deps.automationsApi
78
+ * @param {(callback: (transacting: object) => Promise<void>) => Promise<void>} deps.runInTransaction
72
79
  */
73
80
  constructor(deps) {
74
81
  this.#linkClickRepository = deps.linkClickRepository;
@@ -76,6 +83,8 @@ class LinkClickTrackingService {
76
83
  this.#postLinkRepository = deps.postLinkRepository;
77
84
  this.#DomainEvents = deps.DomainEvents;
78
85
  this.#urlUtils = deps.urlUtils;
86
+ this.#automationsApi = deps.automationsApi;
87
+ this.#runInTransaction = deps.runInTransaction;
79
88
  }
80
89
 
81
90
  async init() {
@@ -166,7 +175,11 @@ class LinkClickTrackingService {
166
175
 
167
176
  // manages transformation of current url to relative for comparision
168
177
  const transformedOldUrl = this.#urlUtils.absoluteToTransformReady(redirectUrl.href);
169
- const filterQuery = `post_id:'${postId}'+to:'${transformedOldUrl}'`;
178
+ // the url is re-serialised into NQL here, so it needs escaping again -
179
+ // quotes are legal in a URL and would otherwise break the filter.
180
+ // postId is String()d because nql parses an unquoted `post_id:123` to a
181
+ // number, and escapeNqlString operates on strings
182
+ const filterQuery = `post_id:${escapeNqlString(String(postId))}+to:${escapeNqlString(transformedOldUrl)}`;
170
183
 
171
184
  const updatedFilterOptions = {
172
185
  ...filterOptions,
@@ -233,6 +246,22 @@ class LinkClickTrackingService {
233
246
  return url;
234
247
  }
235
248
 
249
+ /**
250
+ * Add first-party tracking to a URL in an automation email
251
+ * @param {Readonly<URL>} url
252
+ * @param {string} automationActionRevisionId
253
+ * @param {string} automationRunStepId
254
+ * @param {string} memberUuid
255
+ * @return {Promise<URL>}
256
+ */
257
+ async addAutomationTrackingToUrl(url, automationActionRevisionId, automationRunStepId, memberUuid) {
258
+ const redirect = await this.#linkRedirectService.getOrAddAutomationRedirect(automationActionRevisionId, url);
259
+ const trackedUrl = new URL(redirect.from.href);
260
+ trackedUrl.searchParams.set('m', memberUuid);
261
+ trackedUrl.searchParams.set('step', automationRunStepId);
262
+ return trackedUrl;
263
+ }
264
+
236
265
  subscribe() {
237
266
  this.#DomainEvents.subscribe(RedirectEvent, async (event) => {
238
267
  const uuid = event.data.url.searchParams.get('m');
@@ -242,9 +271,30 @@ class LinkClickTrackingService {
242
271
 
243
272
  const click = new LinkClick({
244
273
  member_uuid: uuid,
245
- link_id: event.data.link.link_id
274
+ link_id: event.data.link.link_id,
275
+ timestamp: event.timestamp
276
+ });
277
+
278
+ const automationActionRevisionId = event.data.link.automationActionRevisionId;
279
+ const automationRunStepId = event.data.url.searchParams.get('step');
280
+ if (!automationActionRevisionId || !automationRunStepId) {
281
+ await this.#linkClickRepository.save(click);
282
+ return;
283
+ }
284
+
285
+ await this.#runInTransaction(async (transacting) => {
286
+ const memberId = await this.#linkClickRepository.save(click, {transacting});
287
+ if (!memberId) {
288
+ return;
289
+ }
290
+
291
+ await this.#automationsApi.trackEmailClicked({
292
+ automationActionRevisionId,
293
+ automationRunStepId,
294
+ memberId,
295
+ clickedAt: event.timestamp
296
+ }, {transacting});
246
297
  });
247
- await this.#linkClickRepository.save(click);
248
298
  });
249
299
  }
250
300
  }
@@ -11,7 +11,9 @@ const blockedReferrerDomains = [
11
11
  'www.chicagomag.com',
12
12
  'bailii.org',
13
13
  'www.bailii.org',
14
- '*.doubleclick.net'
14
+ '*.doubleclick.net',
15
+ // Substack's client app throws on any non-empty ?ref= value and swaps the post for an error screen
16
+ '*.substack.com'
15
17
  ];
16
18
 
17
19
  const isBlockedReferrerDomain = (domain) => {
@@ -2,11 +2,13 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const lexicalLib = require('../../lib/lexical');
4
4
  const labs = require('../../../shared/labs');
5
+ const urlUtils = require('../../../shared/url-utils').default;
5
6
  const {finalize} = require('../email-rendering/finalize');
6
7
  const errors = require('@tryghost/errors');
7
8
  const {MESSAGES} = require('./constants');
8
9
  const {wrapReplacementStrings} = require('@tryghost/kg-default-nodes').utils.replacementStrings;
9
10
  const linkReplacer = require('../lib/link-replacer');
11
+ const linkTracking = require('../link-tracking');
10
12
  const emailDesign = require('../email-rendering/email-design');
11
13
  const {registerHelpers} = require('../email-service/helpers/register-helpers');
12
14
 
@@ -115,9 +117,12 @@ class MemberWelcomeEmailRenderer {
115
117
  * @param {Object} options.member - Member data (name, email)
116
118
  * @param {Object} options.siteSettings - Site settings (title, url, accentColor)
117
119
  * @param {string} [options.unsubscribeUrl] - When set, the footer shows an "Unsubscribe from these emails" link instead of "Manage your preferences"
120
+ * @param {boolean} [options.trackClicks]
121
+ * @param {string | null} [options.automationActionRevisionId]
122
+ * @param {string | null} [options.automationRunStepId]
118
123
  * @returns {Promise<{html: string, text: string, subject: string}>}
119
124
  */
120
- async render({lexical, subject, designSettings, member, siteSettings, unsubscribeUrl}) {
125
+ async render({lexical, subject, designSettings, member, siteSettings, unsubscribeUrl, trackClicks = false, automationActionRevisionId = null, automationRunStepId = null}) {
121
126
  designSettings = designSettings || {};
122
127
 
123
128
  const design = emailDesign.getEmailDesign({
@@ -138,7 +143,8 @@ class MemberWelcomeEmailRenderer {
138
143
 
139
144
  let content;
140
145
  try {
141
- content = await lexicalLib.render(lexical, {target: 'email', design});
146
+ const absoluteLexical = urlUtils.transformReadyToAbsolute(lexical);
147
+ content = await lexicalLib.render(absoluteLexical, {target: 'email', design});
142
148
  } catch (err) {
143
149
  throw new errors.IncorrectUsageError({
144
150
  message: MESSAGES.INVALID_LEXICAL_STRUCTURE,
@@ -158,8 +164,18 @@ class MemberWelcomeEmailRenderer {
158
164
  const contentWithReplacements = this.#applyReplacements({definitions, text: content, escapeHtml: true});
159
165
  const subjectWithReplacements = this.#applyReplacements({definitions, text: subject, escapeHtml: false});
160
166
 
161
- // Resolve relative links (e.g. #/portal/signup) to absolute URLs using the site URL
162
- const contentWithAbsoluteLinks = await linkReplacer.replace(contentWithReplacements, (url) => {
167
+ // Resolve relative links, including Ghost Portal routes such as #/portal/signup,
168
+ // while preserving document-local anchors such as #section.
169
+ const contentWithAbsoluteLinks = await linkReplacer.replace(contentWithReplacements, async (url, originalPath) => {
170
+ const isDocumentAnchor = originalPath.startsWith('#') && !originalPath.startsWith('#/');
171
+ if (isDocumentAnchor) {
172
+ return originalPath;
173
+ }
174
+ const isTrackable = ['http:', 'https:'].includes(url.protocol);
175
+ if (trackClicks && automationActionRevisionId && automationRunStepId && member.uuid && isTrackable) {
176
+ await linkTracking.init();
177
+ return await linkTracking.service.addAutomationTrackingToUrl(url, automationActionRevisionId, automationRunStepId, member.uuid);
178
+ }
163
179
  return url;
164
180
  }, {base: siteSettings.url});
165
181