ghost 6.55.0 → 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 (280) hide show
  1. package/components/tryghost-custom-field-types-0.0.0.tgz +0 -0
  2. package/components/tryghost-kg-default-cards-10.3.3.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/built/admin/assets/{Combination-CJG7Flp-.js → Combination-BCm9FL4o.js} +1 -1
  9. package/core/built/admin/assets/{PolarAngleAxis-BGjf7sBu.js → PolarAngleAxis-C4qtHy1B.js} +1 -1
  10. package/core/built/admin/assets/{RadialBarChart-zeFf9mBz.js → RadialBarChart-Bh09Stsj.js} +1 -1
  11. package/core/built/admin/assets/{account-migration-sjHLx5dy.js → account-migration-D3O_yVck.js} +1 -1
  12. package/core/built/admin/assets/activitypub/{account-migration-DoYXtnZ8.js → account-migration-D8FbP-Z-.js} +7 -7
  13. package/core/built/admin/assets/activitypub/activitypub.js +6 -6
  14. package/core/built/admin/assets/activitypub/{avatar-B3DEDB1l.js → avatar-11EA1VSs.js} +2 -2
  15. package/core/built/admin/assets/activitypub/{avatar-flipboard-Cqrg0XMx.js → avatar-flipboard-jfbLG-xe.js} +2 -2
  16. package/core/built/admin/assets/activitypub/{bluesky-sharing-Bxeh6MKW.js → bluesky-sharing-f1jTQe2P.js} +16 -16
  17. package/core/built/admin/assets/activitypub/{content-formatters-BpQARh28.js → content-formatters-nmTOPY9M.js} +3 -3
  18. package/core/built/admin/assets/activitypub/{copy-D9UWIAan.js → copy-BCwo9ep3.js} +2 -2
  19. package/core/built/admin/assets/activitypub/{dist-D8sFfdpC.js → dist-BEfX-0aW.js} +12 -12
  20. package/core/built/admin/assets/activitypub/{domain-CWueFW6q.js → domain-AaDlZlrA.js} +9 -9
  21. package/core/built/admin/assets/activitypub/{edit-profile-BGxPU7N5.js → edit-profile-DPi8Tl5M.js} +945 -945
  22. package/core/built/admin/assets/activitypub/{explore-DBK-u0Ch.js → explore-CxTzjILQ.js} +8 -8
  23. package/core/built/admin/assets/activitypub/{feed-Bcir5RQZ.js → feed-DPv5x2MX.js} +33 -33
  24. package/core/built/admin/assets/activitypub/{field-BV-CIT3d.js → field-DbgA8lat.js} +3 -3
  25. package/core/built/admin/assets/activitypub/{hash-BC9Q0N4c.js → hash-BWuZoYkA.js} +2 -2
  26. package/core/built/admin/assets/activitypub/{inbox-DQ4ZnC1m.js → inbox-35VDQ2K2.js} +2 -2
  27. package/core/built/admin/assets/activitypub/{inbox-yC0nfvzl.js → inbox-DSrF_i1O.js} +85 -85
  28. package/core/built/admin/assets/activitypub/{label-DTRS9TDk.js → label-CPjFBbo7.js} +3 -3
  29. package/core/built/admin/assets/activitypub/layout-CTgrezN7.js +1 -0
  30. package/core/built/admin/assets/activitypub/{moderation-Dwl9t0pb.js → moderation-BkDC1XVZ.js} +11 -11
  31. package/core/built/admin/assets/activitypub/{note-B-zKsZ7r.js → note-xEyH4xT-.js} +7 -7
  32. package/core/built/admin/assets/activitypub/{notifications-DjJZD54t.js → notifications-DClaL3ui.js} +9 -9
  33. package/core/built/admin/assets/activitypub/{preferences-CoU8vs6d.js → preferences-DpnOa-4Z.js} +20 -20
  34. package/core/built/admin/assets/activitypub/{profile-BAsaJekx.js → profile-Dvi9mz-6.js} +237 -237
  35. package/core/built/admin/assets/activitypub/{reply-CpdMeAO5.js → reply-Cs9ySd6Z.js} +2 -2
  36. package/core/built/admin/assets/activitypub/{routes-CLH3OVTP.js → routes-DFBeTnfn.js} +1139 -1150
  37. package/core/built/admin/assets/activitypub/{separator-BI1FLsYM.js → separator-C5u_VjzY.js} +3 -3
  38. package/core/built/admin/assets/activitypub/{settings-DA6pNJ-Q.js → settings-CV2fFu6D.js} +13 -13
  39. package/core/built/admin/assets/activitypub/{site-CQD42Pw5.js → site-_TgDd_EM.js} +81 -81
  40. package/core/built/admin/assets/activitypub/{step-1-DIDg4aHb.js → step-1-BfnNL2oF.js} +5 -5
  41. package/core/built/admin/assets/activitypub/{step-2-BfGe8BYt.js → step-2-DLndZkO-.js} +6 -6
  42. package/core/built/admin/assets/activitypub/{step-3-mE9spAqw.js → step-3-A-PZTtRE.js} +10 -10
  43. package/core/built/admin/assets/activitypub/{tabs-Cg0saow1.js → tabs-BUbz7tea.js} +317 -315
  44. package/core/built/admin/assets/activitypub/{topic-filter-DLHBZT93.js → topic-filter-BdX_xYYT.js} +3 -3
  45. package/core/built/admin/assets/activitypub/{use-activity-pub-queries-VzbN99u_.js → use-activity-pub-queries-EwuRemHh.js} +2 -2
  46. package/core/built/admin/assets/activitypub/{use-navigate-with-base-path-CAwXTmqH.js → use-navigate-with-base-path-cy4ACBc4.js} +92 -90
  47. package/core/built/admin/assets/activitypub/{use-reply-chain-data-Br4aOWOh.js → use-reply-chain-data-CyFcGfan.js} +5 -5
  48. package/core/built/admin/assets/activitypub/{x-C8tAnXly.js → x-7zk8mV2p.js} +2 -2
  49. package/core/built/admin/assets/{audience-DjEWNhp3.js → audience-CkBb2LlO.js} +1 -1
  50. package/core/built/admin/assets/{automated-emails-DRy7qY-J.js → automated-emails-6t2bIRWf.js} +1 -1
  51. package/core/built/admin/assets/{automations-Bx3VcFIL.js → automations-cMllU545.js} +1 -1
  52. package/core/built/admin/assets/{bluesky-sharing-ClMd0-Ft.js → bluesky-sharing-DPGpyD-G.js} +1 -1
  53. package/core/built/admin/assets/{box-BWbhXh21.js → box-By_EgGtm.js} +1 -1
  54. package/core/built/admin/assets/{breadcrumb-BD0U2ygg.js → breadcrumb-DIfcF5ow.js} +1 -1
  55. package/core/built/admin/assets/build-CsiH26uS.js +1 -0
  56. package/core/built/admin/assets/{card-DGoDJzrn.js → card-aUHX0hyR.js} +1 -1
  57. package/core/built/admin/assets/{chart-BuRT2Y6p.js → chart-BnXyfH11.js} +1 -1
  58. package/core/built/admin/assets/{chart-helpers-fjQKRVyY.js → chart-helpers-DV0FkdcD.js} +1 -1
  59. package/core/built/admin/assets/{checkbox-CWa28lgO.js → checkbox-BIl077a0.js} +1 -1
  60. package/core/built/admin/assets/{chunk.481.6c7395a49484bb4583aa.js → chunk.332.05991a101d67f732dec3.js} +43 -42
  61. package/core/built/admin/assets/chunk.524.87b52f4fada529df2c4c.js +25 -0
  62. package/core/built/admin/assets/chunk.582.340bcca873e806cbd4bb.js +27 -0
  63. package/core/built/admin/assets/{code-editor-view-CCr0rBWS.js → code-editor-view-C0aWNwdl.js} +1 -1
  64. package/core/built/admin/assets/comments-qwhIjH3c.js +1 -0
  65. package/core/built/admin/assets/{container-BYz2Ed39.js → container-LiZf3XdY.js} +1 -1
  66. package/core/built/admin/assets/{data-list-16rkJYYi.js → data-list-CupF7bjM.js} +1 -1
  67. package/core/built/admin/assets/{date-range-select-BFp58EMk.js → date-range-select-dXc-GsVe.js} +1 -1
  68. package/core/built/admin/assets/{disabled-sources-indicator-CUxMfiPa.js → disabled-sources-indicator-DYAXW-6V.js} +1 -1
  69. package/core/built/admin/assets/dist-BUKmD51u.js +1 -0
  70. package/core/built/admin/assets/{dist-2Tnz2Yfq.js → dist-CNlMj7ML.js} +1 -1
  71. package/core/built/admin/assets/{dist-DGzPJ_bO.js → dist-Cb5RSHSg.js} +1 -1
  72. package/core/built/admin/assets/{dist-P_ohUlTi.js → dist-CqO2lWS0.js} +1 -1
  73. package/core/built/admin/assets/domain-B5ogXUBa.js +1 -0
  74. package/core/built/admin/assets/{dropzone-DlK-K2DQ.js → dropzone-BnFG7dDP.js} +1 -1
  75. package/core/built/admin/assets/{edit-profile-BKT-REJw.js → edit-profile-B07jCZy9.js} +1 -1
  76. package/core/built/admin/assets/editor-DDYSOXGN.js +8 -0
  77. package/core/built/admin/assets/{empty-indicator-oX1u1Pkn.js → empty-indicator-WB1WgT9h.js} +1 -1
  78. package/core/built/admin/assets/{explore-ClUpJZOp.js → explore-Ca1y8HW_.js} +1 -1
  79. package/core/built/admin/assets/{feed-DF92dUMP.js → feed-x4iFMBP4.js} +1 -1
  80. package/core/built/admin/assets/{field-Do7lieBA.js → field-ChCAabZg.js} +1 -1
  81. package/core/built/admin/assets/filter-sources-LdtH1e-T.js +1 -0
  82. package/core/built/admin/assets/filters-CLehPo_D.js +1 -0
  83. package/core/built/admin/assets/filters-CjVKRP8U.js +1 -0
  84. package/core/built/admin/assets/{ghost-c4057474943effc568c5659ccc4909d7.js → ghost-7f01131115186d1af84ead76e79646dd.js} +149 -129
  85. package/core/built/admin/assets/{ghost-a9c788f6f0b1ea6cf0f14ca4b13f4496.css → ghost-d38c30b866e59f2e06a357b5eb844fe5.css} +1 -1
  86. package/core/built/admin/assets/{ghost-dark-db36d795d21c61426837df40af270966.css → ghost-dark-1bffd5099ba7a749303133efacf8480b.css} +1 -1
  87. package/core/built/admin/assets/{gift-link-modal-f9VIMNDv.js → gift-link-modal-Buqh2vXh.js} +1 -1
  88. package/core/built/admin/assets/{growth-B3I6gNLm.js → growth-DajOBdN0.js} +1 -1
  89. package/core/built/admin/assets/{growth-CVw1My4S.js → growth-Dy2UOSay.js} +2 -2
  90. package/core/built/admin/assets/{inbox-CFBSu4Ed.js → inbox-BUOLDoji.js} +12 -12
  91. package/core/built/admin/assets/index-08n05-rc.js +67 -0
  92. package/core/built/admin/assets/index-DgUzgFvS.css +1 -0
  93. package/core/built/admin/assets/{inline-EMAm3okk.js → inline-Cw-gigDW.js} +1 -1
  94. package/core/built/admin/assets/input-group-uSK3XT7b.js +1 -0
  95. package/core/built/admin/assets/{input-surface-BpAjZXY5.js → input-surface-BdOe4KzO.js} +1 -1
  96. package/core/built/admin/assets/koenig-lexical/koenig-lexical.js +9535 -9527
  97. package/core/built/admin/assets/koenig-lexical/koenig-lexical.umd.js +124 -124
  98. package/core/built/admin/assets/{koenig-lexical-f8vHRYvz.js → koenig-lexical-BFc4yFed.js} +86 -86
  99. package/core/built/admin/assets/{kpi-card-DSap9rAy.js → kpi-card-BN-E6WYX.js} +1 -1
  100. package/core/built/admin/assets/{kpi-card-DU0rn9Cj.js → kpi-card-CCt-qeWD.js} +1 -1
  101. package/core/built/admin/assets/{kpi-helpers-Dz33AV6h.js → kpi-helpers-DlxTbTs4.js} +1 -1
  102. package/core/built/admin/assets/{kpi-tabs-BtTTrKvJ.js → kpi-tabs-BlcUttpn.js} +1 -1
  103. package/core/built/admin/assets/{label-DS8Mi6G5.js → label-AVrxwmsz.js} +1 -1
  104. package/core/built/admin/assets/{label-picker-1DvSuLA1.js → label-picker-DVnpu_IF.js} +1 -1
  105. package/core/built/admin/assets/labels-DAIqKed7.js +1 -0
  106. package/core/built/admin/assets/layout-B0YLh7R9.js +1 -0
  107. package/core/built/admin/assets/link-helpers-D9LsDOkG.js +1 -0
  108. package/core/built/admin/assets/{list-page-B6ANtzkd.js → list-page-CdsnQR9N.js} +1 -1
  109. package/core/built/admin/assets/member-custom-fields-C8NpZYza.js +1 -0
  110. package/core/built/admin/assets/member-detail-CIVAdKXf.js +1 -0
  111. package/core/built/admin/assets/{members-CQpVFJu4.js → members-oLPiSkXD.js} +2 -2
  112. package/core/built/admin/assets/modals-CB7v-k3T.js +31 -0
  113. package/core/built/admin/assets/{moderation-DySbgyUS.js → moderation-CQ3urTFE.js} +1 -1
  114. package/core/built/admin/assets/newsletter-rbeDX9Wk.js +1 -0
  115. package/core/built/admin/assets/newsletters-vt3Ni1Ov.js +1 -0
  116. package/core/built/admin/assets/{note-C8kxrz6w.js → note-Dk877BTv.js} +1 -1
  117. package/core/built/admin/assets/{notifications-CgtSfX7t.js → notifications-CPlLjPxO.js} +1 -1
  118. package/core/built/admin/assets/{onboarding-route-4qtziA9d.js → onboarding-route-DdStOtjy.js} +1 -1
  119. package/core/built/admin/assets/{overview-Bf2sbznq.js → overview--4V-kx9c.js} +1 -1
  120. package/core/built/admin/assets/overview-DSLv30oz.js +1 -0
  121. package/core/built/admin/assets/{page-header-B3Wb9Jqv.js → page-header-pp4dWmOS.js} +1 -1
  122. package/core/built/admin/assets/{post-analytics-We-ko5Cb.js → post-analytics-BMJrI81R.js} +1 -1
  123. package/core/built/admin/assets/post-analytics-header-pU2m9ZuV.js +1 -0
  124. package/core/built/admin/assets/{post-analytics-provider-B6UynTGb.js → post-analytics-provider-Btml5vJI.js} +1 -1
  125. package/core/built/admin/assets/{post-share-modal-DFfO93_0.js → post-share-modal-AcUZkbfL.js} +1 -1
  126. package/core/built/admin/assets/preferences-Ru4CRPHC.js +5 -0
  127. package/core/built/admin/assets/profile-BNb1oj7D.js +1 -0
  128. package/core/built/admin/assets/{router-provider-C1R4AZ2o.js → router-provider-BmEZf6c6.js} +1 -1
  129. package/core/built/admin/assets/select-B0HteHrp.js +1 -0
  130. package/core/built/admin/assets/settings-B1C9Q3wo.js +4 -0
  131. package/core/built/admin/assets/{settings-WXNeWyU1.js → settings-BGgk7oCI.js} +1 -1
  132. package/core/built/admin/assets/{shade-app-B3heOAfz-CGXQwWeO.js → shade-app-B3heOAfz-BhQXxNkT.js} +1 -1
  133. package/core/built/admin/assets/{share-modal-DsgW3wbg.js → share-modal-BeuydaBf.js} +1 -1
  134. package/core/built/admin/assets/social-urls-DA2lXrhW.js +8 -0
  135. package/core/built/admin/assets/{sort-button-Bb_FCl7e.js → sort-button-Df4t2AL8.js} +1 -1
  136. package/core/built/admin/assets/{source-icon-BeaRxUn9.js → source-icon-ajoaKb41.js} +1 -1
  137. package/core/built/admin/assets/{stack-xiN0eQ6Z.js → stack-CIfOAAVf.js} +1 -1
  138. package/core/built/admin/assets/{stats-Bjj_kmJ1.js → stats-C_lfxZAw.js} +1 -1
  139. package/core/built/admin/assets/{stats-view-BxPbW6K1.js → stats-view-Cxot1iD8.js} +1 -1
  140. package/core/built/admin/assets/{step-1-B1e3-1F8.js → step-1-ogAA4lDr.js} +1 -1
  141. package/core/built/admin/assets/{step-2-C5WaeBJo.js → step-2-C5Xini-7.js} +1 -1
  142. package/core/built/admin/assets/{step-3-B46iVPwu.js → step-3-CZv1FV81.js} +2 -2
  143. package/core/built/admin/assets/{switch-z5Y6Uzt6.js → switch-CyQcwMAv.js} +1 -1
  144. package/core/built/admin/assets/{table-CB-4_HBJ.js → table-DFj1wiYv.js} +1 -1
  145. package/core/built/admin/assets/{tabs-CCEgKYzq.js → tabs-D4y1gqUd.js} +1 -1
  146. package/core/built/admin/assets/{tags-Cg2VJGmG.js → tags-Dmft-Y4O.js} +1 -1
  147. package/core/built/admin/assets/{text-DwtjwGkh.js → text-DU9Hv33_.js} +1 -1
  148. package/core/built/admin/assets/textarea-d9qEkMOP.js +1 -0
  149. package/core/built/admin/assets/{toggle-group-b3IlxvhV.js → toggle-group-R2QMFvXW.js} +1 -1
  150. package/core/built/admin/assets/{topic-filter-DAFT87tQ.js → topic-filter-DGDxDalo.js} +1 -1
  151. package/core/built/admin/assets/{use-analytics-data-S0FTd43s.js → use-analytics-data-C76v4LIp.js} +1 -1
  152. package/core/built/admin/assets/use-filter-params-WrdhAISv.js +1 -0
  153. package/core/built/admin/assets/{use-growth-stats-CIaiHOVb.js → use-growth-stats-aNXJOmQr.js} +1 -1
  154. package/core/built/admin/assets/{use-post-referrers-Cq2vBPrA.js → use-post-referrers-CG0SZioj.js} +1 -1
  155. package/core/built/admin/assets/{use-reply-chain-data-DBDRtGu-.js → use-reply-chain-data-RLdPKRmS.js} +1 -1
  156. package/core/built/admin/assets/{use-simple-pagination-CjYNxdwe.js → use-simple-pagination-DgcF69KI.js} +1 -1
  157. package/core/built/admin/assets/{use-tinybird-query-falHAtuK.js → use-tinybird-query-C1R_Kbmy.js} +1 -1
  158. package/core/built/admin/assets/{use-tinybird-token-D7-8ND1y.js → use-tinybird-token-C9fPjVM_.js} +1 -1
  159. package/core/built/admin/assets/{virtual-list-CvnC9zF2.js → virtual-list-BmTUzeoF.js} +1 -1
  160. package/core/built/admin/assets/{web-B_5GZRvH.js → web-BqW0b0_C.js} +1 -1
  161. package/core/built/admin/assets/web-Cy5GKMWM.js +2 -0
  162. package/core/built/admin/index.html +16 -16
  163. package/core/frontend/services/data/fetch-data.js +10 -5
  164. package/core/frontend/services/routing/api-adapter.js +115 -0
  165. package/core/frontend/services/routing/api-adapter.ts +157 -0
  166. package/core/frontend/services/routing/collection-router.js +5 -3
  167. package/core/frontend/services/routing/controllers/index.js +1 -1
  168. package/core/frontend/services/routing/controllers/rss.js +26 -31
  169. package/core/frontend/services/routing/controllers/rss.ts +66 -0
  170. package/core/frontend/services/routing/controllers/static.js +8 -7
  171. package/core/frontend/services/routing/parent-router.js +17 -4
  172. package/core/frontend/services/routing/permalink-adapter.js +26 -0
  173. package/core/frontend/services/routing/permalink-adapter.ts +23 -0
  174. package/core/frontend/services/routing/registry.js +59 -33
  175. package/core/frontend/services/routing/router-manager.js +9 -13
  176. package/core/frontend/services/routing/static-routes-router.js +4 -4
  177. package/core/frontend/services/routing/taxonomy-router.js +6 -2
  178. 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
  179. package/core/server/data/migrations/versions/6.56/2026-08-04-14-42-39-restrict-notification-add-permission-to-administrators.js +12 -0
  180. package/core/server/data/schema/fixtures/fixtures.json +2 -2
  181. package/core/server/data/schema/schema.js +1 -0
  182. package/core/server/models/post.js +10 -1
  183. package/core/server/services/automations/automations-repository.ts +3 -1
  184. package/core/server/services/automations/database-automations-repository.js +33 -14
  185. package/core/server/services/automations/database-automations-repository.ts +41 -15
  186. package/core/server/services/automations/get-scheduler-idempotency-key.js +13 -0
  187. package/core/server/services/automations/get-scheduler-idempotency-key.ts +8 -0
  188. package/core/server/services/automations/poll.js +3 -1
  189. package/core/server/services/automations/poll.ts +4 -1
  190. package/core/server/services/automations/service.js +5 -1
  191. package/core/server/services/automations/service.ts +5 -1
  192. package/core/server/services/gift-links/CONTEXT.md +9 -0
  193. package/core/server/services/gifts/CONTEXT.md +55 -0
  194. package/core/server/services/gifts/README.md +28 -0
  195. package/core/server/services/gifts/gift-bookshelf-repository.js +65 -45
  196. package/core/server/services/gifts/gift-bookshelf-repository.ts +203 -74
  197. package/core/server/services/gifts/gift-checkout-adapter.js +59 -0
  198. package/core/server/services/gifts/gift-checkout-offer.js +74 -0
  199. package/core/server/services/gifts/gift-checkout-offer.ts +127 -0
  200. package/core/server/services/gifts/gift-codec.js +63 -0
  201. package/core/server/services/gifts/gift-codec.ts +62 -0
  202. package/core/server/services/gifts/gift-controller.js +6 -31
  203. package/core/server/services/gifts/gift-controller.ts +6 -69
  204. package/core/server/services/gifts/gift-email-service.ts +3 -2
  205. package/core/server/services/gifts/gift-schema.js +34 -0
  206. package/core/server/services/gifts/gift-schema.ts +39 -0
  207. package/core/server/services/gifts/gift-service-wrapper.js +13 -11
  208. package/core/server/services/gifts/gift-service.js +265 -52
  209. package/core/server/services/gifts/gift-service.ts +431 -97
  210. package/core/server/services/gifts/gift.ts +16 -39
  211. package/core/server/services/link-tracking/link-click-repository.js +6 -1
  212. package/core/server/services/link-tracking/link-click-tracking-service.js +12 -3
  213. package/core/server/services/member-welcome-emails/member-welcome-email-renderer.js +4 -3
  214. package/core/server/services/member-welcome-emails/service.js +7 -3
  215. package/core/server/services/members/api.js +1 -2
  216. package/core/server/services/members/import-export/import/importer.ts +3 -2
  217. package/core/server/services/members/import-export/index.js +10 -2
  218. package/core/server/services/members/import-export/index.ts +14 -4
  219. package/core/server/services/members/members-api/controllers/router-controller.js +38 -36
  220. package/core/server/services/members/members-api/members-api.js +13 -7
  221. package/core/server/services/members/members-api/repositories/event-repository.js +4 -94
  222. package/core/server/services/members/members-api/services/member-bread-service.js +3 -3
  223. package/core/server/services/members/members-api/services/payments-service.js +6 -68
  224. package/core/server/services/members-custom-fields/schema.js +8 -1
  225. package/core/server/services/members-custom-fields/schema.ts +8 -0
  226. package/core/server/services/members-custom-fields/values-service.js +118 -2
  227. package/core/server/services/members-custom-fields/values-service.ts +134 -4
  228. package/core/server/services/oembed/oembed-service.js +44 -3
  229. package/core/server/services/posts/posts-service.js +11 -0
  230. package/core/server/services/route-settings/dynamic-routing-service.js +1 -3
  231. package/core/server/services/route-settings/route-settings-parser.js +25 -25
  232. package/core/server/services/route-settings/route-settings-parser.ts +27 -27
  233. package/core/server/services/route-settings/validation-errors.js +68 -4
  234. package/core/server/services/route-settings/validation-errors.ts +79 -4
  235. package/core/server/services/stats/posts-stats-service.js +27 -23
  236. package/core/server/services/stripe/services/webhook/charge-refunded-event-service.js +1 -1
  237. package/core/server/services/stripe/services/webhook/checkout-session-event-service.js +16 -4
  238. package/core/server/services/stripe/stripe-api.js +10 -0
  239. package/core/server/web/gift-preview/controller.js +10 -25
  240. package/core/shared/labs.js +2 -2
  241. package/package.json +10 -9
  242. package/pnpm-lock.yaml +28 -18
  243. package/pnpm-workspace.yaml +5 -4
  244. package/components/tryghost-kg-default-nodes-2.1.3.tgz +0 -0
  245. package/core/built/admin/assets/activitypub/layout-qx-bUL1k.js +0 -1
  246. package/core/built/admin/assets/chunk.524.cffd399783a519e404a9.js +0 -25
  247. package/core/built/admin/assets/chunk.582.eebf0d0b2d202bb746a5.js +0 -27
  248. package/core/built/admin/assets/comments-DyUSMUdj.js +0 -1
  249. package/core/built/admin/assets/dist-CjY-f5Z1.js +0 -1
  250. package/core/built/admin/assets/domain-C0bV2UV8.js +0 -1
  251. package/core/built/admin/assets/editor-Dp865O-4.js +0 -8
  252. package/core/built/admin/assets/filter-sources-BV2rnpfA.js +0 -1
  253. package/core/built/admin/assets/filters-CUXJ2Nm9.js +0 -1
  254. package/core/built/admin/assets/filters-DfbIWc_l.js +0 -1
  255. package/core/built/admin/assets/index-9Cw8z3rA.css +0 -1
  256. package/core/built/admin/assets/index-BGG7NckO.js +0 -67
  257. package/core/built/admin/assets/input-group-BSgG_lVw.js +0 -1
  258. package/core/built/admin/assets/labels-CDSUt4J_.js +0 -1
  259. package/core/built/admin/assets/layout-yQEi2qDM.js +0 -1
  260. package/core/built/admin/assets/link-helpers-7EYqvvHs.js +0 -1
  261. package/core/built/admin/assets/member-custom-fields-yOMHyAg9.js +0 -1
  262. package/core/built/admin/assets/member-detail-BAq7gDaa.js +0 -1
  263. package/core/built/admin/assets/modals-ReORvWnu.js +0 -31
  264. package/core/built/admin/assets/newsletter-CJQvtHqN.js +0 -1
  265. package/core/built/admin/assets/newsletters-B3QQXfGT.js +0 -1
  266. package/core/built/admin/assets/nql-BsgFMd93.js +0 -1
  267. package/core/built/admin/assets/overview-CBfePxcr.js +0 -1
  268. package/core/built/admin/assets/post-analytics-header-Bmc9su6q.js +0 -1
  269. package/core/built/admin/assets/preferences-DJPfHEGu.js +0 -5
  270. package/core/built/admin/assets/profile-DqBQp-gT.js +0 -1
  271. package/core/built/admin/assets/select-C5_xEt5b.js +0 -1
  272. package/core/built/admin/assets/settings-BwiVIXau.js +0 -4
  273. package/core/built/admin/assets/social-urls-BQPFEk1_.js +0 -8
  274. package/core/built/admin/assets/textarea-Bjccvh_E.js +0 -1
  275. package/core/built/admin/assets/use-filter-params-Gpoq2E1L.js +0 -1
  276. package/core/built/admin/assets/web-jFYWCKDn.js +0 -2
  277. package/core/server/services/gifts/gift-repository.js +0 -2
  278. package/core/server/services/gifts/gift-repository.ts +0 -29
  279. package/core/server/services/route-settings/activation-bridge.js +0 -174
  280. package/core/server/services/route-settings/activation-bridge.ts +0 -250
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.toExpressNotation = toExpressNotation;
4
+ exports.toDomainNotation = toDomainNotation;
5
+ /**
6
+ * Convert a domain-model permalink into Express / URL-service notation.
7
+ *
8
+ * @example toExpressNotation('/{slug}/') // => '/:slug/'
9
+ * @example toExpressNotation('/{primary_tag}/{slug}/') // => '/:primary_tag/:slug/'
10
+ * @example toExpressNotation('/:slug/') // => '/:slug/' (idempotent)
11
+ */
12
+ function toExpressNotation(permalink) {
13
+ return permalink.replace(/{(\w+)}/g, ':$1');
14
+ }
15
+ /**
16
+ * Convert an Express / URL-service permalink back into domain-model notation.
17
+ * Will be used on the download path when serialising the canonical shape back
18
+ * to YAML — no production caller yet.
19
+ *
20
+ * @example toDomainNotation('/:slug/') // => '/{slug}/'
21
+ * @example toDomainNotation('/:primary_tag/:slug/') // => '/{primary_tag}/{slug}/'
22
+ * @example toDomainNotation('/{slug}/') // => '/{slug}/' (idempotent)
23
+ */
24
+ function toDomainNotation(permalink) {
25
+ return permalink.replace(/:(\w+)/g, '{$1}');
26
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Convert a domain-model permalink into Express / URL-service notation.
3
+ *
4
+ * @example toExpressNotation('/{slug}/') // => '/:slug/'
5
+ * @example toExpressNotation('/{primary_tag}/{slug}/') // => '/:primary_tag/:slug/'
6
+ * @example toExpressNotation('/:slug/') // => '/:slug/' (idempotent)
7
+ */
8
+ export function toExpressNotation(permalink: string): string {
9
+ return permalink.replace(/{(\w+)}/g, ':$1');
10
+ }
11
+
12
+ /**
13
+ * Convert an Express / URL-service permalink back into domain-model notation.
14
+ * Will be used on the download path when serialising the canonical shape back
15
+ * to YAML — no production caller yet.
16
+ *
17
+ * @example toDomainNotation('/:slug/') // => '/{slug}/'
18
+ * @example toDomainNotation('/:primary_tag/:slug/') // => '/{primary_tag}/{slug}/'
19
+ * @example toDomainNotation('/{slug}/') // => '/{slug}/' (idempotent)
20
+ */
21
+ export function toDomainNotation(permalink: string): string {
22
+ return permalink.replace(/:(\w+)/g, '{$1}');
23
+ }
@@ -1,58 +1,82 @@
1
1
  const _ = require('lodash');
2
- let routes = [];
3
- let routers = {};
4
2
 
5
3
  /**
6
- * @description The router registry is helpful for debugging purposes and let's you search existing routes and routers.
4
+ * Every registered router is a Ghost router `ParentRouter` itself (the site
5
+ * and apps routers) or one of its subclasses (Collection, StaticRoutes,
6
+ * Taxonomy, StaticPages, Preview, Email, Unsubscribe). They wrap an Express
7
+ * router, reachable via `.router()`, but are not one themselves: the registry's
8
+ * own lookups read Ghost-side members (`.name`, `.routerName`, `.reset()`,
9
+ * `.getRssUrl()`) that an Express router does not have.
10
+ *
11
+ * Type-only import — no runtime require, so this does not close a cycle with
12
+ * `parent-router.js`, which requires this module.
13
+ *
14
+ * @typedef {import('./parent-router')} ParentRouter
7
15
  */
8
- module.exports = {
16
+
17
+ /**
18
+ * @description The router registry is helpful for debugging purposes and lets you search existing routes and routers.
19
+ *
20
+ * Exported as a single shared instance so that every consumer
21
+ * (`routing/index.js`, `parent-router.js`) operates on the same registry, the
22
+ * same way the previous module-level state did — just held on an instance
23
+ * rather than in module-scoped `let` variables.
24
+ */
25
+ class Registry {
26
+ constructor() {
27
+ /** @type {Array<{route: string, from: string}>} */
28
+ this.routes = [];
29
+ /** @type {Object<string, ParentRouter>} */
30
+ this.routers = {};
31
+ }
32
+
9
33
  /**
10
- * @description Get's called if you register a url pattern in express.
34
+ * @description Gets called if you register a url pattern in express.
11
35
  * @param {string} routerName
12
36
  * @param {string} route
13
37
  */
14
38
  setRoute(routerName, route) {
15
- routes.push({route: route, from: routerName});
16
- },
39
+ this.routes.push({route: route, from: routerName});
40
+ }
17
41
 
18
42
  /**
19
- * @description Get's called if you register a router in express.
43
+ * @description Gets called if you register a router in express.
20
44
  * @param {string} name
21
- * @param {import('express').Router} router
45
+ * @param {ParentRouter} router
22
46
  */
23
47
  setRouter(name, router) {
24
- routers[name] = router;
25
- },
48
+ this.routers[name] = router;
49
+ }
26
50
 
27
51
  /**
28
52
  * @description Get all registered routes.
29
- * @returns {Array}
53
+ * @returns {Array<{route: string, from: string}>}
30
54
  */
31
55
  getAllRoutes() {
32
- return _.cloneDeep(routes);
33
- },
56
+ return _.cloneDeep(this.routes);
57
+ }
34
58
 
35
59
  /**
36
60
  * @description Get router by name.
37
61
  * @param {string} name
38
- * @returns {import('express').Router}
62
+ * @returns {ParentRouter|undefined}
39
63
  */
40
64
  getRouter(name) {
41
- return routers[name];
42
- },
65
+ return this.routers[name];
66
+ }
43
67
 
44
68
  /**
45
- * Gets a router by it's internal router name
69
+ * Gets a router by its internal router name
46
70
  * @param {string} name internal router name
47
- * @returns {import('express').Router}
71
+ * @returns {ParentRouter|undefined}
48
72
  */
49
73
  getRouterByName(name) {
50
- for (let routerKey in routers) {
51
- if (routers[routerKey].name === name) {
52
- return routers[routerKey];
74
+ for (let routerKey in this.routers) {
75
+ if (this.routers[routerKey].name === name) {
76
+ return this.routers[routerKey];
53
77
  }
54
78
  }
55
- },
79
+ }
56
80
 
57
81
  /**
58
82
  *
@@ -77,7 +101,7 @@ module.exports = {
77
101
  getRssUrl(options) {
78
102
  let rssUrl = null;
79
103
 
80
- const collectionIndexRouter = _.find(routers, {name: 'CollectionRouter', routerName: 'index'});
104
+ const collectionIndexRouter = _.find(this.routers, {name: 'CollectionRouter', routerName: 'index'});
81
105
 
82
106
  if (collectionIndexRouter) {
83
107
  rssUrl = collectionIndexRouter.getRssUrl(options);
@@ -88,7 +112,7 @@ module.exports = {
88
112
  }
89
113
  }
90
114
 
91
- const collectionRouters = _.filter(routers, {name: 'CollectionRouter'});
115
+ const collectionRouters = _.filter(this.routers, {name: 'CollectionRouter'});
92
116
 
93
117
  if (collectionRouters && collectionRouters.length === 1) {
94
118
  rssUrl = collectionRouters[0].getRssUrl(options);
@@ -108,32 +132,34 @@ module.exports = {
108
132
  }
109
133
 
110
134
  return rssUrl;
111
- },
135
+ }
112
136
 
113
137
  /**
114
138
  * @description Reset all routes.
115
139
  */
116
140
  resetAllRoutes() {
117
- routes = [];
118
- },
141
+ this.routes = [];
142
+ }
119
143
 
120
144
  /**
121
145
  * @description Reset all routers.
122
146
  */
123
147
  resetAllRouters() {
124
- _.each(routers, (value) => {
148
+ _.each(this.routers, (value) => {
125
149
  if (value && typeof value.reset === 'function') {
126
150
  value.reset();
127
151
  }
128
152
  });
129
153
 
130
- routers = {};
131
- },
154
+ this.routers = {};
155
+ }
132
156
 
133
157
  /**
134
158
  * @description Clear all routers (for testing).
135
159
  */
136
160
  clearAllRouters() {
137
- routers = {};
161
+ this.routers = {};
138
162
  }
139
- };
163
+ }
164
+
165
+ module.exports = new Registry();
@@ -90,10 +90,10 @@ class RouterManager {
90
90
  * 5. Static Pages: Weaker than collections, because we first try to find a post slug and fallback to lookup a static page.
91
91
  * 6. Internal Apps: Weakest
92
92
  *
93
- * @param {object} routerSettings
93
+ * @param {RouteSettings} routeSettings
94
94
  */
95
- start(routerSettings) {
96
- debug('routing start', routerSettings);
95
+ start(routeSettings) {
96
+ debug('routing start', routeSettings);
97
97
  const RESOURCE_CONFIG = require(`./config`);
98
98
 
99
99
  const unsubscribeRouter = new UnsubscribeRouter();
@@ -110,14 +110,14 @@ class RouterManager {
110
110
  this.siteRouter.mountRouter(previewRouter.router());
111
111
  this.registry.setRouter('previewRouter', previewRouter);
112
112
 
113
- for (const route of routerSettings.routes) {
113
+ for (const route of routeSettings.routes) {
114
114
  const staticRoutesRouter = new StaticRoutesRouter(route.path, route, this.routerCreated.bind(this));
115
115
  this.siteRouter.mountRouter(staticRoutesRouter.router());
116
116
 
117
117
  this.registry.setRouter(staticRoutesRouter.identifier, staticRoutesRouter);
118
118
  }
119
119
 
120
- for (const collection of routerSettings.collections) {
120
+ for (const collection of routeSettings.collections) {
121
121
  const collectionRouter = new CollectionRouter(collection.path, collection, RESOURCE_CONFIG, this.routerCreated.bind(this));
122
122
  this.siteRouter.mountRouter(collectionRouter.router());
123
123
  this.registry.setRouter(collectionRouter.identifier, collectionRouter);
@@ -128,8 +128,8 @@ class RouterManager {
128
128
 
129
129
  this.registry.setRouter('staticPagesRouter', staticPagesRouter);
130
130
 
131
- for (const taxonomy of routerSettings.taxonomies) {
132
- const taxonomyRouter = new TaxonomyRouter(taxonomy.key, taxonomy.permalink, RESOURCE_CONFIG, this.routerCreated.bind(this));
131
+ for (const [key, permalink] of Object.entries(routeSettings.taxonomies)) {
132
+ const taxonomyRouter = new TaxonomyRouter(key, permalink, RESOURCE_CONFIG, this.routerCreated.bind(this));
133
133
  this.siteRouter.mountRouter(taxonomyRouter.router());
134
134
 
135
135
  this.registry.setRouter(taxonomyRouter.identifier, taxonomyRouter);
@@ -180,16 +180,12 @@ module.exports = RouterManager;
180
180
 
181
181
  /**
182
182
  * @typedef {Object} RouterConfig
183
- * @property {RouteSettings} [routeSettings] - JSON config representing routes
183
+ * @property {RouteSettings} [routeSettings] - parsed route settings domain model
184
184
  * @property {URLServiceFacade} urlService - resource-based URL service facade
185
185
  */
186
186
 
187
187
  /**
188
- * The shape RouterManager consumes — produced by the activation bridge. This is
189
- * a temporary reference into server code; when the bridge is removed (HKG-1898)
190
- * it becomes the domain `RouteSettings` from @tryghost/adapter-base-route-settings.
191
- *
192
- * @typedef {import('../../../server/services/route-settings/activation-bridge').RouterSettings} RouteSettings
188
+ * @typedef {import('@tryghost/adapter-base-route-settings').RouteSettings} RouteSettings
193
189
  */
194
190
 
195
191
  /**
@@ -15,7 +15,7 @@ class StaticRoutesRouter extends ParentRouter {
15
15
 
16
16
  this.route = {value: mainRoute};
17
17
  this.templates = object.templates || [];
18
- this.data = object.data || {query: {}, router: {}};
18
+ this.data = object.data || {};
19
19
  this.routerName = mainRoute === '/' ? 'index' : mainRoute.replace(/\//g, '');
20
20
  this.routerCreated = routerCreated;
21
21
 
@@ -80,7 +80,7 @@ class StaticRoutesRouter extends ParentRouter {
80
80
  filter: this.filter,
81
81
  limit: this.limit,
82
82
  order: this.order,
83
- data: this.data.query,
83
+ data: this.data,
84
84
  templates: this.templates
85
85
  };
86
86
 
@@ -117,7 +117,7 @@ class StaticRoutesRouter extends ParentRouter {
117
117
  message: `Missing template ${res.routerOptions.templates.map(x => `${x}.hbs`).join(', ')} for route "${req.originalUrl}".`
118
118
  });
119
119
  },
120
- data: this.data.query,
120
+ data: this.data,
121
121
  context: [this.routerName],
122
122
  contentType: this.contentType
123
123
  };
@@ -131,7 +131,7 @@ class StaticRoutesRouter extends ParentRouter {
131
131
  * @returns {boolean}
132
132
  */
133
133
  isChannel(object) {
134
- // buildRouterSettings always sets `type` ('channel' | 'template') on
134
+ // parseRouteSettings always sets `type` ('channel' | 'template') on
135
135
  // every route, so a channel is simply `type === 'channel'`.
136
136
  return object?.type === 'channel';
137
137
  }
@@ -5,6 +5,7 @@ const RSSRouter = require('./rss-router');
5
5
  const urlUtils = require('../../../shared/url-utils').default;
6
6
  const controllers = require('./controllers');
7
7
  const middleware = require('./middleware');
8
+ const {toExpressNotation} = require('./permalink-adapter');
8
9
 
9
10
  /**
10
11
  * @description Taxonomies are groupings of posts based on a common relation.
@@ -17,8 +18,9 @@ class TaxonomyRouter extends ParentRouter {
17
18
  this.taxonomyKey = key;
18
19
  this.RESOURCE_CONFIG = RESOURCE_CONFIG;
19
20
 
21
+ // convert domain `{slug}` -> Express/URL-service `:slug` at this boundary
20
22
  this.permalinks = {
21
- value: permalinks
23
+ value: toExpressNotation(permalinks)
22
24
  };
23
25
 
24
26
  this.permalinks.getValue = () => {
@@ -74,7 +76,9 @@ class TaxonomyRouter extends ParentRouter {
74
76
  type: 'channel',
75
77
  name: this.taxonomyKey,
76
78
  permalinks: this.permalinks.getValue(),
77
- data: {[this.taxonomyKey]: this.RESOURCE_CONFIG.QUERY[this.taxonomyKey]},
79
+ // Route data in domain form — the API adapter resolves it to a
80
+ // controller call, and fetch-data fills `%s` in from the request.
81
+ data: {[this.taxonomyKey]: {type: 'read', resource: this.getResourceType(), slug: '%s'}},
78
82
  filter: this.RESOURCE_CONFIG.TAXONOMIES[this.taxonomyKey].filter,
79
83
  resourceType: this.getResourceType(),
80
84
  context: [this.taxonomyKey],
@@ -0,0 +1,8 @@
1
+ const {createAddColumnMigration} = require('../../utils');
2
+
3
+ module.exports = createAddColumnMigration('automated_email_recipients', 'automation_run_step_id', {
4
+ type: 'string',
5
+ maxlength: 24,
6
+ nullable: true,
7
+ references: 'automation_run_steps.id'
8
+ }, {algorithm: 'auto'});
@@ -0,0 +1,12 @@
1
+ const {combineTransactionalMigrations, removePermissionFromRole} = require('../../utils');
2
+
3
+ module.exports = combineTransactionalMigrations(
4
+ removePermissionFromRole({
5
+ permission: 'Add notifications',
6
+ role: 'Editor'
7
+ }),
8
+ removePermissionFromRole({
9
+ permission: 'Add notifications',
10
+ role: 'Super Editor'
11
+ })
12
+ );
@@ -1066,7 +1066,7 @@
1066
1066
  "gift_link": "manage"
1067
1067
  },
1068
1068
  "Super Editor": {
1069
- "notification": "all",
1069
+ "notification": ["browse", "destroy"],
1070
1070
  "post": "all",
1071
1071
  "setting": ["browse", "read"],
1072
1072
  "slug": "all",
@@ -1091,7 +1091,7 @@
1091
1091
  "gift_link": "manage"
1092
1092
  },
1093
1093
  "Editor": {
1094
- "notification": "all",
1094
+ "notification": ["browse", "destroy"],
1095
1095
  "post": "all",
1096
1096
  "setting": ["browse", "read"],
1097
1097
  "slug": "all",
@@ -1339,6 +1339,7 @@ module.exports = {
1339
1339
  id: {type: 'string', maxlength: 24, nullable: false, primary: true},
1340
1340
  automated_email_id: {type: 'string', maxlength: 24, nullable: true, references: 'welcome_email_automated_emails.id'},
1341
1341
  automation_action_revision_id: {type: 'string', maxlength: 24, nullable: true, references: 'automation_action_revisions.id'},
1342
+ automation_run_step_id: {type: 'string', maxlength: 24, nullable: true, references: 'automation_run_steps.id'},
1342
1343
  member_id: {type: 'string', maxlength: 24, nullable: false, index: true},
1343
1344
  member_uuid: {type: 'string', maxlength: 36, nullable: false},
1344
1345
  member_email: {type: 'string', maxlength: 191, nullable: false},
@@ -1346,7 +1346,16 @@ Post = ghostBookshelf.Model.extend({
1346
1346
  destroy: function destroy(unfilteredOptions) {
1347
1347
  let options = this.filterOptions(unfilteredOptions, 'destroy', {extraAllowedProperties: ['id']});
1348
1348
 
1349
- const destroyPost = () => {
1349
+ const destroyPost = async () => {
1350
+ // The `comments.in_reply_to_id` references form chains between a post's
1351
+ // comments, which MySQL cannot resolve while cascade-deleting them
1352
+ // alongside `comments.parent_id`. Clear the references first so the
1353
+ // `comments.post_id` cascade delete can do its job
1354
+ await ghostBookshelf.knex('comments')
1355
+ .where('post_id', options.id)
1356
+ .update('in_reply_to_id', null)
1357
+ .transacting(options.transacting);
1358
+
1350
1359
  return ghostBookshelf.Model.destroy.call(this, options);
1351
1360
  };
1352
1361
 
@@ -90,6 +90,7 @@ export type AutomatedEmailEvents = {
90
90
 
91
91
  export type RecordEmailSentOptions = Readonly<{
92
92
  automationActionRevisionId: string;
93
+ automationRunStepId: string;
93
94
  mailgunMessageId?: string;
94
95
  memberEmail: string;
95
96
  memberId: string;
@@ -203,11 +204,12 @@ export interface AutomationsRepository {
203
204
  eventsByAutomatedEmailRecipientId: ReadonlyDeep<Map<string, AutomatedEmailEvents>>
204
205
  ): Promise<void>;
205
206
  /**
206
- * Track the first click for an automated email recipient.
207
+ * Record the first click timestamp for the automated email recipient identified by a run step.
207
208
  *
208
209
  */
209
210
  trackEmailClicked(options: {
210
211
  automationActionRevisionId: string;
212
+ automationRunStepId: string;
211
213
  memberId: string;
212
214
  clickedAt: Readonly<Date>;
213
215
  }, transactionOptions?: {
@@ -135,6 +135,7 @@ function createDatabaseAutomationsRepository({ knex, fakeWaitHoursMultiplier })
135
135
  member_email: options.memberEmail,
136
136
  member_name: options.memberName,
137
137
  automation_action_revision_id: options.automationActionRevisionId,
138
+ automation_run_step_id: options.automationRunStepId,
138
139
  ...(options.mailgunMessageId ? { mailgun_message_id: options.mailgunMessageId } : {}),
139
140
  track_clicks: options.trackClicks,
140
141
  track_opens: options.trackOpens,
@@ -157,6 +158,13 @@ function createDatabaseAutomationsRepository({ knex, fakeWaitHoursMultiplier })
157
158
  return;
158
159
  }
159
160
  await knex.transaction(async (trx) => {
161
+ const revisionIds = new Set();
162
+ for (const { openedAt, automationActionRevisionId } of eventsByAutomatedEmailRecipientId.values()) {
163
+ if (openedAt) {
164
+ revisionIds.add(automationActionRevisionId);
165
+ }
166
+ }
167
+ const orderedRevisionIds = await lockActionRevisions(trx, revisionIds);
160
168
  const notYetOpened = await lockNotYetOpened(trx, eventsByAutomatedEmailRecipientId);
161
169
  const newOpensPerRevision = new Map();
162
170
  for (const [id, { deliveredAt, openedAt, automationActionRevisionId }] of eventsByAutomatedEmailRecipientId) {
@@ -177,10 +185,11 @@ function createDatabaseAutomationsRepository({ knex, fakeWaitHoursMultiplier })
177
185
  newOpensPerRevision.set(automationActionRevisionId, (newOpensPerRevision.get(automationActionRevisionId) ?? 0) + 1);
178
186
  }
179
187
  }
180
- // Keep lock acquisition order consistent across concurrent transactions to avoid deadlocks.
181
- const revisions = [...newOpensPerRevision.entries()]
182
- .sort(([left], [right]) => left.localeCompare(right));
183
- for (const [id, opens] of revisions) {
188
+ for (const id of orderedRevisionIds) {
189
+ const opens = newOpensPerRevision.get(id);
190
+ if (!opens) {
191
+ continue;
192
+ }
184
193
  await trx('automation_action_revisions')
185
194
  .where({ id })
186
195
  .update({
@@ -189,24 +198,17 @@ function createDatabaseAutomationsRepository({ knex, fakeWaitHoursMultiplier })
189
198
  }
190
199
  });
191
200
  },
192
- async trackEmailClicked({ automationActionRevisionId, memberId, clickedAt }, { transacting } = {}) {
201
+ async trackEmailClicked({ automationActionRevisionId, automationRunStepId, memberId, clickedAt }, { transacting } = {}) {
193
202
  const trackClick = async (trx) => {
194
- // Match recordEmailSent's lock order to avoid deadlocks.
195
- await trx('automation_action_revisions')
196
- .select('id')
197
- .where({ id: automationActionRevisionId })
198
- .forUpdate()
199
- .first();
203
+ await lockActionRevisions(trx, [automationActionRevisionId]);
200
204
  const recipient = await trx('automated_email_recipients')
201
205
  .select('id', 'clicked_at')
202
206
  .where({
203
207
  automation_action_revision_id: automationActionRevisionId,
208
+ automation_run_step_id: automationRunStepId,
204
209
  member_id: memberId,
205
210
  track_clicks: true
206
211
  })
207
- .where('created_at', '<=', (0, database_date_1.toDatabaseDate)(clickedAt))
208
- .orderBy('created_at', 'desc')
209
- .orderBy('id', 'desc')
210
212
  .forUpdate()
211
213
  .first();
212
214
  if (!recipient || recipient.clicked_at !== null) {
@@ -233,6 +235,23 @@ function createDatabaseAutomationsRepository({ knex, fakeWaitHoursMultiplier })
233
235
  }
234
236
  };
235
237
  }
238
+ /**
239
+ * Lock revisions before recipients because inserting a recipient takes a shared
240
+ * foreign-key lock on its revision. Updating that revision later can deadlock
241
+ * with another transaction that has already locked the revision and is waiting
242
+ * for the recipient. Keep multi-revision lock acquisition deterministic.
243
+ */
244
+ async function lockActionRevisions(trx, revisionIds) {
245
+ const sortedRevisionIds = [...new Set(revisionIds)]
246
+ .sort((left, right) => left.localeCompare(right));
247
+ if (sortedRevisionIds.length > 0) {
248
+ await trx('automation_action_revisions')
249
+ .select('id')
250
+ .whereIn('id', sortedRevisionIds)
251
+ .forUpdate();
252
+ }
253
+ return sortedRevisionIds;
254
+ }
236
255
  /**
237
256
  * Which of these recipients have yet to open, and so should count towards their
238
257
  * revision's open count. Locks them for the transaction, so a worker racing on
@@ -276,6 +276,7 @@ export function createDatabaseAutomationsRepository({
276
276
  member_email: options.memberEmail,
277
277
  member_name: options.memberName,
278
278
  automation_action_revision_id: options.automationActionRevisionId,
279
+ automation_run_step_id: options.automationRunStepId,
279
280
  ...(options.mailgunMessageId ? {mailgun_message_id: options.mailgunMessageId} : {}),
280
281
  track_clicks: options.trackClicks,
281
282
  track_opens: options.trackOpens,
@@ -301,6 +302,15 @@ export function createDatabaseAutomationsRepository({
301
302
  }
302
303
 
303
304
  await knex.transaction(async (trx) => {
305
+ const revisionIds = new Set<string>();
306
+ for (const {openedAt, automationActionRevisionId} of eventsByAutomatedEmailRecipientId.values()) {
307
+ if (openedAt) {
308
+ revisionIds.add(automationActionRevisionId);
309
+ }
310
+ }
311
+
312
+ const orderedRevisionIds = await lockActionRevisions(trx, revisionIds);
313
+
304
314
  const notYetOpened = await lockNotYetOpened(trx, eventsByAutomatedEmailRecipientId);
305
315
  const newOpensPerRevision = new Map<string, number>();
306
316
 
@@ -327,11 +337,11 @@ export function createDatabaseAutomationsRepository({
327
337
  }
328
338
  }
329
339
 
330
- // Keep lock acquisition order consistent across concurrent transactions to avoid deadlocks.
331
- const revisions = [...newOpensPerRevision.entries()]
332
- .sort(([left], [right]) => left.localeCompare(right));
333
-
334
- for (const [id, opens] of revisions) {
340
+ for (const id of orderedRevisionIds) {
341
+ const opens = newOpensPerRevision.get(id);
342
+ if (!opens) {
343
+ continue;
344
+ }
335
345
  await trx('automation_action_revisions')
336
346
  .where({id})
337
347
  .update({
@@ -341,25 +351,18 @@ export function createDatabaseAutomationsRepository({
341
351
  });
342
352
  },
343
353
 
344
- async trackEmailClicked({automationActionRevisionId, memberId, clickedAt}, {transacting} = {}) {
354
+ async trackEmailClicked({automationActionRevisionId, automationRunStepId, memberId, clickedAt}, {transacting} = {}) {
345
355
  const trackClick = async (trx: Knex.Transaction) => {
346
- // Match recordEmailSent's lock order to avoid deadlocks.
347
- await trx('automation_action_revisions')
348
- .select('id')
349
- .where({id: automationActionRevisionId})
350
- .forUpdate()
351
- .first();
356
+ await lockActionRevisions(trx, [automationActionRevisionId]);
352
357
 
353
358
  const recipient = await trx('automated_email_recipients')
354
359
  .select('id', 'clicked_at')
355
360
  .where({
356
361
  automation_action_revision_id: automationActionRevisionId,
362
+ automation_run_step_id: automationRunStepId,
357
363
  member_id: memberId,
358
364
  track_clicks: true
359
365
  })
360
- .where('created_at', '<=', toDatabaseDate(clickedAt))
361
- .orderBy('created_at', 'desc')
362
- .orderBy('id', 'desc')
363
366
  .forUpdate()
364
367
  .first();
365
368
 
@@ -394,6 +397,29 @@ export function createDatabaseAutomationsRepository({
394
397
  };
395
398
  }
396
399
 
400
+ /**
401
+ * Lock revisions before recipients because inserting a recipient takes a shared
402
+ * foreign-key lock on its revision. Updating that revision later can deadlock
403
+ * with another transaction that has already locked the revision and is waiting
404
+ * for the recipient. Keep multi-revision lock acquisition deterministic.
405
+ */
406
+ async function lockActionRevisions(
407
+ trx: Knex.Transaction,
408
+ revisionIds: Iterable<string>
409
+ ): Promise<string[]> {
410
+ const sortedRevisionIds = [...new Set(revisionIds)]
411
+ .sort((left, right) => left.localeCompare(right));
412
+
413
+ if (sortedRevisionIds.length > 0) {
414
+ await trx('automation_action_revisions')
415
+ .select('id')
416
+ .whereIn('id', sortedRevisionIds)
417
+ .forUpdate();
418
+ }
419
+
420
+ return sortedRevisionIds;
421
+ }
422
+
397
423
  /**
398
424
  * Which of these recipients have yet to open, and so should count towards their
399
425
  * revision's open count. Locks them for the transaction, so a worker racing on
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getSchedulerIdempotencyKey = getSchedulerIdempotencyKey;
7
+ const node_crypto_1 = __importDefault(require("node:crypto"));
8
+ function getSchedulerIdempotencyKey(date, url) {
9
+ const hash = node_crypto_1.default.createHash('sha256');
10
+ hash.update(date.toISOString());
11
+ hash.update(url.href);
12
+ return `ghost-automations-${hash.digest('hex')}`;
13
+ }
@@ -0,0 +1,8 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ export function getSchedulerIdempotencyKey(date: Readonly<Date>, url: Readonly<URL>): string {
4
+ const hash = crypto.createHash('sha256');
5
+ hash.update(date.toISOString());
6
+ hash.update(url.href);
7
+ return `ghost-automations-${hash.digest('hex')}`;
8
+ }