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.
- package/core/boot.js +8 -8
- package/core/built/admin/assets/{PolarAngleAxis-D7m1zwVk.js → PolarAngleAxis-Bh28wbPV.js} +1 -1
- package/core/built/admin/assets/{_baseAssignValue-BDsvYKNK.js → _baseAssignValue-hdDG700N.js} +1 -1
- package/core/built/admin/assets/{a-large-small-DxRNdz0F.js → a-large-small-BiK5_5LV.js} +1 -1
- package/core/built/admin/assets/{account-migration-SYnE1jBW.js → account-migration-DsSHanIB.js} +1 -1
- package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +1 -1
- package/core/built/admin/assets/admin-x-settings/{code-editor-view-Bj9rgbLw.mjs → code-editor-view-BEIdFhw3.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/{index-BrZ9YAzd.mjs → index-BEh3nFEy.mjs} +3 -3
- package/core/built/admin/assets/admin-x-settings/{index-DRndIh9T.mjs → index-BLF2n6YB.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/{index-CIl25Teq.mjs → index-BmPIWs5F.mjs} +3 -3
- package/core/built/admin/assets/admin-x-settings/{index-CNyNY3Wh.mjs → index-CP9YnNzc.mjs} +3 -3
- package/core/built/admin/assets/admin-x-settings/{index-SGr5sE2h.mjs → index-Cd2Ns-D2.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/{index-PGoLP2kh.mjs → index-CjqAcKpK.mjs} +5 -5
- package/core/built/admin/assets/admin-x-settings/{index-pCIv_7bB.mjs → index-CnMcD9aj.mjs} +6212 -6158
- package/core/built/admin/assets/admin-x-settings/{index-CPCcgad-.mjs → index-LzsrZC40.mjs} +3 -3
- package/core/built/admin/assets/admin-x-settings/{index-DPqY4h5h.mjs → index-ZYW0UW7I.mjs} +3 -3
- package/core/built/admin/assets/admin-x-settings/{modals-lo32WZLi.mjs → modals-C48Tq1rW.mjs} +9 -9
- package/core/built/admin/assets/{arrow-right-BixoEua4.js → arrow-right-Bk43nMGd.js} +1 -1
- package/core/built/admin/assets/{at-sign-BsHNViA_.js → at-sign-DWOf36uj.js} +1 -1
- package/core/built/admin/assets/{audience-0RCWjbIK.js → audience-CvP78Gug.js} +1 -1
- package/core/built/admin/assets/{automations-DTUfYvf6.js → automations-BBWZhrha.js} +1 -1
- package/core/built/admin/assets/{automations-DiGkcVt-.js → automations-CmulGWwu.js} +1 -1
- package/core/built/admin/assets/{avatar-flipboard-BgoH-wIq.js → avatar-flipboard-CVf-N6wp.js} +1 -1
- package/core/built/admin/assets/{bluesky-sharing-C2xwSoS-.js → bluesky-sharing-DVC3I9Um.js} +1 -1
- package/core/built/admin/assets/{chart-X5cLIH1X.js → chart-CUKSNmKY.js} +1 -1
- package/core/built/admin/assets/{checkbox-DMamS4oG.js → checkbox-B_yDSM8z.js} +1 -1
- package/core/built/admin/assets/{chevron-up-CVgBkTk5.js → chevron-up-DuaKtE_M.js} +1 -1
- package/core/built/admin/assets/{chunk.941.f47098cbbce89a049e03.js → chunk.331.2ec2e2702eda02de96cd.js} +3 -3
- package/core/built/admin/assets/{chunk.524.a1d668dcc84709a5d702.js → chunk.524.49cc01002e1a5dba8ef9.js} +4 -4
- package/core/built/admin/assets/{chunk.582.180bf2525cf552f5ac5f.js → chunk.582.4bbc54c5f7d3dfb93288.js} +6 -6
- package/core/built/admin/assets/{circle-alert-Dg3UkvRP.js → circle-alert-bcK_Yqap.js} +1 -1
- package/core/built/admin/assets/{code-editor-view-CAICoZJe.js → code-editor-view-Bl4Jzjhk.js} +1 -1
- package/core/built/admin/assets/{comments-BHru9ZQ8.js → comments-DMea0nul.js} +1 -1
- package/core/built/admin/assets/{content-helpers-BByMp-Y5.js → content-helpers-B2yRzdvJ.js} +1 -1
- package/core/built/admin/assets/{copy-D7jqplrv.js → copy-DVtQi_VJ.js} +1 -1
- package/core/built/admin/assets/{data-list-DUaY9nlT.js → data-list-BF4GeUmt.js} +1 -1
- package/core/built/admin/assets/{deleted-feed-item-jLcSKuul.js → deleted-feed-item-DO2u3qmi.js} +1 -1
- package/core/built/admin/assets/{dropzone-DSPf3N_o.js → dropzone-Cs4DrURS.js} +1 -1
- package/core/built/admin/assets/{edit-profile-BvU2-lW5.js → edit-profile-CnuBNQhO.js} +1 -1
- package/core/built/admin/assets/{editor-D3fcjt_F.js → editor-CTO9FIUW.js} +1 -1
- package/core/built/admin/assets/{empty-indicator-2Pjmce3n.js → empty-indicator-BvyaMcmw.js} +1 -1
- package/core/built/admin/assets/{en-Bu-6PYn5.js → en-CjPXRj8j.js} +1 -1
- package/core/built/admin/assets/{feed-CygLK1Ad.js → feed-D2wk4cCS.js} +1 -1
- package/core/built/admin/assets/{filter-query-core-a0PNU2EO.js → filter-query-core-VEnwTCyO.js} +1 -1
- package/core/built/admin/assets/{filters-jiyRsLMH.js → filters-DtIsuqv5.js} +1 -1
- package/core/built/admin/assets/{gh-chart-CEhBBu3F.js → gh-chart-JxzKqaoO.js} +1 -1
- package/core/built/admin/assets/{ghost-0ab96884815339963364804ab266c99f.js → ghost-30c3dedbe1cca943b92efa7efd2710e4.js} +36 -21
- package/core/built/admin/assets/{growth-Fz3Oatml.js → growth-CPNrBpHK.js} +1 -1
- package/core/built/admin/assets/{hash-CeFrjo3C.js → hash-CJzbTA5k.js} +1 -1
- package/core/built/admin/assets/{inbox-CCirPaG2.js → inbox-CVVAkCmd.js} +1 -1
- package/core/built/admin/assets/{index-Dd2BJRZH.js → index-AkQJUr1z.js} +1 -1
- package/core/built/admin/assets/{index-CYPN5csJ.js → index-BLWOUf31.js} +1 -1
- package/core/built/admin/assets/{index-D7Ui3Fih.js → index-BRBRkRqa.js} +1 -1
- package/core/built/admin/assets/{index-CFEzD2_M.js → index-BkbhBpVC.js} +3 -3
- package/core/built/admin/assets/{index-BYwL7gFN.js → index-BxFFWYdP.js} +1 -1
- package/core/built/admin/assets/{index-D3LfHeBl.js → index-C5_ZKpC1.js} +1 -1
- package/core/built/admin/assets/{index-Bjw2qAph.js → index-Cdbufdfy.js} +1 -1
- package/core/built/admin/assets/{index-BTf6YUBb.js → index-Cq5vsvxE.js} +1 -1
- package/core/built/admin/assets/{index-DPbi4ie_.js → index-CuGoX8Ub.js} +1 -1
- package/core/built/admin/assets/{index-CsaU9dfl.js → index-CuH5raTz.js} +1 -1
- package/core/built/admin/assets/{index-Bf83suMc.js → index-D3A8RTi0.js} +1 -1
- package/core/built/admin/assets/{index-Dskrbhv3.js → index-DIyK69JM.js} +1 -1
- package/core/built/admin/assets/{index-GExKMpy_.js → index-DLFVpwt3.js} +1 -1
- package/core/built/admin/assets/{index-BOMdiHRG.js → index-DQZp_NsL.js} +1 -1
- package/core/built/admin/assets/{index-2qcjFCQs.js → index-DR74ulj2.js} +1 -1
- package/core/built/admin/assets/{index-WKMARia9.js → index-DYE2untj.js} +1 -1
- package/core/built/admin/assets/{index-DNZUJQ8v.js → index-DssW7xhk.js} +1 -1
- package/core/built/admin/assets/{index-Cq4mS7QK.js → index-DvyNqFct.js} +1 -1
- package/core/built/admin/assets/{index-C7xLyJVb.js → index-DxB7NHpG.js} +1 -1
- package/core/built/admin/assets/{index-Bw4DM4Pr.js → index-IDaHqjnu.js} +1 -1
- package/core/built/admin/assets/{index-ClOB5u8L.js → index-TtBMzSeP.js} +1 -1
- package/core/built/admin/assets/{index-takfwHSn.js → index-UMWVIyz7.js} +1 -1
- package/core/built/admin/assets/{koenig-lexical-BJM9wAwQ.js → koenig-lexical-DVAmevhl.js} +1 -1
- package/core/built/admin/assets/{kpi-card-BP-2yicn.js → kpi-card-D3CzvsrI.js} +1 -1
- package/core/built/admin/assets/{kpi-card-DOplXdDT.js → kpi-card-DV-nN7xT.js} +1 -1
- package/core/built/admin/assets/{kpi-tabs-Rq5jJW2Z.js → kpi-tabs-B3NkaTwb.js} +1 -1
- package/core/built/admin/assets/{kpis-yHgME_iy.js → kpis-CVwF08QL.js} +1 -1
- package/core/built/admin/assets/{label-fijjy1Mb.js → label-BiPpMny2.js} +1 -1
- package/core/built/admin/assets/{links-s3IeAkbo.js → links-BY0Uo6O5.js} +1 -1
- package/core/built/admin/assets/{list-page-CUcB5ebB.js → list-page-COHNMpOi.js} +1 -1
- package/core/built/admin/assets/{loader-circle-C7mWzo52.js → loader-circle-B1uEfpbZ.js} +1 -1
- package/core/built/admin/assets/{mail-9TFkAaAh.js → mail-ccQhEr7k.js} +1 -1
- package/core/built/admin/assets/{main-layout-XtviVkg1.js → main-layout-BYKdA6Z1.js} +1 -1
- package/core/built/admin/assets/{members-XkHao5Yf.js → members-DkpEnvG5.js} +1 -1
- package/core/built/admin/assets/minus-C68D81nx.js +1 -0
- package/core/built/admin/assets/{modals-B6R7oqvb.js → modals-X6RSWn5Y.js} +3 -3
- package/core/built/admin/assets/{moderation-B2Tixfce.js → moderation-BFrrwfvx.js} +1 -1
- package/core/built/admin/assets/{newsletter-CbESXhxf.js → newsletter-n7kc6cxR.js} +1 -1
- package/core/built/admin/assets/{newsletters-DOIL_cOY.js → newsletters-CSCa3QdE.js} +1 -1
- package/core/built/admin/assets/{note-mUikaB-p.js → note-CMZjeK8e.js} +1 -1
- package/core/built/admin/assets/{onboarding-route-Dqv2nP7b.js → onboarding-route-BaX0a-pu.js} +1 -1
- package/core/built/admin/assets/{overview-yZ-7KJdx.js → overview-CS9dmE_6.js} +1 -1
- package/core/built/admin/assets/{pagemenu-TxGvHwRO.js → pagemenu-O_y8Jm4v.js} +1 -1
- package/core/built/admin/assets/{pencil-CKTgzafp.js → pencil-BcymGPnE.js} +1 -1
- package/core/built/admin/assets/{pin-BU_u-xRX.js → pin-Bl3nSr7I.js} +1 -1
- package/core/built/admin/assets/{post-analytics-CWA8invj.js → post-analytics-DW-TKu-t.js} +1 -1
- package/core/built/admin/assets/{post-analytics-context-BWDhMRq_.js → post-analytics-context-D7PgEMo8.js} +1 -1
- package/core/built/admin/assets/{post-analytics-header-Bm0mcxjK.js → post-analytics-header-B8C0vA5Y.js} +1 -1
- package/core/built/admin/assets/{post-share-modal-D8BtRD2u.js → post-share-modal-D0OlASBQ.js} +1 -1
- package/core/built/admin/assets/{posts-u-CAKddc.js → posts-BZaw4-dc.js} +1 -1
- package/core/built/admin/assets/{power-DR9gpiQR.js → power-DWm6pts5.js} +1 -1
- package/core/built/admin/assets/{referrers-DV_Ihg9y.js → referrers-BsZ85Z0a.js} +1 -1
- package/core/built/admin/assets/{repeat-uXmOJ6y8.js → repeat-DQay9e22.js} +1 -1
- package/core/built/admin/assets/{reply-9ZyqaxI-.js → reply-CoNqmExw.js} +1 -1
- package/core/built/admin/assets/{rocket-DMOr1OCt.js → rocket-Dne6P_8f.js} +1 -1
- package/core/built/admin/assets/{select-CPSy44ld.js → select-BfWJkrKc.js} +1 -1
- package/core/built/admin/assets/{settings-krk0432u.js → settings-BoXzKMhL.js} +1 -1
- package/core/built/admin/assets/{settings-66Zv8QvL.js → settings-rRY210wx.js} +25 -25
- package/core/built/admin/assets/{share-modal-BRmGllZq.js → share-modal-CtdLQbnQ.js} +1 -1
- package/core/built/admin/assets/{sort-button-5fyeViQL.js → sort-button-7pVgpa0d.js} +1 -1
- package/core/built/admin/assets/{source-icon-DeeHyZRn.js → source-icon-Dtua85oN.js} +1 -1
- package/core/built/admin/assets/{sprout-956Q4On2.js → sprout-CpwUJAPT.js} +1 -1
- package/core/built/admin/assets/{square-BaCvbKJY.js → square-CDrQjv8R.js} +1 -1
- package/core/built/admin/assets/{stats-BVKNZJO6.js → stats-Yo9Gco0_.js} +1 -1
- package/core/built/admin/assets/{stats-view-gtyaqudG.js → stats-view-DubukX9V.js} +1 -1
- package/core/built/admin/assets/{step-1-09wdjzdX.js → step-1-CH8jWKi7.js} +1 -1
- package/core/built/admin/assets/{step-2-8iaQ0efq.js → step-2-CWy7Qi0Q.js} +1 -1
- package/core/built/admin/assets/{step-3-BuHAZbdD.js → step-3-CNiDFH17.js} +1 -1
- package/core/built/admin/assets/{table-DTofVyjA.js → table-qQi9EXB1.js} +1 -1
- package/core/built/admin/assets/{tabs-CL5eaCGL.js → tabs-M6kFEqPE.js} +1 -1
- package/core/built/admin/assets/{tags-DJrbEw5-.js → tags-74U6lgwY.js} +1 -1
- package/core/built/admin/assets/{tags-U3lciBmd.js → tags-DuMshkTz.js} +1 -1
- package/core/built/admin/assets/{textarea-DdDuNixZ.js → textarea-CGBp9nOo.js} +1 -1
- package/core/built/admin/assets/{tiers-DXKbghdk.js → tiers-D2DYHaeO.js} +1 -1
- package/core/built/admin/assets/{toggle-group-DaZKTYDS.js → toggle-group-GYp_w6Po.js} +1 -1
- package/core/built/admin/assets/{topic-filter-BzqiVOAU.js → topic-filter-N3CzJ01P.js} +1 -1
- package/core/built/admin/assets/{trash-CKK_p9G5.js → trash-CUqAK3SQ.js} +1 -1
- package/core/built/admin/assets/{underline-Bq0P-5Zy.js → underline-CwuJG-Ql.js} +1 -1
- package/core/built/admin/assets/{undo-2-CcBtw-tW.js → undo-2-D6ZRqONE.js} +1 -1
- package/core/built/admin/assets/{upload-BcylHEMw.js → upload-BLiT66bJ.js} +1 -1
- package/core/built/admin/assets/{use-growth-stats-Ci0gymWp.js → use-growth-stats-u18So4Gq.js} +1 -1
- package/core/built/admin/assets/{use-simple-pagination-ByaFwxfZ.js → use-simple-pagination-Dp2sTt2S.js} +1 -1
- package/core/built/admin/assets/{user-round-check-D0I7NO1g.js → user-round-check-BDomHaso.js} +1 -1
- package/core/built/admin/assets/{user-round-x-DeGVH29o.js → user-round-x-Cc0RL-V9.js} +1 -1
- package/core/built/admin/assets/{virtual-list-window-S0xZxQAf.js → virtual-list-window-BnjCoNIr.js} +1 -1
- package/core/built/admin/assets/{wallet-cards-B9TQg4x3.js → wallet-cards-CVZ99Nm3.js} +1 -1
- package/core/built/admin/assets/{web-BRN2mUpo.js → web-B_MLxGxZ.js} +1 -1
- package/core/built/admin/index.html +5 -5
- package/core/server/{services/custom-redirects/file-store.js → adapters/redirects/FileStore.js} +6 -5
- package/core/server/{services/custom-redirects/file-store.ts → adapters/redirects/FileStore.ts} +6 -4
- package/core/server/adapters/redirects/RedirectsStoreBase.d.ts +9 -0
- package/core/server/adapters/redirects/RedirectsStoreBase.js +8 -0
- package/core/server/{services/custom-redirects/gcs-store.js → adapters/redirects/S3RedirectsStore.js} +50 -18
- package/core/server/adapters/redirects/S3RedirectsStore.ts +163 -0
- package/core/server/adapters/scheduling/scheduling-base.js +41 -0
- package/core/server/adapters/scheduling/types.js +2 -0
- package/core/server/adapters/scheduling/types.ts +36 -0
- package/core/server/api/endpoints/authentication.js +26 -5
- package/core/server/api/endpoints/schedules.js +0 -30
- package/core/server/api/endpoints/utils/serializers/output/authentication.js +10 -0
- package/core/server/data/migrations/versions/6.41/2026-05-13-12-00-00-rename-reset-all-passwords-permission.js +21 -0
- package/core/server/data/schema/fixtures/fixtures.json +3 -3
- package/core/server/models/api-key.js +18 -0
- package/core/server/models/user.js +18 -0
- package/core/server/services/adapter-manager/config.js +6 -0
- package/core/server/services/adapter-manager/index.js +1 -0
- package/core/server/services/auth/index.js +4 -0
- package/core/server/services/auth/reset-authentication.js +50 -0
- package/core/server/services/auth/reset-authentication.ts +75 -0
- package/core/server/services/automations/index.js +21 -15
- package/core/server/services/custom-redirects/index.js +2 -4
- package/core/server/services/gifts/gift-bookshelf-repository.js +7 -0
- package/core/server/services/gifts/gift-bookshelf-repository.ts +10 -0
- package/core/server/services/gifts/gift-reminder-scheduler.js +94 -0
- package/core/server/services/gifts/gift-reminder-scheduler.ts +109 -0
- package/core/server/services/gifts/gift-repository.ts +1 -0
- package/core/server/services/gifts/gift-service-wrapper.js +10 -7
- package/core/server/services/gifts/gift-service.js +1 -32
- package/core/server/services/gifts/gift-service.ts +3 -59
- package/core/server/services/internal-keys/index.js +1 -3
- package/core/server/services/internal-keys/index.ts +11 -3
- package/core/server/services/post-scheduling/index.js +16 -28
- package/core/server/services/post-scheduling/index.ts +14 -0
- package/core/server/services/post-scheduling/post-scheduling.js +115 -0
- package/core/server/services/post-scheduling/post-scheduling.ts +138 -0
- package/core/server/services/users.js +29 -16
- package/core/server/web/api/endpoints/admin/middleware.js +8 -4
- package/core/server/web/api/endpoints/admin/routes.js +1 -1
- package/core/shared/config/defaults.json +5 -0
- package/core/shared/labs.js +2 -1
- package/package.json +1 -1
- package/core/built/admin/assets/minus-DPrXh11r.js +0 -1
- package/core/server/services/custom-redirects/gcs-store.ts +0 -117
- 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 {
|
|
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
|
-
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
+
}
|