ghost 6.40.0 → 6.41.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 (184) hide show
  1. package/core/boot.js +8 -8
  2. package/core/built/admin/assets/{PolarAngleAxis-D7m1zwVk.js → PolarAngleAxis-Bh28wbPV.js} +1 -1
  3. package/core/built/admin/assets/{_baseAssignValue-BDsvYKNK.js → _baseAssignValue-hdDG700N.js} +1 -1
  4. package/core/built/admin/assets/{a-large-small-DxRNdz0F.js → a-large-small-BiK5_5LV.js} +1 -1
  5. package/core/built/admin/assets/{account-migration-SYnE1jBW.js → account-migration-DsSHanIB.js} +1 -1
  6. package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +1 -1
  7. package/core/built/admin/assets/admin-x-settings/{code-editor-view-Bj9rgbLw.mjs → code-editor-view-BEIdFhw3.mjs} +2 -2
  8. package/core/built/admin/assets/admin-x-settings/{index-BrZ9YAzd.mjs → index-BEh3nFEy.mjs} +3 -3
  9. package/core/built/admin/assets/admin-x-settings/{index-DRndIh9T.mjs → index-BLF2n6YB.mjs} +2 -2
  10. package/core/built/admin/assets/admin-x-settings/{index-CIl25Teq.mjs → index-BmPIWs5F.mjs} +3 -3
  11. package/core/built/admin/assets/admin-x-settings/{index-CNyNY3Wh.mjs → index-CP9YnNzc.mjs} +3 -3
  12. package/core/built/admin/assets/admin-x-settings/{index-SGr5sE2h.mjs → index-Cd2Ns-D2.mjs} +2 -2
  13. package/core/built/admin/assets/admin-x-settings/{index-PGoLP2kh.mjs → index-CjqAcKpK.mjs} +5 -5
  14. package/core/built/admin/assets/admin-x-settings/{index-pCIv_7bB.mjs → index-CnMcD9aj.mjs} +6212 -6158
  15. package/core/built/admin/assets/admin-x-settings/{index-CPCcgad-.mjs → index-LzsrZC40.mjs} +3 -3
  16. package/core/built/admin/assets/admin-x-settings/{index-DPqY4h5h.mjs → index-ZYW0UW7I.mjs} +3 -3
  17. package/core/built/admin/assets/admin-x-settings/{modals-lo32WZLi.mjs → modals-C48Tq1rW.mjs} +9 -9
  18. package/core/built/admin/assets/{arrow-right-BixoEua4.js → arrow-right-Bk43nMGd.js} +1 -1
  19. package/core/built/admin/assets/{at-sign-BsHNViA_.js → at-sign-DWOf36uj.js} +1 -1
  20. package/core/built/admin/assets/{audience-0RCWjbIK.js → audience-CvP78Gug.js} +1 -1
  21. package/core/built/admin/assets/{automations-DTUfYvf6.js → automations-BBWZhrha.js} +1 -1
  22. package/core/built/admin/assets/{automations-DiGkcVt-.js → automations-CmulGWwu.js} +1 -1
  23. package/core/built/admin/assets/{avatar-flipboard-BgoH-wIq.js → avatar-flipboard-CVf-N6wp.js} +1 -1
  24. package/core/built/admin/assets/{bluesky-sharing-C2xwSoS-.js → bluesky-sharing-DVC3I9Um.js} +1 -1
  25. package/core/built/admin/assets/{chart-X5cLIH1X.js → chart-CUKSNmKY.js} +1 -1
  26. package/core/built/admin/assets/{checkbox-DMamS4oG.js → checkbox-B_yDSM8z.js} +1 -1
  27. package/core/built/admin/assets/{chevron-up-CVgBkTk5.js → chevron-up-DuaKtE_M.js} +1 -1
  28. package/core/built/admin/assets/{chunk.941.f47098cbbce89a049e03.js → chunk.331.2ec2e2702eda02de96cd.js} +3 -3
  29. package/core/built/admin/assets/{chunk.524.a1d668dcc84709a5d702.js → chunk.524.49cc01002e1a5dba8ef9.js} +4 -4
  30. package/core/built/admin/assets/{chunk.582.180bf2525cf552f5ac5f.js → chunk.582.4bbc54c5f7d3dfb93288.js} +6 -6
  31. package/core/built/admin/assets/{circle-alert-Dg3UkvRP.js → circle-alert-bcK_Yqap.js} +1 -1
  32. package/core/built/admin/assets/{code-editor-view-CAICoZJe.js → code-editor-view-Bl4Jzjhk.js} +1 -1
  33. package/core/built/admin/assets/{comments-BHru9ZQ8.js → comments-DMea0nul.js} +1 -1
  34. package/core/built/admin/assets/{content-helpers-BByMp-Y5.js → content-helpers-B2yRzdvJ.js} +1 -1
  35. package/core/built/admin/assets/{copy-D7jqplrv.js → copy-DVtQi_VJ.js} +1 -1
  36. package/core/built/admin/assets/{data-list-DUaY9nlT.js → data-list-BF4GeUmt.js} +1 -1
  37. package/core/built/admin/assets/{deleted-feed-item-jLcSKuul.js → deleted-feed-item-DO2u3qmi.js} +1 -1
  38. package/core/built/admin/assets/{dropzone-DSPf3N_o.js → dropzone-Cs4DrURS.js} +1 -1
  39. package/core/built/admin/assets/{edit-profile-BvU2-lW5.js → edit-profile-CnuBNQhO.js} +1 -1
  40. package/core/built/admin/assets/{editor-D3fcjt_F.js → editor-CTO9FIUW.js} +1 -1
  41. package/core/built/admin/assets/{empty-indicator-2Pjmce3n.js → empty-indicator-BvyaMcmw.js} +1 -1
  42. package/core/built/admin/assets/{en-Bu-6PYn5.js → en-CjPXRj8j.js} +1 -1
  43. package/core/built/admin/assets/{feed-CygLK1Ad.js → feed-D2wk4cCS.js} +1 -1
  44. package/core/built/admin/assets/{filter-query-core-a0PNU2EO.js → filter-query-core-VEnwTCyO.js} +1 -1
  45. package/core/built/admin/assets/{filters-jiyRsLMH.js → filters-DtIsuqv5.js} +1 -1
  46. package/core/built/admin/assets/{gh-chart-CEhBBu3F.js → gh-chart-JxzKqaoO.js} +1 -1
  47. package/core/built/admin/assets/{ghost-0ab96884815339963364804ab266c99f.js → ghost-30c3dedbe1cca943b92efa7efd2710e4.js} +36 -21
  48. package/core/built/admin/assets/{growth-Fz3Oatml.js → growth-CPNrBpHK.js} +1 -1
  49. package/core/built/admin/assets/{hash-CeFrjo3C.js → hash-CJzbTA5k.js} +1 -1
  50. package/core/built/admin/assets/{inbox-CCirPaG2.js → inbox-CVVAkCmd.js} +1 -1
  51. package/core/built/admin/assets/{index-Dd2BJRZH.js → index-AkQJUr1z.js} +1 -1
  52. package/core/built/admin/assets/{index-CYPN5csJ.js → index-BLWOUf31.js} +1 -1
  53. package/core/built/admin/assets/{index-D7Ui3Fih.js → index-BRBRkRqa.js} +1 -1
  54. package/core/built/admin/assets/{index-CFEzD2_M.js → index-BkbhBpVC.js} +3 -3
  55. package/core/built/admin/assets/{index-BYwL7gFN.js → index-BxFFWYdP.js} +1 -1
  56. package/core/built/admin/assets/{index-D3LfHeBl.js → index-C5_ZKpC1.js} +1 -1
  57. package/core/built/admin/assets/{index-Bjw2qAph.js → index-Cdbufdfy.js} +1 -1
  58. package/core/built/admin/assets/{index-BTf6YUBb.js → index-Cq5vsvxE.js} +1 -1
  59. package/core/built/admin/assets/{index-DPbi4ie_.js → index-CuGoX8Ub.js} +1 -1
  60. package/core/built/admin/assets/{index-CsaU9dfl.js → index-CuH5raTz.js} +1 -1
  61. package/core/built/admin/assets/{index-Bf83suMc.js → index-D3A8RTi0.js} +1 -1
  62. package/core/built/admin/assets/{index-Dskrbhv3.js → index-DIyK69JM.js} +1 -1
  63. package/core/built/admin/assets/{index-GExKMpy_.js → index-DLFVpwt3.js} +1 -1
  64. package/core/built/admin/assets/{index-BOMdiHRG.js → index-DQZp_NsL.js} +1 -1
  65. package/core/built/admin/assets/{index-2qcjFCQs.js → index-DR74ulj2.js} +1 -1
  66. package/core/built/admin/assets/{index-WKMARia9.js → index-DYE2untj.js} +1 -1
  67. package/core/built/admin/assets/{index-DNZUJQ8v.js → index-DssW7xhk.js} +1 -1
  68. package/core/built/admin/assets/{index-Cq4mS7QK.js → index-DvyNqFct.js} +1 -1
  69. package/core/built/admin/assets/{index-C7xLyJVb.js → index-DxB7NHpG.js} +1 -1
  70. package/core/built/admin/assets/{index-Bw4DM4Pr.js → index-IDaHqjnu.js} +1 -1
  71. package/core/built/admin/assets/{index-ClOB5u8L.js → index-TtBMzSeP.js} +1 -1
  72. package/core/built/admin/assets/{index-takfwHSn.js → index-UMWVIyz7.js} +1 -1
  73. package/core/built/admin/assets/{koenig-lexical-BJM9wAwQ.js → koenig-lexical-DVAmevhl.js} +1 -1
  74. package/core/built/admin/assets/{kpi-card-BP-2yicn.js → kpi-card-D3CzvsrI.js} +1 -1
  75. package/core/built/admin/assets/{kpi-card-DOplXdDT.js → kpi-card-DV-nN7xT.js} +1 -1
  76. package/core/built/admin/assets/{kpi-tabs-Rq5jJW2Z.js → kpi-tabs-B3NkaTwb.js} +1 -1
  77. package/core/built/admin/assets/{kpis-yHgME_iy.js → kpis-CVwF08QL.js} +1 -1
  78. package/core/built/admin/assets/{label-fijjy1Mb.js → label-BiPpMny2.js} +1 -1
  79. package/core/built/admin/assets/{links-s3IeAkbo.js → links-BY0Uo6O5.js} +1 -1
  80. package/core/built/admin/assets/{list-page-CUcB5ebB.js → list-page-COHNMpOi.js} +1 -1
  81. package/core/built/admin/assets/{loader-circle-C7mWzo52.js → loader-circle-B1uEfpbZ.js} +1 -1
  82. package/core/built/admin/assets/{mail-9TFkAaAh.js → mail-ccQhEr7k.js} +1 -1
  83. package/core/built/admin/assets/{main-layout-XtviVkg1.js → main-layout-BYKdA6Z1.js} +1 -1
  84. package/core/built/admin/assets/{members-XkHao5Yf.js → members-DkpEnvG5.js} +1 -1
  85. package/core/built/admin/assets/minus-C68D81nx.js +1 -0
  86. package/core/built/admin/assets/{modals-B6R7oqvb.js → modals-X6RSWn5Y.js} +3 -3
  87. package/core/built/admin/assets/{moderation-B2Tixfce.js → moderation-BFrrwfvx.js} +1 -1
  88. package/core/built/admin/assets/{newsletter-CbESXhxf.js → newsletter-n7kc6cxR.js} +1 -1
  89. package/core/built/admin/assets/{newsletters-DOIL_cOY.js → newsletters-CSCa3QdE.js} +1 -1
  90. package/core/built/admin/assets/{note-mUikaB-p.js → note-CMZjeK8e.js} +1 -1
  91. package/core/built/admin/assets/{onboarding-route-Dqv2nP7b.js → onboarding-route-BaX0a-pu.js} +1 -1
  92. package/core/built/admin/assets/{overview-yZ-7KJdx.js → overview-CS9dmE_6.js} +1 -1
  93. package/core/built/admin/assets/{pagemenu-TxGvHwRO.js → pagemenu-O_y8Jm4v.js} +1 -1
  94. package/core/built/admin/assets/{pencil-CKTgzafp.js → pencil-BcymGPnE.js} +1 -1
  95. package/core/built/admin/assets/{pin-BU_u-xRX.js → pin-Bl3nSr7I.js} +1 -1
  96. package/core/built/admin/assets/{post-analytics-CWA8invj.js → post-analytics-DW-TKu-t.js} +1 -1
  97. package/core/built/admin/assets/{post-analytics-context-BWDhMRq_.js → post-analytics-context-D7PgEMo8.js} +1 -1
  98. package/core/built/admin/assets/{post-analytics-header-Bm0mcxjK.js → post-analytics-header-B8C0vA5Y.js} +1 -1
  99. package/core/built/admin/assets/{post-share-modal-D8BtRD2u.js → post-share-modal-D0OlASBQ.js} +1 -1
  100. package/core/built/admin/assets/{posts-u-CAKddc.js → posts-BZaw4-dc.js} +1 -1
  101. package/core/built/admin/assets/{power-DR9gpiQR.js → power-DWm6pts5.js} +1 -1
  102. package/core/built/admin/assets/{referrers-DV_Ihg9y.js → referrers-BsZ85Z0a.js} +1 -1
  103. package/core/built/admin/assets/{repeat-uXmOJ6y8.js → repeat-DQay9e22.js} +1 -1
  104. package/core/built/admin/assets/{reply-9ZyqaxI-.js → reply-CoNqmExw.js} +1 -1
  105. package/core/built/admin/assets/{rocket-DMOr1OCt.js → rocket-Dne6P_8f.js} +1 -1
  106. package/core/built/admin/assets/{select-CPSy44ld.js → select-BfWJkrKc.js} +1 -1
  107. package/core/built/admin/assets/{settings-krk0432u.js → settings-BoXzKMhL.js} +1 -1
  108. package/core/built/admin/assets/{settings-66Zv8QvL.js → settings-rRY210wx.js} +25 -25
  109. package/core/built/admin/assets/{share-modal-BRmGllZq.js → share-modal-CtdLQbnQ.js} +1 -1
  110. package/core/built/admin/assets/{sort-button-5fyeViQL.js → sort-button-7pVgpa0d.js} +1 -1
  111. package/core/built/admin/assets/{source-icon-DeeHyZRn.js → source-icon-Dtua85oN.js} +1 -1
  112. package/core/built/admin/assets/{sprout-956Q4On2.js → sprout-CpwUJAPT.js} +1 -1
  113. package/core/built/admin/assets/{square-BaCvbKJY.js → square-CDrQjv8R.js} +1 -1
  114. package/core/built/admin/assets/{stats-BVKNZJO6.js → stats-Yo9Gco0_.js} +1 -1
  115. package/core/built/admin/assets/{stats-view-gtyaqudG.js → stats-view-DubukX9V.js} +1 -1
  116. package/core/built/admin/assets/{step-1-09wdjzdX.js → step-1-CH8jWKi7.js} +1 -1
  117. package/core/built/admin/assets/{step-2-8iaQ0efq.js → step-2-CWy7Qi0Q.js} +1 -1
  118. package/core/built/admin/assets/{step-3-BuHAZbdD.js → step-3-CNiDFH17.js} +1 -1
  119. package/core/built/admin/assets/{table-DTofVyjA.js → table-qQi9EXB1.js} +1 -1
  120. package/core/built/admin/assets/{tabs-CL5eaCGL.js → tabs-M6kFEqPE.js} +1 -1
  121. package/core/built/admin/assets/{tags-DJrbEw5-.js → tags-74U6lgwY.js} +1 -1
  122. package/core/built/admin/assets/{tags-U3lciBmd.js → tags-DuMshkTz.js} +1 -1
  123. package/core/built/admin/assets/{textarea-DdDuNixZ.js → textarea-CGBp9nOo.js} +1 -1
  124. package/core/built/admin/assets/{tiers-DXKbghdk.js → tiers-D2DYHaeO.js} +1 -1
  125. package/core/built/admin/assets/{toggle-group-DaZKTYDS.js → toggle-group-GYp_w6Po.js} +1 -1
  126. package/core/built/admin/assets/{topic-filter-BzqiVOAU.js → topic-filter-N3CzJ01P.js} +1 -1
  127. package/core/built/admin/assets/{trash-CKK_p9G5.js → trash-CUqAK3SQ.js} +1 -1
  128. package/core/built/admin/assets/{underline-Bq0P-5Zy.js → underline-CwuJG-Ql.js} +1 -1
  129. package/core/built/admin/assets/{undo-2-CcBtw-tW.js → undo-2-D6ZRqONE.js} +1 -1
  130. package/core/built/admin/assets/{upload-BcylHEMw.js → upload-BLiT66bJ.js} +1 -1
  131. package/core/built/admin/assets/{use-growth-stats-Ci0gymWp.js → use-growth-stats-u18So4Gq.js} +1 -1
  132. package/core/built/admin/assets/{use-simple-pagination-ByaFwxfZ.js → use-simple-pagination-Dp2sTt2S.js} +1 -1
  133. package/core/built/admin/assets/{user-round-check-D0I7NO1g.js → user-round-check-BDomHaso.js} +1 -1
  134. package/core/built/admin/assets/{user-round-x-DeGVH29o.js → user-round-x-Cc0RL-V9.js} +1 -1
  135. package/core/built/admin/assets/{virtual-list-window-S0xZxQAf.js → virtual-list-window-BnjCoNIr.js} +1 -1
  136. package/core/built/admin/assets/{wallet-cards-B9TQg4x3.js → wallet-cards-CVZ99Nm3.js} +1 -1
  137. package/core/built/admin/assets/{web-BRN2mUpo.js → web-B_MLxGxZ.js} +1 -1
  138. package/core/built/admin/index.html +5 -5
  139. package/core/server/{services/custom-redirects/file-store.js → adapters/redirects/FileStore.js} +6 -5
  140. package/core/server/{services/custom-redirects/file-store.ts → adapters/redirects/FileStore.ts} +6 -4
  141. package/core/server/adapters/redirects/RedirectsStoreBase.d.ts +9 -0
  142. package/core/server/adapters/redirects/RedirectsStoreBase.js +8 -0
  143. package/core/server/{services/custom-redirects/gcs-store.js → adapters/redirects/S3RedirectsStore.js} +50 -18
  144. package/core/server/adapters/redirects/S3RedirectsStore.ts +163 -0
  145. package/core/server/adapters/scheduling/scheduling-base.js +41 -0
  146. package/core/server/adapters/scheduling/types.js +2 -0
  147. package/core/server/adapters/scheduling/types.ts +36 -0
  148. package/core/server/api/endpoints/authentication.js +26 -5
  149. package/core/server/api/endpoints/schedules.js +0 -30
  150. package/core/server/api/endpoints/utils/serializers/output/authentication.js +10 -0
  151. package/core/server/data/migrations/versions/6.41/2026-05-13-12-00-00-rename-reset-all-passwords-permission.js +21 -0
  152. package/core/server/data/schema/fixtures/fixtures.json +3 -3
  153. package/core/server/models/api-key.js +18 -0
  154. package/core/server/models/user.js +18 -0
  155. package/core/server/services/adapter-manager/config.js +6 -0
  156. package/core/server/services/adapter-manager/index.js +1 -0
  157. package/core/server/services/auth/index.js +4 -0
  158. package/core/server/services/auth/reset-authentication.js +50 -0
  159. package/core/server/services/auth/reset-authentication.ts +75 -0
  160. package/core/server/services/automations/index.js +21 -15
  161. package/core/server/services/custom-redirects/index.js +2 -4
  162. package/core/server/services/gifts/gift-bookshelf-repository.js +7 -0
  163. package/core/server/services/gifts/gift-bookshelf-repository.ts +10 -0
  164. package/core/server/services/gifts/gift-reminder-scheduler.js +94 -0
  165. package/core/server/services/gifts/gift-reminder-scheduler.ts +109 -0
  166. package/core/server/services/gifts/gift-repository.ts +1 -0
  167. package/core/server/services/gifts/gift-service-wrapper.js +10 -7
  168. package/core/server/services/gifts/gift-service.js +1 -32
  169. package/core/server/services/gifts/gift-service.ts +3 -59
  170. package/core/server/services/internal-keys/index.js +1 -3
  171. package/core/server/services/internal-keys/index.ts +11 -3
  172. package/core/server/services/post-scheduling/index.js +16 -28
  173. package/core/server/services/post-scheduling/index.ts +14 -0
  174. package/core/server/services/post-scheduling/post-scheduling.js +115 -0
  175. package/core/server/services/post-scheduling/post-scheduling.ts +138 -0
  176. package/core/server/services/users.js +29 -16
  177. package/core/server/web/api/endpoints/admin/middleware.js +8 -4
  178. package/core/server/web/api/endpoints/admin/routes.js +1 -1
  179. package/core/shared/config/defaults.json +5 -0
  180. package/core/shared/labs.js +2 -1
  181. package/package.json +1 -1
  182. package/core/built/admin/assets/minus-DPrXh11r.js +0 -1
  183. package/core/server/services/custom-redirects/gcs-store.ts +0 -117
  184. package/core/server/services/post-scheduling/post-scheduler-service.js +0 -126
@@ -0,0 +1,109 @@
1
+ import logging from '@tryghost/logging';
2
+ import {Gift} from './gift';
3
+ import type {InternalApiKey, InternalKeys} from '../internal-keys';
4
+ import type {SchedulerAdapter, SchedulerJob} from '../../adapters/scheduling/types';
5
+ import {GIFT_REMINDER_LEAD_DAYS} from './constants';
6
+ // Same-domain (scheduling) primitives, used unconditionally.
7
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
8
+ const urlUtils = require('../../../shared/url-utils');
9
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
10
+ const {getSignedAdminToken} = require('../../adapters/scheduling/utils');
11
+
12
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
13
+ const GIFT_REMINDER_LEAD_MS = GIFT_REMINDER_LEAD_DAYS * MS_PER_DAY;
14
+
15
+ interface GiftReminderSchedulerDeps {
16
+ apiUrl: string;
17
+ // Optional in deps so the JS wrapper can pass options.schedulerAdapter
18
+ // through without TS complaining at the JS/TS boundary. The class field
19
+ // below is non-optional; the constructor's adapter.register(this) call
20
+ // throws if undefined is passed through in practice.
21
+ adapter?: SchedulerAdapter;
22
+ internalKeys: InternalKeys;
23
+ findUnsentReminders(): Promise<Gift[]>;
24
+ }
25
+
26
+ export class GiftReminderScheduler {
27
+ readonly #apiUrl: string;
28
+ readonly #adapter: SchedulerAdapter;
29
+ readonly #internalKeys: InternalKeys;
30
+ readonly #findUnsentReminders: () => Promise<Gift[]>;
31
+
32
+ constructor({apiUrl, adapter, internalKeys, findUnsentReminders}: GiftReminderSchedulerDeps) {
33
+ this.#apiUrl = apiUrl;
34
+ this.#adapter = adapter!;
35
+ this.#internalKeys = internalKeys;
36
+ this.#findUnsentReminders = findUnsentReminders;
37
+ this.#adapter.register(this);
38
+ }
39
+
40
+ /**
41
+ * Queue a single reminder callback for a freshly-redeemed gift. The
42
+ * callback fires at consumesAt - GIFT_REMINDER_LEAD_DAYS. Already-due
43
+ * reminders are skipped — the daily cron picks them up.
44
+ */
45
+ async scheduleFor(gift: Gift): Promise<void> {
46
+ if (!gift.consumesAt) {
47
+ return;
48
+ }
49
+ const time = gift.consumesAt.getTime() - GIFT_REMINDER_LEAD_MS;
50
+ if (time <= Date.now()) {
51
+ return;
52
+ }
53
+
54
+ try {
55
+ const key = await this.#internalKeys.get('ghost-scheduler');
56
+ this.#adapter.schedule(this.#buildJob(time, key));
57
+ } catch (err) {
58
+ logging.error({
59
+ event: {name: 'gift_reminder_scheduler.schedule.failed'},
60
+ err,
61
+ giftToken: gift.token
62
+ }, 'Failed to schedule gift reminder');
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Re-issue every queued reminder under the current scheduler key. Pass
68
+ * the pre-rotation secret as `previousKey` so each adapter-queued URL
69
+ * can be reconstructed for unschedule before resigning with the new
70
+ * key. Reminders whose fire time has already passed are skipped — the
71
+ * daily cron picks them up.
72
+ */
73
+ async rescheduleAll({previousKey}: {previousKey?: InternalApiKey} = {}): Promise<void> {
74
+ const currentKey = await this.#internalKeys.get('ghost-scheduler');
75
+ const unscheduleKey = previousKey ?? currentKey;
76
+ const pending = await this.#findUnsentReminders();
77
+
78
+ // Same-key rebuild (no previousKey, boot path) → URL signature is
79
+ // identical to the about-to-be-scheduled job. The default adapter
80
+ // implements unschedule via tombstones keyed by URL+time, so a same-URL
81
+ // unschedule poisons the scheduled job. Bootstrap mode skips the
82
+ // tombstone write. Rotation (previousKey provided) → URLs differ, so
83
+ // the tombstone correctly targets the old queued entry.
84
+ const bootstrap = !previousKey;
85
+
86
+ for (const gift of pending) {
87
+ if (!gift.consumesAt) {
88
+ continue;
89
+ }
90
+ const time = gift.consumesAt.getTime() - GIFT_REMINDER_LEAD_MS;
91
+ if (time <= Date.now()) {
92
+ continue;
93
+ }
94
+ this.#adapter.unschedule(this.#buildJob(time, unscheduleKey), {bootstrap});
95
+ this.#adapter.schedule(this.#buildJob(time, currentKey));
96
+ }
97
+ }
98
+
99
+ #buildJob(time: number, key: InternalApiKey): SchedulerJob {
100
+ const signedAdminToken = getSignedAdminToken({
101
+ publishedAt: new Date(time).toISOString(),
102
+ apiUrl: this.#apiUrl,
103
+ key
104
+ });
105
+ const url = new URL(urlUtils.urlJoin(this.#apiUrl, 'gifts', 'flush_reminders'));
106
+ url.searchParams.set('token', signedAdminToken);
107
+ return {time, url: url.toString(), extra: {httpMethod: 'PUT'}};
108
+ }
109
+ }
@@ -20,6 +20,7 @@ export interface GiftRepository {
20
20
  findPendingConsumption(): Promise<Gift[]>;
21
21
  findPendingExpiration(): Promise<Gift[]>;
22
22
  findPendingReminder(options: FindPendingReminderOptions): Promise<Gift[]>;
23
+ findUnsentReminders(): Promise<Gift[]>;
23
24
  getActiveByMember(memberId: string, options?: RepositoryTransactionOptions): Promise<Gift | null>;
24
25
  getActiveByMembers(memberIds: string[], options?: RepositoryTransactionOptions): Promise<Map<string, Gift>>;
25
26
  create(gift: Gift, options?: RepositoryTransactionOptions): Promise<void>;
@@ -7,7 +7,7 @@
7
7
  * @typedef {object} InitOptions
8
8
  * @prop {string} [apiUrl]
9
9
  * @prop {SchedulerAdapter} [schedulerAdapter]
10
- * @prop {ReadonlyMap<string, Promise<{id: string, secret: string}>>} [internalKeys]
10
+ * @prop {import('../internal-keys').InternalKeys} [internalKeys]
11
11
  */
12
12
 
13
13
  class GiftServiceWrapper {
@@ -26,6 +26,7 @@ class GiftServiceWrapper {
26
26
  const {Gift: GiftModel} = require('../../models');
27
27
  const {GiftBookshelfRepository} = require('./gift-bookshelf-repository');
28
28
  const {GiftService} = require('./gift-service');
29
+ const {GiftReminderScheduler} = require('./gift-reminder-scheduler');
29
30
  const {GiftEmailService} = require('./gift-email-service');
30
31
  const {GiftController} = require('./gift-controller');
31
32
  const membersService = require('../members');
@@ -45,7 +46,6 @@ class GiftServiceWrapper {
45
46
  const settingsHelpers = require('../settings-helpers');
46
47
  const EmailAddressParser = require('../email-address/email-address-parser');
47
48
  const {blogIcon} = require('../../../server/lib/image');
48
- const {getSignedAdminToken} = require('../../adapters/scheduling/utils');
49
49
  const {t} = require('../i18n');
50
50
 
51
51
  const repository = new GiftBookshelfRepository({
@@ -61,6 +61,13 @@ class GiftServiceWrapper {
61
61
  t
62
62
  });
63
63
 
64
+ const giftReminderScheduler = new GiftReminderScheduler({
65
+ apiUrl: options.apiUrl,
66
+ adapter: options.schedulerAdapter,
67
+ internalKeys: options.internalKeys,
68
+ findUnsentReminders: () => repository.findUnsentReminders()
69
+ });
70
+
64
71
  this.service = new GiftService({
65
72
  giftRepository: repository,
66
73
  get memberRepository() {
@@ -71,11 +78,7 @@ class GiftServiceWrapper {
71
78
  get staffServiceEmails() {
72
79
  return staffService.api.emails;
73
80
  },
74
- schedulerAdapter: options.schedulerAdapter ?? null,
75
- getSchedulerKey: options.internalKeys ? () => options.internalKeys.get('ghost-scheduler') : null,
76
- getSignedAdminToken,
77
- urlJoin: urlUtils.urlJoin.bind(urlUtils),
78
- apiUrl: options.apiUrl ?? null
81
+ giftReminderScheduler
79
82
  });
80
83
 
81
84
  this.controller = new GiftController({
@@ -204,7 +204,7 @@ class GiftService {
204
204
  catch (err) {
205
205
  logging_1.default.error('Failed to notify staff of gift redemption', err);
206
206
  }
207
- await this.scheduleReminder(redeemed);
207
+ await this.deps.giftReminderScheduler.scheduleFor(redeemed);
208
208
  };
209
209
  if (options.transacting) {
210
210
  // Only notify once the transaction has finished
@@ -380,37 +380,6 @@ class GiftService {
380
380
  }
381
381
  return { expiredCount };
382
382
  }
383
- async scheduleReminder(gift) {
384
- const { schedulerAdapter, getSchedulerKey, getSignedAdminToken, urlJoin, apiUrl } = this.deps;
385
- if (!schedulerAdapter || !getSchedulerKey || !getSignedAdminToken || !urlJoin || !apiUrl) {
386
- return;
387
- }
388
- if (!gift.consumesAt) {
389
- return;
390
- }
391
- const time = gift.consumesAt.getTime() - GIFT_REMINDER_LEAD_MS;
392
- if (time <= Date.now()) {
393
- return;
394
- }
395
- try {
396
- const key = await getSchedulerKey();
397
- const signedAdminToken = getSignedAdminToken({
398
- publishedAt: new Date(time).toISOString(),
399
- apiUrl,
400
- key
401
- });
402
- const url = new URL(urlJoin(apiUrl, 'gifts', 'flush_reminders'));
403
- url.searchParams.set('token', signedAdminToken);
404
- schedulerAdapter.schedule({
405
- time,
406
- url: url.toString(),
407
- extra: { httpMethod: 'PUT' }
408
- });
409
- }
410
- catch (err) {
411
- logging_1.default.error('Failed to schedule gift reminder', err);
412
- }
413
- }
414
383
  async processReminders() {
415
384
  const now = new Date();
416
385
  const toRemind = await this.deps.giftRepository.findPendingReminder({
@@ -3,7 +3,7 @@ import errors from '@tryghost/errors';
3
3
  import logging from '@tryghost/logging';
4
4
  import {Gift} from './gift';
5
5
  import type {GiftRepository} from './gift-repository';
6
- import type {InternalApiKey} from '../internal-keys';
6
+ import type {GiftReminderScheduler} from './gift-reminder-scheduler';
7
7
  import tpl from '@tryghost/tpl';
8
8
  import {GIFT_REMINDER_FLOOR_DAYS, GIFT_REMINDER_LEAD_DAYS} from './constants';
9
9
  import {MEMBER_WELCOME_EMAIL_SLUGS} from '../member-welcome-emails/constants';
@@ -113,31 +113,13 @@ export interface GiftPurchaseData {
113
113
  stripePaymentIntentId: string;
114
114
  }
115
115
 
116
- interface SchedulerAdapter {
117
- schedule(job: {time: number; url: string; extra: {httpMethod: string}}): void;
118
- }
119
-
120
- type GetSchedulerKey = () => Promise<InternalApiKey>;
121
-
122
- type GetSignedAdminToken = (options: {
123
- publishedAt: string;
124
- apiUrl: string;
125
- key: InternalApiKey;
126
- }) => string;
127
-
128
- type UrlJoin = (...parts: string[]) => string;
129
-
130
116
  interface GiftServiceDeps {
131
117
  giftRepository: GiftRepository;
132
118
  memberRepository: MemberRepository;
133
119
  tiersService: TiersService;
134
120
  giftEmailService: GiftEmailService;
135
121
  staffServiceEmails: StaffServiceEmails;
136
- schedulerAdapter: SchedulerAdapter | null;
137
- getSchedulerKey: GetSchedulerKey | null;
138
- getSignedAdminToken: GetSignedAdminToken | null;
139
- urlJoin: UrlJoin | null;
140
- apiUrl: string | null;
122
+ giftReminderScheduler: Pick<GiftReminderScheduler, 'scheduleFor'>;
141
123
  }
142
124
 
143
125
  interface ReminderSend {
@@ -355,7 +337,7 @@ export class GiftService {
355
337
  logging.error('Failed to notify staff of gift redemption', err);
356
338
  }
357
339
 
358
- await this.scheduleReminder(redeemed);
340
+ await this.deps.giftReminderScheduler.scheduleFor(redeemed);
359
341
  };
360
342
 
361
343
  if (options.transacting) {
@@ -595,44 +577,6 @@ export class GiftService {
595
577
  return {expiredCount};
596
578
  }
597
579
 
598
- private async scheduleReminder(gift: Gift): Promise<void> {
599
- const {schedulerAdapter, getSchedulerKey, getSignedAdminToken, urlJoin, apiUrl} = this.deps;
600
-
601
- if (!schedulerAdapter || !getSchedulerKey || !getSignedAdminToken || !urlJoin || !apiUrl) {
602
- return;
603
- }
604
-
605
- if (!gift.consumesAt) {
606
- return;
607
- }
608
-
609
- const time = gift.consumesAt.getTime() - GIFT_REMINDER_LEAD_MS;
610
-
611
- if (time <= Date.now()) {
612
- return;
613
- }
614
-
615
- try {
616
- const key = await getSchedulerKey();
617
- const signedAdminToken = getSignedAdminToken({
618
- publishedAt: new Date(time).toISOString(),
619
- apiUrl,
620
- key
621
- });
622
-
623
- const url = new URL(urlJoin(apiUrl, 'gifts', 'flush_reminders'));
624
- url.searchParams.set('token', signedAdminToken);
625
-
626
- schedulerAdapter.schedule({
627
- time,
628
- url: url.toString(),
629
- extra: {httpMethod: 'PUT'}
630
- });
631
- } catch (err) {
632
- logging.error('Failed to schedule gift reminder', err);
633
- }
634
- }
635
-
636
580
  async processReminders(): Promise<{remindedCount: number; skippedCount: number; failedCount: number}> {
637
581
  const now = new Date();
638
582
  const toRemind = await this.deps.giftRepository.findPendingReminder({
@@ -16,9 +16,7 @@ const SLUG_KEY_TYPE = {
16
16
  const models = require('../../models');
17
17
  /**
18
18
  * Process-lifetime cache of internal-integration API keys, keyed by slug.
19
- * Exposed to consumers as a `ReadonlyMap<InternalIntegrationSlug, Promise<InternalApiKey>>`
20
- * so they only see `.get(slug)`; rotation orchestration uses the full Map
21
- * surface (`.delete(slug)`, `.clear()`) to invalidate after rotating the
19
+ * Rotation orchestration calls `.clear()` to invalidate after rotating the
22
20
  * underlying api_keys row.
23
21
  */
24
22
  const internalKeys = new auto_filling_map_1.AutoFillingMap(slug => models.Integration.getApiKeyBySlug(slug, SLUG_KEY_TYPE[slug]));
@@ -20,6 +20,16 @@ const SLUG_KEY_TYPE = {
20
20
 
21
21
  export type ApiKeyType = typeof SLUG_KEY_TYPE[InternalIntegrationSlug];
22
22
 
23
+ /**
24
+ * The shape consumers receive when they import the singleton: an
25
+ * `AutoFillingMap` whose `get` returns `Promise<InternalApiKey>` directly
26
+ * (the override drops the `| undefined` from the structural `Map.get`
27
+ * signature). Rotation orchestration uses the inherited `Map` surface
28
+ * (`.clear()`, `.delete()`) to invalidate after rotating the underlying
29
+ * api_keys row.
30
+ */
31
+ export type InternalKeys = AutoFillingMap<InternalIntegrationSlug, Promise<InternalApiKey>>;
32
+
23
33
  // models/index.js is the Bookshelf model registry — a JS module without
24
34
  // TypeScript declarations. Use a typed require so we can call the model
25
35
  // method without polluting the file with `any`. The generic constrains
@@ -35,9 +45,7 @@ const models = require('../../models') as {
35
45
 
36
46
  /**
37
47
  * Process-lifetime cache of internal-integration API keys, keyed by slug.
38
- * Exposed to consumers as a `ReadonlyMap<InternalIntegrationSlug, Promise<InternalApiKey>>`
39
- * so they only see `.get(slug)`; rotation orchestration uses the full Map
40
- * surface (`.delete(slug)`, `.clear()`) to invalidate after rotating the
48
+ * Rotation orchestration calls `.clear()` to invalidate after rotating the
41
49
  * underlying api_keys row.
42
50
  */
43
51
  const internalKeys = new AutoFillingMap<InternalIntegrationSlug, Promise<InternalApiKey>>(
@@ -1,29 +1,17 @@
1
- const events = require('../../lib/common/events');
2
- const PostSchedulerService = require('./post-scheduler-service');
3
- const {sequence} = require('@tryghost/promise');
4
-
5
- const SCHEDULED_RESOURCES = ['post', 'page'];
6
-
7
- /**
8
- * @description Load all scheduled posts/pages from database.
9
- * @return {Promise}
10
- */
11
- const loadScheduledResources = async function () {
12
- const api = require('../../api').endpoints;
13
- const results = await sequence(SCHEDULED_RESOURCES.map(resourceType => async () => {
14
- const result = await api.schedules.getScheduled.query({options: {resource: resourceType}});
15
- return result[resourceType] || [];
16
- }));
17
- return SCHEDULED_RESOURCES.reduce((obj, entry, index) => Object.assign(obj, {[entry]: results[index]}), {});
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
18
4
  };
19
-
20
- const init = async ({adapter, apiUrl, internalKeys}) => {
21
- const service = new PostSchedulerService({apiUrl, internalKeys, adapter, events});
22
- if (adapter.rescheduleOnBoot) {
23
- const scheduledResources = await loadScheduledResources();
24
- await service.reschedule(scheduledResources);
25
- }
26
- return service;
27
- };
28
-
29
- exports.init = init;
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const post_scheduling_1 = __importDefault(require("./post-scheduling"));
7
+ const internal_keys_1 = __importDefault(require("../internal-keys"));
8
+ // CJS modules without TS declarations — typed loosely at the boundary.
9
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
10
+ const adapterManager = require('../adapter-manager');
11
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
12
+ const urlUtils = require('../../../shared/url-utils');
13
+ exports.default = new post_scheduling_1.default({
14
+ apiUrl: urlUtils.urlFor('api', { type: 'admin' }, true),
15
+ adapter: adapterManager.getAdapter('scheduling'),
16
+ internalKeys: internal_keys_1.default
17
+ });
@@ -0,0 +1,14 @@
1
+ import PostScheduling from './post-scheduling';
2
+ import internalKeys from '../internal-keys';
3
+
4
+ // CJS modules without TS declarations — typed loosely at the boundary.
5
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
6
+ const adapterManager = require('../adapter-manager');
7
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
8
+ const urlUtils = require('../../../shared/url-utils');
9
+
10
+ export default new PostScheduling({
11
+ apiUrl: urlUtils.urlFor('api', {type: 'admin'}, true),
12
+ adapter: adapterManager.getAdapter('scheduling'),
13
+ internalKeys
14
+ });
@@ -0,0 +1,115 @@
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
+ /* eslint-disable @typescript-eslint/no-explicit-any */
7
+ const moment_1 = __importDefault(require("moment"));
8
+ const logging_1 = __importDefault(require("@tryghost/logging"));
9
+ // CJS-only modules — typed loosely below. models is the Bookshelf registry
10
+ // without TS declarations; the rest are JS modules without types.
11
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
12
+ const models = require('../../models');
13
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
14
+ const urlUtils = require('../../../shared/url-utils');
15
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
16
+ const { getSignedAdminToken } = require('../../adapters/scheduling/utils');
17
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
18
+ const events = require('../../lib/common/events');
19
+ // Pages live in the posts table with type:'page', so both types are
20
+ // queried through models.Post and discriminated via the type filter.
21
+ const SCHEDULED_RESOURCES = ['post', 'page'];
22
+ class PostScheduling {
23
+ #apiUrl;
24
+ #adapter;
25
+ #internalKeys;
26
+ constructor({ apiUrl, adapter, internalKeys }) {
27
+ this.#apiUrl = apiUrl;
28
+ this.#adapter = adapter;
29
+ this.#internalKeys = internalKeys;
30
+ SCHEDULED_RESOURCES.forEach((resource) => {
31
+ events.on(`${resource}.scheduled`, async (model) => {
32
+ try {
33
+ const key = await internalKeys.get('ghost-scheduler');
34
+ this.#adapter.schedule(this.#normalize({ model, key, resourceType: resource }));
35
+ }
36
+ catch (err) {
37
+ logging_1.default.error({ event: { name: 'post-scheduling.schedule.error' }, err, resource, id: model.get('id') }, 'Failed to schedule resource');
38
+ }
39
+ });
40
+ // Reschedule = matched unschedule + fresh schedule, because tokens
41
+ // are signed against the published_at timestamp.
42
+ events.on(`${resource}.rescheduled`, async (model) => {
43
+ try {
44
+ const key = await internalKeys.get('ghost-scheduler');
45
+ this.#adapter.unschedule(this.#normalize({ model, key, resourceType: resource }, 'unscheduled'));
46
+ this.#adapter.schedule(this.#normalize({ model, key, resourceType: resource }));
47
+ }
48
+ catch (err) {
49
+ logging_1.default.error({ event: { name: 'post-scheduling.reschedule.error' }, err, resource, id: model.get('id') }, 'Failed to reschedule resource');
50
+ }
51
+ });
52
+ events.on(`${resource}.unscheduled`, async (model) => {
53
+ try {
54
+ const key = await internalKeys.get('ghost-scheduler');
55
+ this.#adapter.unschedule(this.#normalize({ model, key, resourceType: resource }, 'unscheduled'));
56
+ }
57
+ catch (err) {
58
+ logging_1.default.error({ event: { name: 'post-scheduling.unschedule.error' }, err, resource, id: model.get('id') }, 'Failed to unschedule resource');
59
+ }
60
+ });
61
+ });
62
+ this.#adapter.register(this);
63
+ }
64
+ /**
65
+ * Re-issue every queued schedule under the current internal-keys cache.
66
+ * On boot the previous key is the same as the current key. For key
67
+ * rotation, the caller passes `previousKey` so unschedule URLs match
68
+ * the entries the adapter already holds (signed under the previous
69
+ * secret); schedule URLs are reissued under the current secret.
70
+ */
71
+ async rescheduleAll({ previousKey } = {}) {
72
+ const scheduledResources = await this.#loadScheduledResources();
73
+ const currentKey = await this.#internalKeys.get('ghost-scheduler');
74
+ const unscheduleKey = previousKey ?? currentKey;
75
+ // Same-key rebuild (no previousKey, boot path) → URL signature is
76
+ // identical to the about-to-be-scheduled job. The default adapter
77
+ // implements unschedule via tombstones keyed by URL+time, so a same-URL
78
+ // unschedule poisons the scheduled job. Bootstrap mode skips the
79
+ // tombstone write. Rotation (previousKey provided) → URLs differ, so
80
+ // the tombstone correctly targets the old queued entry.
81
+ const bootstrap = !previousKey;
82
+ for (const resourceType of Object.keys(scheduledResources)) {
83
+ for (const model of scheduledResources[resourceType]) {
84
+ this.#adapter.unschedule(this.#normalize({ model, key: unscheduleKey, resourceType }), { bootstrap });
85
+ this.#adapter.schedule(this.#normalize({ model, key: currentKey, resourceType }));
86
+ }
87
+ }
88
+ }
89
+ async #loadScheduledResources() {
90
+ const entries = await Promise.all(SCHEDULED_RESOURCES.map(async (resourceType) => {
91
+ const found = await models.Post.findAll({
92
+ filter: `status:scheduled+type:${resourceType}`,
93
+ columns: ['id', 'published_at', 'created_at', 'type']
94
+ });
95
+ return [resourceType, found];
96
+ }));
97
+ return Object.fromEntries(entries);
98
+ }
99
+ #normalize({ model, resourceType, key }, event = '') {
100
+ const resource = `${resourceType}s`;
101
+ const publishedAt = (event === 'unscheduled') ? model.previous('published_at') : model.get('published_at');
102
+ const signedAdminToken = getSignedAdminToken({ publishedAt, apiUrl: this.#apiUrl, key });
103
+ const url = `${urlUtils.urlJoin(this.#apiUrl, 'schedules', resource, model.get('id'))}/?token=${signedAdminToken}`;
104
+ return {
105
+ // NOTE: The scheduler expects a unix timestamp.
106
+ time: (0, moment_1.default)(publishedAt).valueOf(),
107
+ url,
108
+ extra: {
109
+ httpMethod: 'PUT',
110
+ oldTime: model.previous('published_at') ? (0, moment_1.default)(model.previous('published_at')).valueOf() : null
111
+ }
112
+ };
113
+ }
114
+ }
115
+ exports.default = PostScheduling;
@@ -0,0 +1,138 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import moment from 'moment';
3
+ import logging from '@tryghost/logging';
4
+ import type {InternalApiKey, InternalKeys} from '../internal-keys';
5
+ import type {SchedulerAdapter, SchedulerJob} from '../../adapters/scheduling/types';
6
+
7
+ // CJS-only modules — typed loosely below. models is the Bookshelf registry
8
+ // without TS declarations; the rest are JS modules without types.
9
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
10
+ const models = require('../../models');
11
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
12
+ const urlUtils = require('../../../shared/url-utils');
13
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
14
+ const {getSignedAdminToken} = require('../../adapters/scheduling/utils');
15
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
16
+ const events = require('../../lib/common/events');
17
+
18
+ interface PostSchedulingDeps {
19
+ apiUrl: string;
20
+ // Optional in deps so the JS wrapper can pass options.schedulerAdapter
21
+ // through without TS complaining at the JS/TS boundary. The class field
22
+ // below is non-optional; the constructor's adapter.register(this) call
23
+ // throws if undefined is passed through in practice.
24
+ adapter?: SchedulerAdapter;
25
+ internalKeys: InternalKeys;
26
+ }
27
+
28
+ // Pages live in the posts table with type:'page', so both types are
29
+ // queried through models.Post and discriminated via the type filter.
30
+ const SCHEDULED_RESOURCES = ['post', 'page'] as const;
31
+ type ScheduledResource = typeof SCHEDULED_RESOURCES[number];
32
+
33
+ export default class PostScheduling {
34
+ readonly #apiUrl: string;
35
+ readonly #adapter: SchedulerAdapter;
36
+ readonly #internalKeys: InternalKeys;
37
+
38
+ constructor({apiUrl, adapter, internalKeys}: PostSchedulingDeps) {
39
+ this.#apiUrl = apiUrl;
40
+ this.#adapter = adapter!;
41
+ this.#internalKeys = internalKeys;
42
+
43
+ SCHEDULED_RESOURCES.forEach((resource) => {
44
+ events.on(`${resource}.scheduled`, async (model: any) => {
45
+ try {
46
+ const key = await internalKeys.get('ghost-scheduler');
47
+ this.#adapter.schedule(this.#normalize({model, key, resourceType: resource}));
48
+ } catch (err) {
49
+ logging.error({event: {name: 'post-scheduling.schedule.error'}, err, resource, id: model.get('id')}, 'Failed to schedule resource');
50
+ }
51
+ });
52
+
53
+ // Reschedule = matched unschedule + fresh schedule, because tokens
54
+ // are signed against the published_at timestamp.
55
+ events.on(`${resource}.rescheduled`, async (model: any) => {
56
+ try {
57
+ const key = await internalKeys.get('ghost-scheduler');
58
+ this.#adapter.unschedule(this.#normalize({model, key, resourceType: resource}, 'unscheduled'));
59
+ this.#adapter.schedule(this.#normalize({model, key, resourceType: resource}));
60
+ } catch (err) {
61
+ logging.error({event: {name: 'post-scheduling.reschedule.error'}, err, resource, id: model.get('id')}, 'Failed to reschedule resource');
62
+ }
63
+ });
64
+
65
+ events.on(`${resource}.unscheduled`, async (model: any) => {
66
+ try {
67
+ const key = await internalKeys.get('ghost-scheduler');
68
+ this.#adapter.unschedule(this.#normalize({model, key, resourceType: resource}, 'unscheduled'));
69
+ } catch (err) {
70
+ logging.error({event: {name: 'post-scheduling.unschedule.error'}, err, resource, id: model.get('id')}, 'Failed to unschedule resource');
71
+ }
72
+ });
73
+ });
74
+
75
+ this.#adapter.register(this);
76
+ }
77
+
78
+ /**
79
+ * Re-issue every queued schedule under the current internal-keys cache.
80
+ * On boot the previous key is the same as the current key. For key
81
+ * rotation, the caller passes `previousKey` so unschedule URLs match
82
+ * the entries the adapter already holds (signed under the previous
83
+ * secret); schedule URLs are reissued under the current secret.
84
+ */
85
+ async rescheduleAll({previousKey}: {previousKey?: InternalApiKey} = {}): Promise<void> {
86
+ const scheduledResources = await this.#loadScheduledResources();
87
+ const currentKey = await this.#internalKeys.get('ghost-scheduler');
88
+ const unscheduleKey = previousKey ?? currentKey;
89
+
90
+ // Same-key rebuild (no previousKey, boot path) → URL signature is
91
+ // identical to the about-to-be-scheduled job. The default adapter
92
+ // implements unschedule via tombstones keyed by URL+time, so a same-URL
93
+ // unschedule poisons the scheduled job. Bootstrap mode skips the
94
+ // tombstone write. Rotation (previousKey provided) → URLs differ, so
95
+ // the tombstone correctly targets the old queued entry.
96
+ const bootstrap = !previousKey;
97
+
98
+ for (const resourceType of Object.keys(scheduledResources) as ScheduledResource[]) {
99
+ for (const model of scheduledResources[resourceType]) {
100
+ this.#adapter.unschedule(
101
+ this.#normalize({model, key: unscheduleKey, resourceType}),
102
+ {bootstrap}
103
+ );
104
+ this.#adapter.schedule(
105
+ this.#normalize({model, key: currentKey, resourceType})
106
+ );
107
+ }
108
+ }
109
+ }
110
+
111
+ async #loadScheduledResources(): Promise<Record<ScheduledResource, any[]>> {
112
+ const entries = await Promise.all(SCHEDULED_RESOURCES.map(async (resourceType) => {
113
+ const found = await models.Post.findAll({
114
+ filter: `status:scheduled+type:${resourceType}`,
115
+ columns: ['id', 'published_at', 'created_at', 'type']
116
+ });
117
+ return [resourceType, found];
118
+ }));
119
+ return Object.fromEntries(entries);
120
+ }
121
+
122
+ #normalize({model, resourceType, key}: {model: any; resourceType: ScheduledResource; key: InternalApiKey}, event: '' | 'unscheduled' = ''): SchedulerJob {
123
+ const resource = `${resourceType}s`;
124
+ const publishedAt = (event === 'unscheduled') ? model.previous('published_at') : model.get('published_at');
125
+ const signedAdminToken = getSignedAdminToken({publishedAt, apiUrl: this.#apiUrl, key});
126
+ const url = `${urlUtils.urlJoin(this.#apiUrl, 'schedules', resource, model.get('id'))}/?token=${signedAdminToken}`;
127
+
128
+ return {
129
+ // NOTE: The scheduler expects a unix timestamp.
130
+ time: moment(publishedAt).valueOf(),
131
+ url,
132
+ extra: {
133
+ httpMethod: 'PUT',
134
+ oldTime: model.previous('published_at') ? moment(model.previous('published_at')).valueOf() : null
135
+ }
136
+ };
137
+ }
138
+ }