ghost 6.54.1 → 6.55.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/tryghost-adapter-base-scheduling-0.2.0.tgz +0 -0
- package/components/tryghost-custom-field-types-0.0.0.tgz +0 -0
- package/components/tryghost-kg-default-cards-10.3.3.tgz +0 -0
- package/components/tryghost-kg-default-nodes-2.1.3.tgz +0 -0
- package/core/boot.js +3 -6
- package/core/built/admin/assets/{account-migration-BklF2I_I.js → account-migration-sjHLx5dy.js} +1 -1
- package/core/built/admin/assets/activitypub/{account-migration-CNovr6W-.js → account-migration-DoYXtnZ8.js} +4 -4
- package/core/built/admin/assets/activitypub/activitypub.js +1 -1
- package/core/built/admin/assets/activitypub/{bluesky-sharing-Ceslb1_M.js → bluesky-sharing-Bxeh6MKW.js} +4 -4
- package/core/built/admin/assets/activitypub/{dist-DHAjBcV3.js → dist-D8sFfdpC.js} +2 -2
- package/core/built/admin/assets/activitypub/{domain-C1qQvJ8C.js → domain-CWueFW6q.js} +5 -5
- package/core/built/admin/assets/activitypub/{edit-profile-BcH-jEfM.js → edit-profile-BGxPU7N5.js} +3 -3
- package/core/built/admin/assets/activitypub/{explore-AxwBh9dI.js → explore-DBK-u0Ch.js} +3 -3
- package/core/built/admin/assets/activitypub/{feed-BaOlbh7a.js → feed-Bcir5RQZ.js} +3 -3
- package/core/built/admin/assets/activitypub/{field-B7mYYQpN.js → field-BV-CIT3d.js} +2 -2
- package/core/built/admin/assets/activitypub/{inbox-vXez-5uJ.js → inbox-yC0nfvzl.js} +4 -4
- package/core/built/admin/assets/activitypub/{label-Chv5T8bw.js → label-DTRS9TDk.js} +2 -2
- package/core/built/admin/assets/activitypub/layout-qx-bUL1k.js +1 -0
- package/core/built/admin/assets/activitypub/{moderation-DtSM2i1I.js → moderation-Dwl9t0pb.js} +4 -4
- package/core/built/admin/assets/activitypub/{note-Dckp4S-2.js → note-B-zKsZ7r.js} +3 -3
- package/core/built/admin/assets/activitypub/{notifications-CG5T22vg.js → notifications-DjJZD54t.js} +3 -3
- package/core/built/admin/assets/activitypub/{preferences-D_799jdl.js → preferences-CoU8vs6d.js} +6 -6
- package/core/built/admin/assets/activitypub/{profile-D2JfXBQl.js → profile-BAsaJekx.js} +6 -6
- package/core/built/admin/assets/activitypub/{routes-CCHe2dQp.js → routes-CLH3OVTP.js} +18 -18
- package/core/built/admin/assets/activitypub/{settings-kx-9Enkz.js → settings-DA6pNJ-Q.js} +3 -3
- package/core/built/admin/assets/activitypub/{site-x4_IjVJ0.js → site-CQD42Pw5.js} +2 -2
- package/core/built/admin/assets/activitypub/{tabs-CyPRQQ1N.js → tabs-Cg0saow1.js} +7 -7
- package/core/built/admin/assets/activitypub/{use-reply-chain-data-BAB6fNGt.js → use-reply-chain-data-Br4aOWOh.js} +2 -2
- package/core/built/admin/assets/audience-DjEWNhp3.js +1 -0
- package/core/built/admin/assets/{automated-emails-of1MvVsv.js → automated-emails-DRy7qY-J.js} +2 -2
- package/core/built/admin/assets/{automations-DRFjW5fw.js → automations-Bx3VcFIL.js} +1 -1
- package/core/built/admin/assets/{automations-B7-y3dXt.js → automations-DhKeS_U5.js} +1 -1
- package/core/built/admin/assets/{bluesky-sharing-BbbjWdzs.js → bluesky-sharing-ClMd0-Ft.js} +1 -1
- package/core/built/admin/assets/{breadcrumb-B-M9Ip_2.js → breadcrumb-BD0U2ygg.js} +1 -1
- package/core/built/admin/assets/{chart-helpers-xiSsW8r0.js → chart-helpers-fjQKRVyY.js} +1 -1
- package/core/built/admin/assets/{checkbox-EhJKHFdZ.js → checkbox-CWa28lgO.js} +1 -1
- package/core/built/admin/assets/{chunk.361.3cdb584b973959e52ccc.js → chunk.481.6c7395a49484bb4583aa.js} +41 -38
- package/core/built/admin/assets/{chunk.524.8365cd9280405c0f6216.js → chunk.524.cffd399783a519e404a9.js} +4 -4
- package/core/built/admin/assets/{chunk.582.61d078937e6102769f2c.js → chunk.582.eebf0d0b2d202bb746a5.js} +6 -6
- package/core/built/admin/assets/{code-editor-view-CZtJFGhR.js → code-editor-view-CCr0rBWS.js} +1 -1
- package/core/built/admin/assets/comments-DyUSMUdj.js +1 -0
- package/core/built/admin/assets/{date-range-select-CTlyo8zW.js → date-range-select-BFp58EMk.js} +1 -1
- package/core/built/admin/assets/{disabled-sources-indicator-qs2GfLvl.js → disabled-sources-indicator-CUxMfiPa.js} +1 -1
- package/core/built/admin/assets/domain-C0bV2UV8.js +1 -0
- package/core/built/admin/assets/{dropzone-BmCXnw6J.js → dropzone-DlK-K2DQ.js} +1 -1
- package/core/built/admin/assets/{edit-profile-SGrJve5M.js → edit-profile-BKT-REJw.js} +1 -1
- package/core/built/admin/assets/editor-Dp865O-4.js +8 -0
- package/core/built/admin/assets/{explore-CUYLv9G9.js → explore-ClUpJZOp.js} +1 -1
- package/core/built/admin/assets/{feed-CPdJMttL.js → feed-DF92dUMP.js} +1 -1
- package/core/built/admin/assets/field-Do7lieBA.js +1 -0
- package/core/built/admin/assets/{filter-sources-Bieps75g.js → filter-sources-BV2rnpfA.js} +1 -1
- package/core/built/admin/assets/filters-CUXJ2Nm9.js +1 -0
- package/core/built/admin/assets/{filters-CuB-A7ma.js → filters-DfbIWc_l.js} +1 -1
- package/core/built/admin/assets/{ghost-3063eae29e6c9cc6d6e7131928a27da0.css → ghost-a9c788f6f0b1ea6cf0f14ca4b13f4496.css} +1 -1
- package/core/built/admin/assets/{ghost-05f9a7182ed56903dba05878a5b8422b.js → ghost-c4057474943effc568c5659ccc4909d7.js} +190 -190
- package/core/built/admin/assets/{ghost-dark-c8d0ab7f515b2010c173e23e9b32ea8a.css → ghost-dark-db36d795d21c61426837df40af270966.css} +1 -1
- package/core/built/admin/assets/gift-link-modal-f9VIMNDv.js +1 -0
- package/core/built/admin/assets/{gift-links-nBxJjZiQ.js → gift-links-CsUhefWG.js} +1 -1
- package/core/built/admin/assets/growth-B3I6gNLm.js +1 -0
- package/core/built/admin/assets/growth-CVw1My4S.js +3 -0
- package/core/built/admin/assets/{hooks-BApwwBE0.js → hooks-CixO01lL.js} +1 -1
- package/core/built/admin/assets/{inbox-Xx1CdnfO.js → inbox-CFBSu4Ed.js} +13 -13
- package/core/built/admin/assets/index-9Cw8z3rA.css +1 -0
- package/core/built/admin/assets/index-BGG7NckO.js +67 -0
- package/core/built/admin/assets/{input-group-CGOXcy4l.js → input-group-BSgG_lVw.js} +1 -1
- package/core/built/admin/assets/koenig-lexical/koenig-lexical.js +974 -937
- package/core/built/admin/assets/koenig-lexical/koenig-lexical.umd.js +28 -28
- package/core/built/admin/assets/koenig-lexical/style.css +1 -1
- package/core/built/admin/assets/{koenig-lexical-B61IALf5.js → koenig-lexical-f8vHRYvz.js} +28 -28
- package/core/built/admin/assets/{kpi-card-DCdlyaSS.js → kpi-card-DSap9rAy.js} +1 -1
- package/core/built/admin/assets/{kpi-card-BlZaG1Lo.js → kpi-card-DU0rn9Cj.js} +1 -1
- package/core/built/admin/assets/{kpi-helpers-DpAuS1Nb.js → kpi-helpers-Dz33AV6h.js} +1 -1
- package/core/built/admin/assets/{kpi-tabs-Dme44bQS.js → kpi-tabs-BtTTrKvJ.js} +1 -1
- package/core/built/admin/assets/{label-dSs5aepE.js → label-DS8Mi6G5.js} +1 -1
- package/core/built/admin/assets/label-picker-1DvSuLA1.js +1 -0
- package/core/built/admin/assets/labels-CDSUt4J_.js +1 -0
- package/core/built/admin/assets/layout-yQEi2qDM.js +1 -0
- package/core/built/admin/assets/{link-helpers-tOlxmuH5.js → link-helpers-7EYqvvHs.js} +1 -1
- package/core/built/admin/assets/member-custom-fields-yOMHyAg9.js +1 -0
- package/core/built/admin/assets/member-detail-BAq7gDaa.js +1 -0
- package/core/built/admin/assets/members-CQpVFJu4.js +10 -0
- package/core/built/admin/assets/modals-ReORvWnu.js +31 -0
- package/core/built/admin/assets/moderation-DySbgyUS.js +1 -0
- package/core/built/admin/assets/newsletter-CJQvtHqN.js +1 -0
- package/core/built/admin/assets/newsletters-B3QQXfGT.js +1 -0
- package/core/built/admin/assets/{newsletters-D1jbtV-h.js → newsletters-BXJbJgA-.js} +1 -1
- package/core/built/admin/assets/note-C8kxrz6w.js +1 -0
- package/core/built/admin/assets/notifications-CgtSfX7t.js +1 -0
- package/core/built/admin/assets/{offers-CrY6nSRR.js → offers-DEniVRD5.js} +1 -1
- package/core/built/admin/assets/onboarding-route-4qtziA9d.js +1 -0
- package/core/built/admin/assets/overview-Bf2sbznq.js +1 -0
- package/core/built/admin/assets/overview-CBfePxcr.js +1 -0
- package/core/built/admin/assets/{page-header-op0eN36I.js → page-header-B3Wb9Jqv.js} +1 -1
- package/core/built/admin/assets/{pages-BbNWFcGj.js → pages-B4goRPUn.js} +1 -1
- package/core/built/admin/assets/{post-analytics-WwRDEnrV.js → post-analytics-We-ko5Cb.js} +1 -1
- package/core/built/admin/assets/post-analytics-header-Bmc9su6q.js +1 -0
- package/core/built/admin/assets/post-analytics-provider-B6UynTGb.js +1 -0
- package/core/built/admin/assets/{post-share-modal-Dx-MEjhI.js → post-share-modal-DFfO93_0.js} +1 -1
- package/core/built/admin/assets/{posts-EWpZIjkr.js → posts-DhXE7b6r.js} +1 -1
- package/core/built/admin/assets/preferences-DJPfHEGu.js +5 -0
- package/core/built/admin/assets/profile-DqBQp-gT.js +1 -0
- package/core/built/admin/assets/{referrers-BCfFYSHc.js → referrers-pmJT4k3-.js} +1 -1
- package/core/built/admin/assets/{select-C1qDZnW4.js → select-C5_xEt5b.js} +1 -1
- package/core/built/admin/assets/settings-BwiVIXau.js +4 -0
- package/core/built/admin/assets/{settings-H6HxFgfI.js → settings-WXNeWyU1.js} +1 -1
- package/core/built/admin/assets/{share-modal-BJCadJyp.js → share-modal-DsgW3wbg.js} +1 -1
- package/core/built/admin/assets/social-urls-BQPFEk1_.js +8 -0
- package/core/built/admin/assets/{sort-button-DyYlfYp7.js → sort-button-Bb_FCl7e.js} +1 -1
- package/core/built/admin/assets/{source-icon-_ubRDbg_.js → source-icon-BeaRxUn9.js} +1 -1
- package/core/built/admin/assets/{stats-BQZMGMvn.js → stats-Bjj_kmJ1.js} +1 -1
- package/core/built/admin/assets/{stats-view-Doei-qCX.js → stats-view-BxPbW6K1.js} +1 -1
- package/core/built/admin/assets/{step-1-IKiLBPoC.js → step-1-B1e3-1F8.js} +1 -1
- package/core/built/admin/assets/{step-2-jJ41PsDN.js → step-2-C5WaeBJo.js} +1 -1
- package/core/built/admin/assets/{step-3-CaqFAEqf.js → step-3-B46iVPwu.js} +2 -2
- package/core/built/admin/assets/{switch-t-WyENU2.js → switch-z5Y6Uzt6.js} +1 -1
- package/core/built/admin/assets/{table-DxUL78jU.js → table-CB-4_HBJ.js} +1 -1
- package/core/built/admin/assets/tabs-CCEgKYzq.js +1 -0
- package/core/built/admin/assets/{tags-CNujGwkT.js → tags-Cg2VJGmG.js} +1 -1
- package/core/built/admin/assets/{tiers-BgYLWRJv.js → tiers-DYsa4DCo.js} +1 -1
- package/core/built/admin/assets/{toggle-group-BeEpQ69p.js → toggle-group-b3IlxvhV.js} +1 -1
- package/core/built/admin/assets/{topic-filter-Bb5MOD-1.js → topic-filter-DAFT87tQ.js} +1 -1
- package/core/built/admin/assets/{use-analytics-data-Cds6XDjg.js → use-analytics-data-S0FTd43s.js} +1 -1
- package/core/built/admin/assets/use-filter-params-Gpoq2E1L.js +1 -0
- package/core/built/admin/assets/{use-growth-stats-D8uf98-G.js → use-growth-stats-CIaiHOVb.js} +1 -1
- package/core/built/admin/assets/{use-post-referrers-CRtgyuXB.js → use-post-referrers-Cq2vBPrA.js} +1 -1
- package/core/built/admin/assets/{use-reply-chain-data-DbKYX50v.js → use-reply-chain-data-DBDRtGu-.js} +1 -1
- package/core/built/admin/assets/{use-simple-pagination-DC01kpo0.js → use-simple-pagination-CjYNxdwe.js} +1 -1
- package/core/built/admin/assets/{use-tinybird-query-C7bQRJWw.js → use-tinybird-query-falHAtuK.js} +1 -1
- package/core/built/admin/assets/{use-tinybird-token-Az4tam7_.js → use-tinybird-token-D7-8ND1y.js} +1 -1
- package/core/built/admin/assets/{users-CcSCQFcQ.js → users-Dkv8NQZe.js} +1 -1
- package/core/built/admin/assets/{virtual-list-Dhuh9fGx.js → virtual-list-CvnC9zF2.js} +1 -1
- package/core/built/admin/assets/web-B_5GZRvH.js +1 -0
- package/core/built/admin/assets/web-jFYWCKDn.js +2 -0
- package/core/built/admin/index.html +9 -9
- package/core/frontend/services/proxy.js +7 -8
- package/core/frontend/services/routing/router-manager.js +14 -14
- package/core/frontend/services/routing/static-routes-router.js +8 -10
- package/core/server/api/endpoints/automation-action-links.js +68 -0
- package/core/server/api/endpoints/automation-action-links.ts +46 -0
- package/core/server/api/endpoints/index.js +4 -0
- package/core/server/api/endpoints/settings.js +0 -2
- package/core/server/api/endpoints/utils/serializers/output/utils/url.js +11 -1
- package/core/server/data/migrations/versions/6.55/2026-07-27-11-25-44-add-to-hash-to-redirects.js +51 -0
- package/core/server/data/migrations/versions/6.55/2026-07-28-12-08-00-remove-routes-hash-from-settings.js +5 -0
- package/core/server/data/schema/commands.js +5 -0
- package/core/server/data/schema/default-settings/default-settings.json +0 -4
- package/core/server/data/schema/schema.js +5 -1
- package/core/server/services/automations/automations-api.js +15 -0
- package/core/server/services/automations/automations-api.ts +19 -0
- package/core/server/services/automations/automations-repository.ts +18 -0
- package/core/server/services/automations/database-automations-repository.js +109 -26
- package/core/server/services/automations/database-automations-repository.ts +110 -7
- package/core/server/services/automations/database-date.js +10 -0
- package/core/server/services/automations/database-date.ts +5 -0
- package/core/server/services/automations/poll.js +5 -2
- package/core/server/services/automations/poll.ts +7 -2
- package/core/server/services/automations/scheduler-poll-time.js +40 -0
- package/core/server/services/automations/scheduler-poll-time.ts +38 -0
- package/core/server/services/automations/service.js +15 -3
- package/core/server/services/automations/service.ts +16 -3
- package/core/server/services/comments/comments-service-emails.js +10 -7
- package/core/server/services/comments/comments-service.js +9 -4
- package/core/server/services/email-rendering/partials/email-wrapper.hbs +3 -1
- package/core/server/services/email-service/email-service-wrapper.js +6 -1
- package/core/server/services/email-service/email-templates/partials/styles.hbs +8 -0
- package/core/server/services/email-service/newsletter-email-event-storage.js +41 -10
- package/core/server/services/lib/dynamic-redirect-manager.js +12 -3
- package/core/server/services/link-redirection/link-redirect-repository.js +41 -4
- package/core/server/services/link-redirection/link-redirect.js +2 -0
- package/core/server/services/link-redirection/link-redirect.ts +3 -1
- package/core/server/services/link-redirection/link-redirects-service.js +38 -2
- package/core/server/services/link-tracking/click-event.js +3 -0
- package/core/server/services/link-tracking/index.js +38 -6
- package/core/server/services/link-tracking/link-click-repository.js +30 -9
- package/core/server/services/link-tracking/link-click-tracking-service.js +44 -3
- package/core/server/services/member-attribution/outbound-link-tagger.js +3 -1
- package/core/server/services/member-welcome-emails/member-welcome-email-renderer.js +19 -4
- package/core/server/services/member-welcome-emails/service.js +11 -3
- package/core/server/services/members/import-export/csv/formula.js +16 -0
- package/core/server/services/members/import-export/csv/formula.ts +14 -0
- package/core/server/services/members/import-export/csv/index.js +3 -1
- package/core/server/services/members/import-export/csv/index.ts +1 -0
- package/core/server/services/members/import-export/import/completion-email.js +22 -3
- package/core/server/services/members/import-export/import/completion-email.ts +29 -7
- package/core/server/services/members/import-export/import/importer.js +25 -4
- package/core/server/services/members/import-export/import/importer.ts +42 -4
- package/core/server/services/members/import-export/index.js +9 -0
- package/core/server/services/members/import-export/index.ts +20 -1
- package/core/server/services/members/service.js +8 -1
- package/core/server/services/members-custom-fields/values-service.js +19 -7
- package/core/server/services/members-custom-fields/values-service.ts +19 -7
- package/core/server/services/mentions/service.js +5 -1
- package/core/server/services/route-settings/activation-bridge.js +59 -49
- package/core/server/services/route-settings/activation-bridge.ts +91 -51
- package/core/server/services/route-settings/dynamic-routing-service.js +22 -51
- package/core/server/services/route-settings/index.js +1 -16
- package/core/server/services/settings/settings-service.js +0 -18
- package/core/server/services/url/url-service-facade.js +1 -1
- package/core/server/services/url/url-service-facade.ts +8 -1
- package/core/server/web/api/endpoints/admin/routes.js +1 -0
- package/package.json +7 -7
- package/pnpm-lock.yaml +8 -8
- package/pnpm-workspace.yaml +5 -4
- package/core/built/admin/assets/Growth-BTP1LthE.js +0 -3
- package/core/built/admin/assets/Newsletters-CSEX0myc.js +0 -1
- package/core/built/admin/assets/Overview-BjACEsJF.js +0 -1
- package/core/built/admin/assets/Web-CxxxjCcv.js +0 -2
- package/core/built/admin/assets/activitypub/layout-CziktJXo.js +0 -1
- package/core/built/admin/assets/audience-X_XsZivI.js +0 -1
- package/core/built/admin/assets/comments-CvyXxOyN.js +0 -1
- package/core/built/admin/assets/constants-c-JNF9c2.js +0 -1
- package/core/built/admin/assets/domain-CPNXKVOV.js +0 -1
- package/core/built/admin/assets/editor-BOG2YPUm.js +0 -8
- package/core/built/admin/assets/field-D0XISCK2.js +0 -1
- package/core/built/admin/assets/filters-bXkkWro8.js +0 -1
- package/core/built/admin/assets/gift-link-modal-su8MsRdg.js +0 -1
- package/core/built/admin/assets/growth-Ba6S5FQX.js +0 -1
- package/core/built/admin/assets/index-D8PbzuT9.css +0 -1
- package/core/built/admin/assets/index-ukW_0Uie.js +0 -67
- package/core/built/admin/assets/label-picker-Uq91atex.js +0 -1
- package/core/built/admin/assets/labels-Cyayfd-e.js +0 -1
- package/core/built/admin/assets/layout-WUcz6tu1.js +0 -1
- package/core/built/admin/assets/member-custom-fields-NCzrMuba.js +0 -1
- package/core/built/admin/assets/member-detail-DPWDtYnN.js +0 -1
- package/core/built/admin/assets/members-B9rO7qr4.js +0 -10
- package/core/built/admin/assets/modals-CxRaan1J.js +0 -31
- package/core/built/admin/assets/moderation-D8zon60E.js +0 -1
- package/core/built/admin/assets/multi-select-combobox-1wYm3nle.js +0 -1
- package/core/built/admin/assets/newsletter-DbYMRxRS.js +0 -1
- package/core/built/admin/assets/note-PiWPnnrh.js +0 -1
- package/core/built/admin/assets/notifications-DEgqqB9G.js +0 -1
- package/core/built/admin/assets/onboarding-route-BzF6HeVn.js +0 -1
- package/core/built/admin/assets/overview-DixLeIqY.js +0 -1
- package/core/built/admin/assets/post-analytics-header-DsMoLx0R.js +0 -1
- package/core/built/admin/assets/post-analytics-provider-BJCPLMIb.js +0 -1
- package/core/built/admin/assets/preferences-D7-j6A4C.js +0 -5
- package/core/built/admin/assets/profile-Dl69QPp0.js +0 -1
- package/core/built/admin/assets/settings-BbmC_Iqq.js +0 -4
- package/core/built/admin/assets/social-urls-BPL1nADp.js +0 -8
- package/core/built/admin/assets/tabs-BQcJQ1ke.js +0 -1
- package/core/built/admin/assets/use-filter-params-DjXkhO54.js +0 -1
- package/core/built/admin/assets/web-LtHNiNBV.js +0 -1
- package/core/server/services/route-settings/default-settings-manager.js +0 -63
- package/core/server/services/route-settings/route-settings.js +0 -170
- package/core/server/services/route-settings/settings-loader.js +0 -60
- package/core/server/services/route-settings/settings-path-manager.js +0 -47
- package/core/server/services/route-settings/validate.js +0 -440
|
@@ -10,7 +10,7 @@ const moment = require('moment');
|
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* @typedef {object} ILinkClickRepository
|
|
13
|
-
* @prop {(event: LinkClick) => Promise<
|
|
13
|
+
* @prop {(event: LinkClick, options?: {transacting?: object}) => Promise<string | undefined>} save
|
|
14
14
|
* @prop {({filter: string}) => Promise<LinkClick[]>} getAll
|
|
15
15
|
*/
|
|
16
16
|
|
|
@@ -61,6 +61,10 @@ class LinkClickTrackingService {
|
|
|
61
61
|
#LinkRedirect;
|
|
62
62
|
/** @type {Object} */
|
|
63
63
|
#urlUtils;
|
|
64
|
+
/** @type {object} */
|
|
65
|
+
#automationsApi;
|
|
66
|
+
/** @type {(callback: (transacting: object) => Promise<void>) => Promise<void>} */
|
|
67
|
+
#runInTransaction;
|
|
64
68
|
|
|
65
69
|
/**
|
|
66
70
|
* @param {object} deps
|
|
@@ -69,6 +73,8 @@ class LinkClickTrackingService {
|
|
|
69
73
|
* @param {IPostLinkRepository} deps.postLinkRepository
|
|
70
74
|
* @param {DomainEvents} deps.DomainEvents
|
|
71
75
|
* @param {urlUtils} deps.urlUtils
|
|
76
|
+
* @param {object} deps.automationsApi
|
|
77
|
+
* @param {(callback: (transacting: object) => Promise<void>) => Promise<void>} deps.runInTransaction
|
|
72
78
|
*/
|
|
73
79
|
constructor(deps) {
|
|
74
80
|
this.#linkClickRepository = deps.linkClickRepository;
|
|
@@ -76,6 +82,8 @@ class LinkClickTrackingService {
|
|
|
76
82
|
this.#postLinkRepository = deps.postLinkRepository;
|
|
77
83
|
this.#DomainEvents = deps.DomainEvents;
|
|
78
84
|
this.#urlUtils = deps.urlUtils;
|
|
85
|
+
this.#automationsApi = deps.automationsApi;
|
|
86
|
+
this.#runInTransaction = deps.runInTransaction;
|
|
79
87
|
}
|
|
80
88
|
|
|
81
89
|
async init() {
|
|
@@ -233,6 +241,20 @@ class LinkClickTrackingService {
|
|
|
233
241
|
return url;
|
|
234
242
|
}
|
|
235
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Add first-party tracking to a URL in an automation email
|
|
246
|
+
* @param {Readonly<URL>} url
|
|
247
|
+
* @param {string} automationActionRevisionId
|
|
248
|
+
* @param {string} memberUuid
|
|
249
|
+
* @return {Promise<URL>}
|
|
250
|
+
*/
|
|
251
|
+
async addAutomationTrackingToUrl(url, automationActionRevisionId, memberUuid) {
|
|
252
|
+
const redirect = await this.#linkRedirectService.getOrAddAutomationRedirect(automationActionRevisionId, url);
|
|
253
|
+
const trackedUrl = new URL(redirect.from.href);
|
|
254
|
+
trackedUrl.searchParams.set('m', memberUuid);
|
|
255
|
+
return trackedUrl;
|
|
256
|
+
}
|
|
257
|
+
|
|
236
258
|
subscribe() {
|
|
237
259
|
this.#DomainEvents.subscribe(RedirectEvent, async (event) => {
|
|
238
260
|
const uuid = event.data.url.searchParams.get('m');
|
|
@@ -242,9 +264,28 @@ class LinkClickTrackingService {
|
|
|
242
264
|
|
|
243
265
|
const click = new LinkClick({
|
|
244
266
|
member_uuid: uuid,
|
|
245
|
-
link_id: event.data.link.link_id
|
|
267
|
+
link_id: event.data.link.link_id,
|
|
268
|
+
timestamp: event.timestamp
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const automationActionRevisionId = event.data.link.automationActionRevisionId;
|
|
272
|
+
if (!automationActionRevisionId) {
|
|
273
|
+
await this.#linkClickRepository.save(click);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
await this.#runInTransaction(async (transacting) => {
|
|
278
|
+
const memberId = await this.#linkClickRepository.save(click, {transacting});
|
|
279
|
+
if (!memberId) {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
await this.#automationsApi.trackEmailClicked({
|
|
284
|
+
automationActionRevisionId,
|
|
285
|
+
memberId,
|
|
286
|
+
clickedAt: event.timestamp
|
|
287
|
+
}, {transacting});
|
|
246
288
|
});
|
|
247
|
-
await this.#linkClickRepository.save(click);
|
|
248
289
|
});
|
|
249
290
|
}
|
|
250
291
|
}
|
|
@@ -11,7 +11,9 @@ const blockedReferrerDomains = [
|
|
|
11
11
|
'www.chicagomag.com',
|
|
12
12
|
'bailii.org',
|
|
13
13
|
'www.bailii.org',
|
|
14
|
-
'*.doubleclick.net'
|
|
14
|
+
'*.doubleclick.net',
|
|
15
|
+
// Substack's client app throws on any non-empty ?ref= value and swaps the post for an error screen
|
|
16
|
+
'*.substack.com'
|
|
15
17
|
];
|
|
16
18
|
|
|
17
19
|
const isBlockedReferrerDomain = (domain) => {
|
|
@@ -2,11 +2,13 @@ const fs = require('fs');
|
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const lexicalLib = require('../../lib/lexical');
|
|
4
4
|
const labs = require('../../../shared/labs');
|
|
5
|
+
const urlUtils = require('../../../shared/url-utils').default;
|
|
5
6
|
const {finalize} = require('../email-rendering/finalize');
|
|
6
7
|
const errors = require('@tryghost/errors');
|
|
7
8
|
const {MESSAGES} = require('./constants');
|
|
8
9
|
const {wrapReplacementStrings} = require('@tryghost/kg-default-nodes').utils.replacementStrings;
|
|
9
10
|
const linkReplacer = require('../lib/link-replacer');
|
|
11
|
+
const linkTracking = require('../link-tracking');
|
|
10
12
|
const emailDesign = require('../email-rendering/email-design');
|
|
11
13
|
const {registerHelpers} = require('../email-service/helpers/register-helpers');
|
|
12
14
|
|
|
@@ -115,9 +117,11 @@ class MemberWelcomeEmailRenderer {
|
|
|
115
117
|
* @param {Object} options.member - Member data (name, email)
|
|
116
118
|
* @param {Object} options.siteSettings - Site settings (title, url, accentColor)
|
|
117
119
|
* @param {string} [options.unsubscribeUrl] - When set, the footer shows an "Unsubscribe from these emails" link instead of "Manage your preferences"
|
|
120
|
+
* @param {boolean} [options.trackClicks]
|
|
121
|
+
* @param {string | null} [options.automationActionRevisionId]
|
|
118
122
|
* @returns {Promise<{html: string, text: string, subject: string}>}
|
|
119
123
|
*/
|
|
120
|
-
async render({lexical, subject, designSettings, member, siteSettings, unsubscribeUrl}) {
|
|
124
|
+
async render({lexical, subject, designSettings, member, siteSettings, unsubscribeUrl, trackClicks = false, automationActionRevisionId = null}) {
|
|
121
125
|
designSettings = designSettings || {};
|
|
122
126
|
|
|
123
127
|
const design = emailDesign.getEmailDesign({
|
|
@@ -138,7 +142,8 @@ class MemberWelcomeEmailRenderer {
|
|
|
138
142
|
|
|
139
143
|
let content;
|
|
140
144
|
try {
|
|
141
|
-
|
|
145
|
+
const absoluteLexical = urlUtils.transformReadyToAbsolute(lexical);
|
|
146
|
+
content = await lexicalLib.render(absoluteLexical, {target: 'email', design});
|
|
142
147
|
} catch (err) {
|
|
143
148
|
throw new errors.IncorrectUsageError({
|
|
144
149
|
message: MESSAGES.INVALID_LEXICAL_STRUCTURE,
|
|
@@ -158,8 +163,18 @@ class MemberWelcomeEmailRenderer {
|
|
|
158
163
|
const contentWithReplacements = this.#applyReplacements({definitions, text: content, escapeHtml: true});
|
|
159
164
|
const subjectWithReplacements = this.#applyReplacements({definitions, text: subject, escapeHtml: false});
|
|
160
165
|
|
|
161
|
-
// Resolve relative links
|
|
162
|
-
|
|
166
|
+
// Resolve relative links, including Ghost Portal routes such as #/portal/signup,
|
|
167
|
+
// while preserving document-local anchors such as #section.
|
|
168
|
+
const contentWithAbsoluteLinks = await linkReplacer.replace(contentWithReplacements, async (url, originalPath) => {
|
|
169
|
+
const isDocumentAnchor = originalPath.startsWith('#') && !originalPath.startsWith('#/');
|
|
170
|
+
if (isDocumentAnchor) {
|
|
171
|
+
return originalPath;
|
|
172
|
+
}
|
|
173
|
+
const isTrackable = ['http:', 'https:'].includes(url.protocol);
|
|
174
|
+
if (trackClicks && automationActionRevisionId && member.uuid && isTrackable) {
|
|
175
|
+
await linkTracking.init();
|
|
176
|
+
return await linkTracking.service.addAutomationTrackingToUrl(url, automationActionRevisionId, member.uuid);
|
|
177
|
+
}
|
|
163
178
|
return url;
|
|
164
179
|
}, {base: siteSettings.url});
|
|
165
180
|
|
|
@@ -368,10 +368,12 @@ class MemberWelcomeEmailService {
|
|
|
368
368
|
* @param {null | object} options.email.designSettings
|
|
369
369
|
* @param {'welcome' | 'automation'} options.emailType
|
|
370
370
|
* @param {boolean} [options.trackOpens]
|
|
371
|
+
* @param {boolean} [options.trackClicks]
|
|
372
|
+
* @param {string | null} [options.automationActionRevisionId]
|
|
371
373
|
* @param {null | {url: string, oneClickUrl: string}} [options.unsubscribe] - When set, the footer links to an unsubscribe URL and the email carries one-click List-Unsubscribe headers
|
|
372
374
|
* @returns {Promise<unknown>}
|
|
373
375
|
*/
|
|
374
|
-
async #sendEmail({member, memberStatus, email, emailType, trackOpens, unsubscribe = null}) {
|
|
376
|
+
async #sendEmail({member, memberStatus, email, emailType, trackOpens, trackClicks = false, automationActionRevisionId = null, unsubscribe = null}) {
|
|
375
377
|
if (!member.email) {
|
|
376
378
|
throw new errors.IncorrectUsageError({
|
|
377
379
|
message: MESSAGES.MISSING_RECIPIENT_EMAIL
|
|
@@ -396,7 +398,9 @@ class MemberWelcomeEmailService {
|
|
|
396
398
|
uuid: member.uuid
|
|
397
399
|
},
|
|
398
400
|
siteSettings: this.#getSiteSettings(),
|
|
399
|
-
unsubscribeUrl: unsubscribe?.url
|
|
401
|
+
unsubscribeUrl: unsubscribe?.url,
|
|
402
|
+
trackClicks,
|
|
403
|
+
automationActionRevisionId
|
|
400
404
|
});
|
|
401
405
|
|
|
402
406
|
const senderOptions = await this.#getEffectiveSenderOptions(
|
|
@@ -474,9 +478,11 @@ class MemberWelcomeEmailService {
|
|
|
474
478
|
* @param {string} options.member.uuid
|
|
475
479
|
* @param {'free' | 'paid'} options.memberStatus
|
|
476
480
|
* @param {boolean} options.trackOpens
|
|
481
|
+
* @param {boolean} options.trackClicks
|
|
482
|
+
* @param {string} options.automationActionRevisionId
|
|
477
483
|
* @returns {Promise<unknown>}
|
|
478
484
|
*/
|
|
479
|
-
async sendAutomationEmail({email, member, memberStatus, trackOpens}) {
|
|
485
|
+
async sendAutomationEmail({email, member, memberStatus, trackOpens, trackClicks, automationActionRevisionId}) {
|
|
480
486
|
const designSettings = email.designSettingId ?
|
|
481
487
|
await EmailDesignSetting.findOne({id: email.designSettingId}) :
|
|
482
488
|
null;
|
|
@@ -502,6 +508,8 @@ class MemberWelcomeEmailService {
|
|
|
502
508
|
designSettings: designSettingsJson
|
|
503
509
|
},
|
|
504
510
|
trackOpens,
|
|
511
|
+
trackClicks,
|
|
512
|
+
automationActionRevisionId,
|
|
505
513
|
unsubscribe
|
|
506
514
|
});
|
|
507
515
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.stripFormulaGuard = stripFormulaGuard;
|
|
4
|
+
// The export writes with papaparse's escapeFormulae on (see serialize), which prefixes a
|
|
5
|
+
// cell starting with one of these with an apostrophe so a spreadsheet won't read it as a
|
|
6
|
+
// formula: `=SUM(A1)` is written as `'=SUM(A1)`.
|
|
7
|
+
const FORMULA_TRIGGERS = ['=', '+', '-', '@', '\t', '\r'];
|
|
8
|
+
// Strip that guard back off, so a re-imported value doesn't gain an apostrophe on every
|
|
9
|
+
// round trip. Only a lone apostrophe before a trigger is the guard; one before anything
|
|
10
|
+
// else is a character the value actually starts with, and is left alone.
|
|
11
|
+
function stripFormulaGuard(cell) {
|
|
12
|
+
if (cell.charAt(0) === '\'' && FORMULA_TRIGGERS.includes(cell.charAt(1))) {
|
|
13
|
+
return cell.slice(1);
|
|
14
|
+
}
|
|
15
|
+
return cell;
|
|
16
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// The export writes with papaparse's escapeFormulae on (see serialize), which prefixes a
|
|
2
|
+
// cell starting with one of these with an apostrophe so a spreadsheet won't read it as a
|
|
3
|
+
// formula: `=SUM(A1)` is written as `'=SUM(A1)`.
|
|
4
|
+
const FORMULA_TRIGGERS = ['=', '+', '-', '@', '\t', '\r'];
|
|
5
|
+
|
|
6
|
+
// Strip that guard back off, so a re-imported value doesn't gain an apostrophe on every
|
|
7
|
+
// round trip. Only a lone apostrophe before a trigger is the guard; one before anything
|
|
8
|
+
// else is a character the value actually starts with, and is left alone.
|
|
9
|
+
export function stripFormulaGuard(cell: string): string {
|
|
10
|
+
if (cell.charAt(0) === '\'' && FORMULA_TRIGGERS.includes(cell.charAt(1))) {
|
|
11
|
+
return cell.slice(1);
|
|
12
|
+
}
|
|
13
|
+
return cell;
|
|
14
|
+
}
|
|
@@ -3,8 +3,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.serialize = exports.parse = void 0;
|
|
6
|
+
exports.stripFormulaGuard = exports.serialize = exports.parse = void 0;
|
|
7
7
|
var parse_1 = require("./parse");
|
|
8
8
|
Object.defineProperty(exports, "parse", { enumerable: true, get: function () { return __importDefault(parse_1).default; } });
|
|
9
9
|
var serialize_1 = require("./serialize");
|
|
10
10
|
Object.defineProperty(exports, "serialize", { enumerable: true, get: function () { return __importDefault(serialize_1).default; } });
|
|
11
|
+
var formula_1 = require("./formula");
|
|
12
|
+
Object.defineProperty(exports, "stripFormulaGuard", { enumerable: true, get: function () { return formula_1.stripFormulaGuard; } });
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.default = buildCompletionEmail;
|
|
4
4
|
const csv_1 = require("../csv");
|
|
5
|
+
const csv_2 = require("@tryghost/custom-field-types/csv");
|
|
5
6
|
const emailTemplate = require('./email-template');
|
|
6
7
|
// Turn an ORM validation error into copy a member manager can act on. Presentation
|
|
7
8
|
// only: the API response keeps the raw messages, and just the emailed report is
|
|
@@ -17,6 +18,14 @@ function humaniseError(message) {
|
|
|
17
18
|
function stringifyLabels(labels) {
|
|
18
19
|
return labels.map(label => (typeof label === 'string' ? label : label.name)).join(',');
|
|
19
20
|
}
|
|
21
|
+
// The custom_fields.* cells a submitted row carried, echoed untouched so a manager can
|
|
22
|
+
// fix a failed row and re-upload the values they mapped.
|
|
23
|
+
function customFieldCells(row) {
|
|
24
|
+
return Object.fromEntries(Object.entries(row).filter(([column]) => (0, csv_2.isCustomFieldColumn)(column)));
|
|
25
|
+
}
|
|
26
|
+
// Shape a failed import row into its fixed error-report cells, with the raw ORM message
|
|
27
|
+
// rewritten into copy the member manager can act on. Custom field cells are merged on by
|
|
28
|
+
// buildErrorReport, which owns the dynamic column set.
|
|
20
29
|
function toErrorReportRow(row) {
|
|
21
30
|
return {
|
|
22
31
|
id: row.id,
|
|
@@ -34,10 +43,20 @@ function toErrorReportRow(row) {
|
|
|
34
43
|
error: humaniseError(row.error)
|
|
35
44
|
};
|
|
36
45
|
}
|
|
37
|
-
// The error report attached to the completion email: the failed rows as CSV.
|
|
38
|
-
//
|
|
46
|
+
// The error report attached to the completion email: the failed rows as CSV. It shares
|
|
47
|
+
// the serialiser with the export but not the shaping -- the export writes db members,
|
|
48
|
+
// this echoes submitted rows. Member columns come from the shaper's keys (so the type
|
|
49
|
+
// stays the single source); the custom_fields.* columns across the rows are threaded in
|
|
50
|
+
// before the last error column, and each row's custom cells merged on.
|
|
39
51
|
function buildErrorReport(errors) {
|
|
40
|
-
|
|
52
|
+
if (errors.length === 0) {
|
|
53
|
+
return (0, csv_1.serialize)([]);
|
|
54
|
+
}
|
|
55
|
+
const memberColumns = Object.keys(toErrorReportRow(errors[0])).filter(column => column !== 'error');
|
|
56
|
+
const customColumns = [...new Set(errors.flatMap(row => Object.keys(customFieldCells(row))))];
|
|
57
|
+
const columns = [...memberColumns, ...customColumns, 'error'];
|
|
58
|
+
const rows = errors.map(row => ({ ...toErrorReportRow(row), ...customFieldCells(row) }));
|
|
59
|
+
return (0, csv_1.serialize)(rows, { columns });
|
|
41
60
|
}
|
|
42
61
|
// Compose the completion email for a finished import: the summary and its links,
|
|
43
62
|
// plus the attached error report. Owns how the outcome is presented, so the
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {serialize} from '../csv';
|
|
2
|
+
import {isCustomFieldColumn} from '@tryghost/custom-field-types/csv';
|
|
2
3
|
import type {MemberImportRow, ImportErrorRow, ImportLabel, Label} from './row';
|
|
3
4
|
|
|
4
5
|
const emailTemplate = require('./email-template');
|
|
@@ -43,10 +44,12 @@ function humaniseError(message: string): string {
|
|
|
43
44
|
.replace(/No such customer:[^,]*/, 'Could not find Stripe customer');
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
// One row of the
|
|
47
|
-
// these keys, so the report cannot drift from the
|
|
48
|
-
// here or the shaper stops compiling. tiers
|
|
49
|
-
// an import never fills; kept always-empty
|
|
47
|
+
// One row of the fixed, member-vocabulary part of the error report, in emit order.
|
|
48
|
+
// papaparse takes the fixed columns from these keys, so the report cannot drift from the
|
|
49
|
+
// shaper -- a new member column must be added here or the shaper stops compiling. tiers
|
|
50
|
+
// and deleted_at are export-vocabulary columns an import never fills; kept always-empty
|
|
51
|
+
// so the report matches the members export CSV. Any custom_fields.* columns a submitted
|
|
52
|
+
// row carried are dynamic, so they are threaded in separately by buildErrorReport.
|
|
50
53
|
type ErrorReportRow = {
|
|
51
54
|
id: MemberImportRow['id'];
|
|
52
55
|
email: MemberImportRow['email'];
|
|
@@ -67,6 +70,15 @@ function stringifyLabels(labels: Array<string | Label>): string {
|
|
|
67
70
|
return labels.map(label => (typeof label === 'string' ? label : label.name)).join(',');
|
|
68
71
|
}
|
|
69
72
|
|
|
73
|
+
// The custom_fields.* cells a submitted row carried, echoed untouched so a manager can
|
|
74
|
+
// fix a failed row and re-upload the values they mapped.
|
|
75
|
+
function customFieldCells(row: ImportErrorRow): Record<string, unknown> {
|
|
76
|
+
return Object.fromEntries(Object.entries(row).filter(([column]) => isCustomFieldColumn(column)));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Shape a failed import row into its fixed error-report cells, with the raw ORM message
|
|
80
|
+
// rewritten into copy the member manager can act on. Custom field cells are merged on by
|
|
81
|
+
// buildErrorReport, which owns the dynamic column set.
|
|
70
82
|
function toErrorReportRow(row: ImportErrorRow): ErrorReportRow {
|
|
71
83
|
return {
|
|
72
84
|
id: row.id,
|
|
@@ -85,10 +97,20 @@ function toErrorReportRow(row: ImportErrorRow): ErrorReportRow {
|
|
|
85
97
|
};
|
|
86
98
|
}
|
|
87
99
|
|
|
88
|
-
// The error report attached to the completion email: the failed rows as CSV.
|
|
89
|
-
//
|
|
100
|
+
// The error report attached to the completion email: the failed rows as CSV. It shares
|
|
101
|
+
// the serialiser with the export but not the shaping -- the export writes db members,
|
|
102
|
+
// this echoes submitted rows. Member columns come from the shaper's keys (so the type
|
|
103
|
+
// stays the single source); the custom_fields.* columns across the rows are threaded in
|
|
104
|
+
// before the last error column, and each row's custom cells merged on.
|
|
90
105
|
function buildErrorReport(errors: ImportErrorRow[]): string {
|
|
91
|
-
|
|
106
|
+
if (errors.length === 0) {
|
|
107
|
+
return serialize([]);
|
|
108
|
+
}
|
|
109
|
+
const memberColumns = Object.keys(toErrorReportRow(errors[0])).filter(column => column !== 'error');
|
|
110
|
+
const customColumns = [...new Set(errors.flatMap(row => Object.keys(customFieldCells(row))))];
|
|
111
|
+
const columns = [...memberColumns, ...customColumns, 'error'];
|
|
112
|
+
const rows = errors.map(row => ({...toErrorReportRow(row), ...customFieldCells(row)}));
|
|
113
|
+
return serialize(rows, {columns});
|
|
92
114
|
}
|
|
93
115
|
|
|
94
116
|
// Compose the completion email for a finished import: the summary and its links,
|
|
@@ -5,6 +5,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
const moment_timezone_1 = __importDefault(require("moment-timezone"));
|
|
7
7
|
const completion_email_1 = __importDefault(require("./completion-email"));
|
|
8
|
+
const csv_1 = require("../csv");
|
|
9
|
+
const csv_2 = require("@tryghost/custom-field-types/csv");
|
|
8
10
|
const metrics = require('@tryghost/metrics');
|
|
9
11
|
const errors = require('@tryghost/errors');
|
|
10
12
|
const tpl = require('@tryghost/tpl');
|
|
@@ -47,11 +49,12 @@ class MembersCSVImporter {
|
|
|
47
49
|
_tiers;
|
|
48
50
|
_stripe;
|
|
49
51
|
_gifts;
|
|
52
|
+
_customFields;
|
|
50
53
|
_email;
|
|
51
54
|
_addJob;
|
|
52
55
|
_getTimezone;
|
|
53
56
|
_getInlineThreshold;
|
|
54
|
-
constructor({ knex, readRows, spool, members, tiers, stripe, gifts, email, addJob, getTimezone, getInlineThreshold }) {
|
|
57
|
+
constructor({ knex, readRows, spool, members, tiers, stripe, gifts, customFields, email, addJob, getTimezone, getInlineThreshold }) {
|
|
55
58
|
this._knex = knex;
|
|
56
59
|
this._readRows = readRows;
|
|
57
60
|
this._spool = spool;
|
|
@@ -59,6 +62,7 @@ class MembersCSVImporter {
|
|
|
59
62
|
this._tiers = tiers;
|
|
60
63
|
this._stripe = stripe;
|
|
61
64
|
this._gifts = gifts;
|
|
65
|
+
this._customFields = customFields;
|
|
62
66
|
this._email = email;
|
|
63
67
|
this._addJob = addJob;
|
|
64
68
|
this._getTimezone = getTimezone;
|
|
@@ -132,6 +136,9 @@ class MembersCSVImporter {
|
|
|
132
136
|
const globalLabels = [importLabel, ...extraLabels];
|
|
133
137
|
const performStart = Date.now();
|
|
134
138
|
const defaultTier = await this._tiers.getDefault();
|
|
139
|
+
// The field set a custom_fields.* column is read against; empty when the feature
|
|
140
|
+
// is off, so a carried-through column is dropped and the write boundary stays shut.
|
|
141
|
+
const activeCustomFields = await this._customFields.activeFields();
|
|
135
142
|
const tierIdCache = new Map();
|
|
136
143
|
const archivableStripePriceIds = [];
|
|
137
144
|
// Copied per row: the member model stamps ids and trims names onto these in
|
|
@@ -143,8 +150,7 @@ class MembersCSVImporter {
|
|
|
143
150
|
let imported = 0;
|
|
144
151
|
const importErrors = [];
|
|
145
152
|
for (const row of rows) {
|
|
146
|
-
|
|
147
|
-
const options = { transacting: trx, context: IMPORT_CONTEXT };
|
|
153
|
+
let trx;
|
|
148
154
|
try {
|
|
149
155
|
if (row.gift_id) {
|
|
150
156
|
if (row.import_tier) {
|
|
@@ -154,6 +160,15 @@ class MembersCSVImporter {
|
|
|
154
160
|
throw wrapGiftError(new errors.DataImportError({ message: tpl(messages.giftCannotCombineWithComplimentary) }));
|
|
155
161
|
}
|
|
156
162
|
}
|
|
163
|
+
// Validate the row's custom field values before the transaction opens.
|
|
164
|
+
// planWrite only reads, and it throws on an invalid value to fail the row
|
|
165
|
+
// before any member write -- so there is no reason to hold a transaction
|
|
166
|
+
// across it, and doing so would deadlock the single-connection SQLite pool.
|
|
167
|
+
const customFieldPlan = activeCustomFields.length > 0
|
|
168
|
+
? await this._customFields.planWrite((0, csv_2.fieldValuesFromCsvRow)(activeCustomFields, row, csv_1.stripFormulaGuard))
|
|
169
|
+
: [];
|
|
170
|
+
trx = await this._knex.transaction(undefined, { doNotRejectOnRollback: false });
|
|
171
|
+
const options = { transacting: trx, context: IMPORT_CONTEXT };
|
|
157
172
|
const createdAt = (row.created_at && (0, moment_timezone_1.default)(row.created_at).isAfter((0, moment_timezone_1.default)())) ? (0, moment_timezone_1.default)().toDate() : row.created_at;
|
|
158
173
|
const memberValues = {
|
|
159
174
|
email: row.email,
|
|
@@ -243,6 +258,8 @@ class MembersCSVImporter {
|
|
|
243
258
|
throw wrapGiftError(giftError);
|
|
244
259
|
}
|
|
245
260
|
}
|
|
261
|
+
// On the row's transaction, so the values commit or roll back with the member.
|
|
262
|
+
await this._customFields.applyWrite(member.id, customFieldPlan, trx);
|
|
246
263
|
await trx.commit();
|
|
247
264
|
imported += 1;
|
|
248
265
|
}
|
|
@@ -251,7 +268,11 @@ class MembersCSVImporter {
|
|
|
251
268
|
const errorMessage = errorList
|
|
252
269
|
.map(e => (typeof e === 'object' && e !== null && 'message' in e ? e.message : undefined))
|
|
253
270
|
.join(', ');
|
|
254
|
-
|
|
271
|
+
// trx is unset if the row failed before the transaction opened (a bad
|
|
272
|
+
// custom field value or gift combination).
|
|
273
|
+
if (trx) {
|
|
274
|
+
await trx.rollback();
|
|
275
|
+
}
|
|
255
276
|
importErrors.push({ ...row, error: errorMessage });
|
|
256
277
|
}
|
|
257
278
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import moment from 'moment-timezone';
|
|
2
2
|
import buildCompletionEmail from './completion-email';
|
|
3
|
+
import {stripFormulaGuard} from '../csv';
|
|
4
|
+
import {fieldValuesFromCsvRow, type CsvField} from '@tryghost/custom-field-types/csv';
|
|
3
5
|
import type {Knex} from 'knex';
|
|
4
6
|
import type {MemberImportRow, ImportErrorRow, ImportLabel, Label} from './row';
|
|
5
7
|
import type {RowSpool, SpooledRows} from './spool';
|
|
@@ -91,6 +93,19 @@ export interface EmailNotifications {
|
|
|
91
93
|
urlFor(type: string, data: unknown, absolute: boolean): string;
|
|
92
94
|
}
|
|
93
95
|
|
|
96
|
+
// Opaque to the import: planWrite produces it, applyWrite consumes it.
|
|
97
|
+
type CustomFieldPlan = unknown;
|
|
98
|
+
|
|
99
|
+
// The custom fields collaborator as the import needs it. activeFields is the field set a
|
|
100
|
+
// custom_fields.* column is read against, empty when the feature is off; planWrite
|
|
101
|
+
// validates a row's values (throwing so the row fails whole) and applyWrite persists
|
|
102
|
+
// them, both on the row's transaction.
|
|
103
|
+
export interface CustomFieldsImport {
|
|
104
|
+
activeFields(): Promise<CsvField[]>;
|
|
105
|
+
planWrite(values: Record<string, unknown>): Promise<CustomFieldPlan[]>;
|
|
106
|
+
applyWrite(memberId: string, plan: CustomFieldPlan[], executor: Knex): Promise<void>;
|
|
107
|
+
}
|
|
108
|
+
|
|
94
109
|
// The collaborators the import depends on, one per concern.
|
|
95
110
|
interface ImporterDeps {
|
|
96
111
|
knex: Knex;
|
|
@@ -100,6 +115,7 @@ interface ImporterDeps {
|
|
|
100
115
|
tiers: TiersRepository;
|
|
101
116
|
stripe: StripeSubscriptions;
|
|
102
117
|
gifts: GiftService;
|
|
118
|
+
customFields: CustomFieldsImport;
|
|
103
119
|
email: EmailNotifications;
|
|
104
120
|
addJob: (job: {job: () => Promise<void>; offloaded: boolean; name: string}) => void;
|
|
105
121
|
getTimezone: () => string;
|
|
@@ -183,12 +199,13 @@ class MembersCSVImporter {
|
|
|
183
199
|
private _tiers: TiersRepository;
|
|
184
200
|
private _stripe: StripeSubscriptions;
|
|
185
201
|
private _gifts: GiftService;
|
|
202
|
+
private _customFields: CustomFieldsImport;
|
|
186
203
|
private _email: EmailNotifications;
|
|
187
204
|
private _addJob: (job: {job: () => Promise<void>; offloaded: boolean; name: string}) => void;
|
|
188
205
|
private _getTimezone: () => string;
|
|
189
206
|
private _getInlineThreshold: () => number;
|
|
190
207
|
|
|
191
|
-
constructor({knex, readRows, spool, members, tiers, stripe, gifts, email, addJob, getTimezone, getInlineThreshold}: ImporterDeps) {
|
|
208
|
+
constructor({knex, readRows, spool, members, tiers, stripe, gifts, customFields, email, addJob, getTimezone, getInlineThreshold}: ImporterDeps) {
|
|
192
209
|
this._knex = knex;
|
|
193
210
|
this._readRows = readRows;
|
|
194
211
|
this._spool = spool;
|
|
@@ -196,6 +213,7 @@ class MembersCSVImporter {
|
|
|
196
213
|
this._tiers = tiers;
|
|
197
214
|
this._stripe = stripe;
|
|
198
215
|
this._gifts = gifts;
|
|
216
|
+
this._customFields = customFields;
|
|
199
217
|
this._email = email;
|
|
200
218
|
this._addJob = addJob;
|
|
201
219
|
this._getTimezone = getTimezone;
|
|
@@ -283,6 +301,9 @@ class MembersCSVImporter {
|
|
|
283
301
|
const globalLabels: Label[] = [importLabel, ...extraLabels];
|
|
284
302
|
const performStart = Date.now();
|
|
285
303
|
const defaultTier = await this._tiers.getDefault();
|
|
304
|
+
// The field set a custom_fields.* column is read against; empty when the feature
|
|
305
|
+
// is off, so a carried-through column is dropped and the write boundary stays shut.
|
|
306
|
+
const activeCustomFields = await this._customFields.activeFields();
|
|
286
307
|
const tierIdCache = new Map();
|
|
287
308
|
const archivableStripePriceIds: string[] = [];
|
|
288
309
|
// Copied per row: the member model stamps ids and trims names onto these in
|
|
@@ -295,8 +316,7 @@ class MembersCSVImporter {
|
|
|
295
316
|
let imported = 0;
|
|
296
317
|
const importErrors: ImportErrorRow[] = [];
|
|
297
318
|
for (const row of rows) {
|
|
298
|
-
|
|
299
|
-
const options = {transacting: trx, context: IMPORT_CONTEXT};
|
|
319
|
+
let trx: Knex.Transaction | undefined;
|
|
300
320
|
try {
|
|
301
321
|
if (row.gift_id) {
|
|
302
322
|
if (row.import_tier) {
|
|
@@ -307,6 +327,17 @@ class MembersCSVImporter {
|
|
|
307
327
|
}
|
|
308
328
|
}
|
|
309
329
|
|
|
330
|
+
// Validate the row's custom field values before the transaction opens.
|
|
331
|
+
// planWrite only reads, and it throws on an invalid value to fail the row
|
|
332
|
+
// before any member write -- so there is no reason to hold a transaction
|
|
333
|
+
// across it, and doing so would deadlock the single-connection SQLite pool.
|
|
334
|
+
const customFieldPlan = activeCustomFields.length > 0
|
|
335
|
+
? await this._customFields.planWrite(fieldValuesFromCsvRow(activeCustomFields, row, stripFormulaGuard))
|
|
336
|
+
: [];
|
|
337
|
+
|
|
338
|
+
trx = await this._knex.transaction(undefined, {doNotRejectOnRollback: false});
|
|
339
|
+
const options = {transacting: trx, context: IMPORT_CONTEXT};
|
|
340
|
+
|
|
310
341
|
const createdAt = (row.created_at && moment(row.created_at).isAfter(moment())) ? moment().toDate() : row.created_at;
|
|
311
342
|
const memberValues: MemberImportValues = {
|
|
312
343
|
email: row.email,
|
|
@@ -394,6 +425,9 @@ class MembersCSVImporter {
|
|
|
394
425
|
}
|
|
395
426
|
}
|
|
396
427
|
|
|
428
|
+
// On the row's transaction, so the values commit or roll back with the member.
|
|
429
|
+
await this._customFields.applyWrite(member.id, customFieldPlan, trx);
|
|
430
|
+
|
|
397
431
|
await trx.commit();
|
|
398
432
|
imported += 1;
|
|
399
433
|
} catch (error) {
|
|
@@ -401,7 +435,11 @@ class MembersCSVImporter {
|
|
|
401
435
|
const errorMessage = errorList
|
|
402
436
|
.map(e => (typeof e === 'object' && e !== null && 'message' in e ? e.message : undefined))
|
|
403
437
|
.join(', ');
|
|
404
|
-
|
|
438
|
+
// trx is unset if the row failed before the transaction opened (a bad
|
|
439
|
+
// custom field value or gift combination).
|
|
440
|
+
if (trx) {
|
|
441
|
+
await trx.rollback();
|
|
442
|
+
}
|
|
405
443
|
importErrors.push({...row, error: errorMessage});
|
|
406
444
|
}
|
|
407
445
|
}
|
|
@@ -42,6 +42,14 @@ function makeImporter(deps) {
|
|
|
42
42
|
const gifts = {
|
|
43
43
|
reassignRedeemer: (giftId, memberId, options) => deps.getGiftService().reassignRedeemer(giftId, memberId, options)
|
|
44
44
|
};
|
|
45
|
+
// Gated by the same labs flag as the export, so the two halves round-trip or stay
|
|
46
|
+
// silent together: off, activeFields resolves empty and every custom_fields.* column
|
|
47
|
+
// is dropped.
|
|
48
|
+
const customFields = {
|
|
49
|
+
activeFields: async () => (labs.isSet('membersCustomFields') ? deps.customFields.definitions.browse() : []),
|
|
50
|
+
planWrite: values => deps.customFields.values.planWrite(values),
|
|
51
|
+
applyWrite: (memberId, plan, executor) => deps.customFields.values.applyWrite(memberId, plan, executor)
|
|
52
|
+
};
|
|
45
53
|
return new importer_1.default({
|
|
46
54
|
knex: deps.knex,
|
|
47
55
|
readRows: reader_1.default,
|
|
@@ -56,6 +64,7 @@ function makeImporter(deps) {
|
|
|
56
64
|
productRepository: deps.productRepository
|
|
57
65
|
}),
|
|
58
66
|
gifts,
|
|
67
|
+
customFields,
|
|
59
68
|
email,
|
|
60
69
|
addJob: deps.addJob,
|
|
61
70
|
getTimezone: deps.getTimezone,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type {Knex} from 'knex';
|
|
2
|
-
import
|
|
2
|
+
import type {CsvField} from '@tryghost/custom-field-types/csv';
|
|
3
|
+
import MembersCSVImporter, {type MembersRepository, type GiftService, type EmailNotifications, type Tier, type CustomFieldsImport} from './import/importer';
|
|
3
4
|
import readMemberRows from './import/reader';
|
|
4
5
|
import {createRowSpool} from './import/spool';
|
|
5
6
|
import MembersCSVExporter, {type ExportOptions, type CustomFieldDefinition} from './export/exporter';
|
|
@@ -25,6 +26,14 @@ interface ImporterServices {
|
|
|
25
26
|
getInlineThreshold(): number;
|
|
26
27
|
stripeAPIService: unknown;
|
|
27
28
|
productRepository: unknown;
|
|
29
|
+
// The custom fields services the members service hands the import composition root.
|
|
30
|
+
customFields: {
|
|
31
|
+
definitions: {browse(): Promise<CsvField[]>};
|
|
32
|
+
values: {
|
|
33
|
+
planWrite(values: Record<string, unknown>): Promise<unknown[]>;
|
|
34
|
+
applyWrite(memberId: string, plan: unknown[], executor: Knex): Promise<void>;
|
|
35
|
+
};
|
|
36
|
+
};
|
|
28
37
|
}
|
|
29
38
|
|
|
30
39
|
// The custom fields services the members service hands the export composition root.
|
|
@@ -66,6 +75,15 @@ export function makeImporter(deps: ImporterServices) {
|
|
|
66
75
|
reassignRedeemer: (giftId, memberId, options) => deps.getGiftService().reassignRedeemer(giftId, memberId, options)
|
|
67
76
|
};
|
|
68
77
|
|
|
78
|
+
// Gated by the same labs flag as the export, so the two halves round-trip or stay
|
|
79
|
+
// silent together: off, activeFields resolves empty and every custom_fields.* column
|
|
80
|
+
// is dropped.
|
|
81
|
+
const customFields: CustomFieldsImport = {
|
|
82
|
+
activeFields: async () => (labs.isSet('membersCustomFields') ? deps.customFields.definitions.browse() : []),
|
|
83
|
+
planWrite: values => deps.customFields.values.planWrite(values),
|
|
84
|
+
applyWrite: (memberId, plan, executor) => deps.customFields.values.applyWrite(memberId, plan, executor)
|
|
85
|
+
};
|
|
86
|
+
|
|
69
87
|
return new MembersCSVImporter({
|
|
70
88
|
knex: deps.knex,
|
|
71
89
|
readRows: readMemberRows,
|
|
@@ -80,6 +98,7 @@ export function makeImporter(deps: ImporterServices) {
|
|
|
80
98
|
productRepository: deps.productRepository
|
|
81
99
|
}),
|
|
82
100
|
gifts,
|
|
101
|
+
customFields,
|
|
83
102
|
email,
|
|
84
103
|
addJob: deps.addJob,
|
|
85
104
|
getTimezone: deps.getTimezone,
|