ghost 6.54.0 → 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/ghost-storage-base-3.0.0.tgz +0 -0
- package/components/tryghost-adapter-base-cache-0.2.0.tgz +0 -0
- package/components/tryghost-adapter-base-scheduling-0.2.0.tgz +0 -0
- package/components/tryghost-adapter-base-sso-0.1.1.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/core/boot.js +3 -6
- package/core/built/admin/assets/{account-migration-DLvVA5yq.js → account-migration-sjHLx5dy.js} +1 -1
- package/core/built/admin/assets/activitypub/{account-migration-DHbZcxGM.js → account-migration-DoYXtnZ8.js} +4 -4
- package/core/built/admin/assets/activitypub/activitypub.js +2 -2
- package/core/built/admin/assets/activitypub/{bluesky-sharing-zfxUey_L.js → bluesky-sharing-Bxeh6MKW.js} +4 -4
- package/core/built/admin/assets/activitypub/{dist-BqkDUQxJ2.js → dist-ByFmDSNP2.js} +64 -63
- package/core/built/admin/assets/activitypub/{dist-Ba_QxHHl.js → dist-D8sFfdpC.js} +2 -2
- package/core/built/admin/assets/activitypub/{domain-CmwSZLHK.js → domain-CWueFW6q.js} +5 -5
- package/core/built/admin/assets/activitypub/{edit-profile-CXdwNmVG.js → edit-profile-BGxPU7N5.js} +3 -3
- package/core/built/admin/assets/activitypub/{explore-DfbjzGM1.js → explore-DBK-u0Ch.js} +3 -3
- package/core/built/admin/assets/activitypub/{feed-DSEBJOqf.js → feed-Bcir5RQZ.js} +3 -3
- package/core/built/admin/assets/activitypub/{field-CY7XVcx6.js → field-BV-CIT3d.js} +2 -2
- package/core/built/admin/assets/activitypub/{inbox-DtQ0kytT.js → inbox-yC0nfvzl.js} +4 -4
- package/core/built/admin/assets/activitypub/{label-Dw96GQ7u.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-Dg_-I-8C.js → moderation-Dwl9t0pb.js} +4 -4
- package/core/built/admin/assets/activitypub/{note-Cy1-uKMY.js → note-B-zKsZ7r.js} +3 -3
- package/core/built/admin/assets/activitypub/{notifications-B_fvoLIM.js → notifications-DjJZD54t.js} +3 -3
- package/core/built/admin/assets/activitypub/{preferences-ByJseZp6.js → preferences-CoU8vs6d.js} +6 -6
- package/core/built/admin/assets/activitypub/{profile-BFtbnIjz.js → profile-BAsaJekx.js} +6 -6
- package/core/built/admin/assets/activitypub/{routes-Dh3xXfce.js → routes-CLH3OVTP.js} +28 -25
- package/core/built/admin/assets/activitypub/{settings-DTgqJPxM.js → settings-DA6pNJ-Q.js} +3 -3
- package/core/built/admin/assets/activitypub/{site-CdL9ZLqm.js → site-CQD42Pw5.js} +2 -2
- package/core/built/admin/assets/activitypub/{tabs-ogqwoBlb.js → tabs-Cg0saow1.js} +7 -7
- package/core/built/admin/assets/activitypub/{use-reply-chain-data-CxonteFD.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-Cn87L5Md.js → automated-emails-DRy7qY-J.js} +2 -2
- package/core/built/admin/assets/{automations-BP_aHamJ.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-Bh36XPh4.js → bluesky-sharing-ClMd0-Ft.js} +1 -1
- package/core/built/admin/assets/{breadcrumb-D34YWnaw.js → breadcrumb-BD0U2ygg.js} +1 -1
- package/core/built/admin/assets/{chart-helpers-DbklaJw9.js → chart-helpers-fjQKRVyY.js} +1 -1
- package/core/built/admin/assets/checkbox-CWa28lgO.js +1 -0
- package/core/built/admin/assets/{chunk.962.b73442b07be930bb5d85.js → chunk.211.6f7328d63c41536451b3.js} +737 -559
- package/core/built/admin/assets/{chunk.962.b73442b07be930bb5d85.js.LICENSE.txt → chunk.211.6f7328d63c41536451b3.js.LICENSE.txt} +2 -0
- package/core/built/admin/assets/{chunk.589.096f4d0bb775e5229aa9.js → chunk.481.6c7395a49484bb4583aa.js} +41 -38
- package/core/built/admin/assets/chunk.524.cffd399783a519e404a9.js +25 -0
- package/core/built/admin/assets/chunk.582.eebf0d0b2d202bb746a5.js +27 -0
- package/core/built/admin/assets/code-editor-view-CCr0rBWS.js +1 -0
- package/core/built/admin/assets/comments-DyUSMUdj.js +1 -0
- package/core/built/admin/assets/{date-range-select-B5vSsrpx.js → date-range-select-BFp58EMk.js} +1 -1
- package/core/built/admin/assets/{disabled-sources-indicator-NkOIoMue.js → disabled-sources-indicator-CUxMfiPa.js} +1 -1
- package/core/built/admin/assets/dist-2Tnz2Yfq.js +1 -0
- package/core/built/admin/assets/dist-CjY-f5Z1.js +1 -0
- package/core/built/admin/assets/dist-DIC8yz5C.js +1 -0
- package/core/built/admin/assets/domain-C0bV2UV8.js +1 -0
- package/core/built/admin/assets/dropzone-DlK-K2DQ.js +5 -0
- package/core/built/admin/assets/{edit-profile-rvDGrS8J.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-BZAZOg5X.js → explore-ClUpJZOp.js} +1 -1
- package/core/built/admin/assets/{feed-CFV8xDw9.js → feed-DF92dUMP.js} +1 -1
- package/core/built/admin/assets/field-Do7lieBA.js +1 -0
- package/core/built/admin/assets/{filter-sources-Fx8wiYYw.js → filter-sources-BV2rnpfA.js} +1 -1
- package/core/built/admin/assets/filters-CUXJ2Nm9.js +1 -0
- package/core/built/admin/assets/{filters-CGX_pf2N.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-b1dce91ad3232cc259ee5fbbf9526243.js → ghost-c4057474943effc568c5659ccc4909d7.js} +407 -406
- 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-CSaHoSVD.js → growth-CVw1My4S.js} +2 -2
- package/core/built/admin/assets/{hooks-BApwwBE0.js → hooks-CixO01lL.js} +1 -1
- package/core/built/admin/assets/{inbox-BRp1_aaC.js → inbox-CFBSu4Ed.js} +12 -12
- 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-BSgG_lVw.js +1 -0
- package/core/built/admin/assets/input-surface-BpAjZXY5.js +1 -0
- 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-CM7UFNdL.js → koenig-lexical-f8vHRYvz.js} +28 -28
- package/core/built/admin/assets/{kpi-card-BrGH6FsT.js → kpi-card-DSap9rAy.js} +1 -1
- package/core/built/admin/assets/{kpi-card-CIGyijjx.js → kpi-card-DU0rn9Cj.js} +1 -1
- package/core/built/admin/assets/{kpi-helpers-_HRRsklE.js → kpi-helpers-Dz33AV6h.js} +1 -1
- package/core/built/admin/assets/{kpi-tabs-BSvh9eoq.js → kpi-tabs-BtTTrKvJ.js} +1 -1
- package/core/built/admin/assets/{label-BOR9XdKl.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-C5cs6FGo.js → newsletters-B3QQXfGT.js} +1 -1
- 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-C0jVxtS8.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-Iw7dk1mT.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-DBfKl2GZ.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-D4EANGoc.js → preferences-DJPfHEGu.js} +2 -2
- 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-C5_xEt5b.js +1 -0
- package/core/built/admin/assets/settings-BwiVIXau.js +4 -0
- package/core/built/admin/assets/{settings-Bz3RZRmE.js → settings-WXNeWyU1.js} +1 -1
- package/core/built/admin/assets/shade-app-B3heOAfz-CGXQwWeO.js +3 -0
- package/core/built/admin/assets/{share-modal-CkdTTMJd.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-DZp9Y2r1.js → sort-button-Bb_FCl7e.js} +1 -1
- package/core/built/admin/assets/{source-icon-CHr9qJ2Y.js → source-icon-BeaRxUn9.js} +1 -1
- package/core/built/admin/assets/{stats-DGoXZxWE.js → stats-Bjj_kmJ1.js} +1 -1
- package/core/built/admin/assets/{stats-view-D2-1L0ms.js → stats-view-BxPbW6K1.js} +1 -1
- package/core/built/admin/assets/{step-1-RdahbwyG.js → step-1-B1e3-1F8.js} +1 -1
- package/core/built/admin/assets/{step-2-D83PeRwd.js → step-2-C5WaeBJo.js} +1 -1
- package/core/built/admin/assets/{step-3-p_Op4wv4.js → step-3-B46iVPwu.js} +2 -2
- package/core/built/admin/assets/switch-z5Y6Uzt6.js +1 -0
- package/core/built/admin/assets/{table-B8LgTQJZ.js → table-CB-4_HBJ.js} +1 -1
- package/core/built/admin/assets/tabs-CCEgKYzq.js +1 -0
- package/core/built/admin/assets/{tags-B4l891z2.js → tags-Cg2VJGmG.js} +1 -1
- package/core/built/admin/assets/textarea-Bjccvh_E.js +1 -0
- package/core/built/admin/assets/{tiers-BgYLWRJv.js → tiers-DYsa4DCo.js} +1 -1
- package/core/built/admin/assets/{toggle-group-DNXDkAwf.js → toggle-group-b3IlxvhV.js} +1 -1
- package/core/built/admin/assets/{topic-filter-DZGjEcz3.js → topic-filter-DAFT87tQ.js} +1 -1
- package/core/built/admin/assets/{use-analytics-data-CaT8RirX.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-9Db2CuJe.js → use-growth-stats-CIaiHOVb.js} +1 -1
- package/core/built/admin/assets/{use-post-referrers-BV54YcQS.js → use-post-referrers-Cq2vBPrA.js} +1 -1
- package/core/built/admin/assets/{use-reply-chain-data-DuYZGDan.js → use-reply-chain-data-DBDRtGu-.js} +1 -1
- package/core/built/admin/assets/{use-simple-pagination-8fTua9Nt.js → use-simple-pagination-CjYNxdwe.js} +1 -1
- package/core/built/admin/assets/{use-tinybird-query-Dz156HvZ.js → use-tinybird-query-falHAtuK.js} +1 -1
- package/core/built/admin/assets/{use-tinybird-token-C9RUdL_n.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-BCvP0YF-.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 +14 -10
- package/core/frontend/services/proxy.js +10 -1
- package/core/frontend/services/routing/router-manager.js +14 -14
- package/core/frontend/services/routing/static-routes-router.js +8 -10
- package/core/server/adapters/storage/LocalFilesStorage.js +13 -11
- package/core/server/adapters/storage/LocalFilesStorage.ts +24 -0
- package/core/server/adapters/storage/LocalImagesStorage.js +14 -12
- package/core/server/adapters/storage/LocalImagesStorage.ts +24 -0
- package/core/server/adapters/storage/LocalMediaStorage.js +13 -11
- package/core/server/adapters/storage/LocalMediaStorage.ts +24 -0
- package/core/server/adapters/storage/LocalStorageBase.js +107 -167
- package/core/server/adapters/storage/LocalStorageBase.ts +342 -0
- package/core/server/adapters/storage/utils.js +36 -50
- package/core/server/adapters/storage/utils.ts +60 -0
- 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/db.js +6 -0
- package/core/server/api/endpoints/index.js +4 -0
- package/core/server/api/endpoints/members.js +25 -28
- package/core/server/api/endpoints/pages.js +6 -1
- package/core/server/api/endpoints/posts.js +11 -2
- package/core/server/api/endpoints/settings.js +0 -2
- package/core/server/api/endpoints/utils/serializers/output/mappers/comments.js +4 -1
- package/core/server/api/endpoints/utils/serializers/output/members.js +18 -87
- package/core/server/api/endpoints/utils/serializers/output/utils/url.js +11 -1
- package/core/server/data/exporter/export-filename.js +2 -1
- package/core/server/data/importer/importers/data/revue-subscriber-importer.js +6 -10
- package/core/server/data/importer/importers/json-to-html.js +24 -4
- 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/lib/image/image-size.js +3 -17
- package/core/server/lib/image/image-utils.js +15 -1
- package/core/server/lib/request-external.js +1 -1
- package/core/server/models/user.js +46 -70
- package/core/server/services/auth/session/session-service.js +28 -1
- package/core/server/services/automations/automations-api.js +21 -6
- package/core/server/services/automations/automations-api.ts +25 -6
- package/core/server/services/automations/automations-repository.ts +19 -0
- package/core/server/services/automations/database-automations-repository.js +116 -28
- package/core/server/services/automations/database-automations-repository.ts +118 -9
- 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 +14 -8
- 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/{importer → import-export}/config.js +4 -9
- package/core/server/services/members/{importer → import-export}/config.ts +4 -9
- 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 +12 -0
- package/core/server/services/members/import-export/csv/index.ts +3 -0
- package/core/server/services/members/import-export/csv/parse.js +64 -0
- package/core/server/services/members/import-export/csv/parse.ts +69 -0
- package/core/server/services/members/import-export/csv/serialize.js +13 -0
- package/core/server/services/members/import-export/csv/serialize.ts +18 -0
- package/core/server/services/members/import-export/export/exporter.js +202 -0
- package/core/server/services/members/import-export/export/exporter.ts +325 -0
- package/core/server/services/members/import-export/import/completion-email.js +84 -0
- package/core/server/services/members/import-export/import/completion-email.ts +141 -0
- package/core/server/services/members/import-export/import/importer.js +297 -0
- package/core/server/services/members/import-export/import/importer.ts +468 -0
- package/core/server/services/members/import-export/import/reader.js +32 -0
- package/core/server/services/members/import-export/import/reader.ts +33 -0
- package/core/server/services/members/import-export/import/row.js +33 -0
- package/core/server/services/members/import-export/import/row.ts +52 -0
- package/core/server/services/members/import-export/import/spool.js +30 -0
- package/core/server/services/members/import-export/import/spool.ts +38 -0
- package/core/server/services/members/import-export/index.js +99 -0
- package/core/server/services/members/import-export/index.ts +137 -0
- package/core/server/services/members/members-api/controllers/router-controller.js +7 -0
- package/core/server/services/members/service.js +25 -20
- 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/posts/posts-exporter.js +6 -4
- package/core/server/services/posts/posts-service.js +2 -2
- 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/themes/storage.js +9 -0
- package/core/server/services/themes/theme-storage.js +1 -1
- 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/core/shared/config/defaults.json +2 -1
- package/core/shared/express.js +6 -1
- package/core/shared/labs.js +2 -1
- package/package.json +11 -11
- package/pnpm-lock.yaml +18 -18
- package/pnpm-workspace.yaml +8 -7
- package/components/tryghost-adapter-base-scheduling-0.1.1.tgz +0 -0
- package/core/built/admin/assets/Overview-j4NaeUNm.js +0 -1
- package/core/built/admin/assets/Web-_fD935OX.js +0 -2
- package/core/built/admin/assets/activitypub/layout-IdVq3lRy.js +0 -1
- package/core/built/admin/assets/audience-BKRdsFmy.js +0 -1
- package/core/built/admin/assets/checkbox-DA4-1UiI.js +0 -1
- package/core/built/admin/assets/chunk.524.45e0875f41b71e46e849.js +0 -25
- package/core/built/admin/assets/chunk.582.51defd4b009f0de8d4d5.js +0 -27
- package/core/built/admin/assets/code-editor-view-BKBrkffZ.js +0 -1
- package/core/built/admin/assets/comments-CzVNIT8b.js +0 -1
- package/core/built/admin/assets/constants-c-JNF9c2.js +0 -1
- package/core/built/admin/assets/dist-CT46_f_J.js +0 -1
- package/core/built/admin/assets/domain-B7_n5nfx.js +0 -1
- package/core/built/admin/assets/dropzone-DcuJEEUf.js +0 -5
- package/core/built/admin/assets/editor-BCiIyFjm.js +0 -8
- package/core/built/admin/assets/field-BW-M2Zwp.js +0 -1
- package/core/built/admin/assets/filters-DtcXMZDU.js +0 -1
- package/core/built/admin/assets/gift-link-modal-Bk8qpnMw.js +0 -1
- package/core/built/admin/assets/growth-Ck2s3JjR.js +0 -1
- package/core/built/admin/assets/index-BBf502Wg.js +0 -69
- package/core/built/admin/assets/index-BCCZKxP2.css +0 -1
- package/core/built/admin/assets/input-group-DjuYnNCd.js +0 -1
- package/core/built/admin/assets/label-picker-BdNAWuUX.js +0 -1
- package/core/built/admin/assets/labels-Cyayfd-e.js +0 -1
- package/core/built/admin/assets/layout-Bw4GnxXp.js +0 -1
- package/core/built/admin/assets/member-custom-fields-DvFyamLu.js +0 -1
- package/core/built/admin/assets/member-detail-Cg2vTqvJ.js +0 -1
- package/core/built/admin/assets/members-DsPxYAVm.js +0 -10
- package/core/built/admin/assets/modals-Bf_fyvHZ.js +0 -31
- package/core/built/admin/assets/moderation-Dlp7M5sa.js +0 -1
- package/core/built/admin/assets/multi-select-combobox-CH1o8XXQ.js +0 -1
- package/core/built/admin/assets/newsletter-CQ5CAYUI.js +0 -1
- package/core/built/admin/assets/note-BrAZ7Z0o.js +0 -1
- package/core/built/admin/assets/notifications-BnbHLOPC.js +0 -1
- package/core/built/admin/assets/onboarding-route-eU146kuA.js +0 -1
- package/core/built/admin/assets/overview-BpbaK-xu.js +0 -1
- package/core/built/admin/assets/post-analytics-header-D9pJ4iNm.js +0 -1
- package/core/built/admin/assets/post-analytics-provider-BDqcfBor.js +0 -1
- package/core/built/admin/assets/profile-dJ44Bn_W.js +0 -1
- package/core/built/admin/assets/select--2uVN9_-.js +0 -1
- package/core/built/admin/assets/settings-SD9bxIpt.js +0 -4
- package/core/built/admin/assets/social-urls-BvFZwYcE.js +0 -8
- package/core/built/admin/assets/switch-qp518gFQ.js +0 -1
- package/core/built/admin/assets/tabs-D3ZOYkM7.js +0 -1
- package/core/built/admin/assets/textarea-CTlb3Uu6.js +0 -1
- package/core/built/admin/assets/use-filter-params-DeQ8sX4N.js +0 -1
- package/core/built/admin/assets/web-DTZnnUGr.js +0 -1
- package/core/server/services/mail/templates/newsletter.html +0 -876
- package/core/server/services/members/csv/index.js +0 -10
- package/core/server/services/members/csv/index.ts +0 -3
- package/core/server/services/members/csv/parse.js +0 -94
- package/core/server/services/members/csv/parse.ts +0 -111
- package/core/server/services/members/csv/types.js +0 -2
- package/core/server/services/members/csv/types.ts +0 -39
- package/core/server/services/members/csv/unparse.js +0 -69
- package/core/server/services/members/csv/unparse.ts +0 -79
- package/core/server/services/members/exporter/query.js +0 -233
- package/core/server/services/members/importer/index.js +0 -30
- package/core/server/services/members/importer/labels.js +0 -125
- package/core/server/services/members/importer/members-csv-importer.js +0 -520
- 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
- /package/core/server/services/members/{importer → import-export/import}/email-template.js +0 -0
- /package/core/server/services/members/{importer/members-csv-importer-stripe-utils.js → import-export/import/stripe-utils.js} +0 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.toExportCsvRow = toExportCsvRow;
|
|
7
|
+
const moment_1 = __importDefault(require("moment"));
|
|
8
|
+
const node_stream_1 = require("node:stream");
|
|
9
|
+
const logging = require('@tryghost/logging');
|
|
10
|
+
const errors = require('@tryghost/errors');
|
|
11
|
+
const { csvCellsForFields } = require('@tryghost/custom-field-types/csv');
|
|
12
|
+
// The columns the export reads off the members table, checked against MemberDbRow so the
|
|
13
|
+
// query cannot select a column the row type does not declare.
|
|
14
|
+
const MEMBER_COLUMNS = ['id', 'email', 'name', 'note', 'status', 'created_at'];
|
|
15
|
+
function namesToCsv(items) {
|
|
16
|
+
return items.map(item => item.name).join(',');
|
|
17
|
+
}
|
|
18
|
+
// Encode a flattened export row into its CSV cells, type-checked against MemberExportRow:
|
|
19
|
+
// a change to that row must be handled here or this stops compiling. deleted_at is always
|
|
20
|
+
// null -- the export reads live members -- and complimentary_plan is written from comped.
|
|
21
|
+
function toExportCsvRow(row) {
|
|
22
|
+
return {
|
|
23
|
+
id: row.id,
|
|
24
|
+
email: row.email,
|
|
25
|
+
name: row.name,
|
|
26
|
+
note: row.note,
|
|
27
|
+
subscribed_to_emails: row.subscribed === true ? 'true' : 'false',
|
|
28
|
+
complimentary_plan: row.comped === true ? 'true' : '',
|
|
29
|
+
stripe_customer_id: row.stripe_customer_id,
|
|
30
|
+
created_at: row.created_at,
|
|
31
|
+
deleted_at: null,
|
|
32
|
+
labels: namesToCsv(row.labels),
|
|
33
|
+
tiers: namesToCsv(row.tiers),
|
|
34
|
+
gift_id: row.gift_id || '',
|
|
35
|
+
...row.custom_field_cells
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const BATCH_SIZE = 1000;
|
|
39
|
+
// Streams the matching members as flat rows, each carrying its related data (tiers,
|
|
40
|
+
// labels, subscription, gift, Stripe customer, custom fields) for the output serializer
|
|
41
|
+
// to turn into CSV lines. The members aggregate and custom fields are reached only
|
|
42
|
+
// through the ports above, so nothing Bookshelf- or flag-shaped leaks into the export.
|
|
43
|
+
class MembersCSVExporter {
|
|
44
|
+
_knex;
|
|
45
|
+
_members;
|
|
46
|
+
_customFields;
|
|
47
|
+
constructor({ knex, members, customFields }) {
|
|
48
|
+
this._knex = knex;
|
|
49
|
+
this._members = members;
|
|
50
|
+
this._customFields = customFields;
|
|
51
|
+
}
|
|
52
|
+
async export(options = {}) {
|
|
53
|
+
const start = Date.now();
|
|
54
|
+
const hasFilter = options.limit !== 'all' || options.filter || options.search;
|
|
55
|
+
const ids = hasFilter ? await this._members.findFilteredIds(options) : null;
|
|
56
|
+
if (ids) {
|
|
57
|
+
logging.info(`[MembersExporter] Found ${ids.length} members matching filter criteria`);
|
|
58
|
+
}
|
|
59
|
+
const reference = await this.fetchReferenceData();
|
|
60
|
+
const membersQuery = this._knex('members').select(...MEMBER_COLUMNS);
|
|
61
|
+
if (ids) {
|
|
62
|
+
membersQuery.whereIn('id', ids);
|
|
63
|
+
}
|
|
64
|
+
logging.info('[MembersExporter] Starting streaming export of members');
|
|
65
|
+
const batchingTransform = this.createBatchingTransform();
|
|
66
|
+
const processingTransform = this.createProcessingTransform(reference);
|
|
67
|
+
(0, node_stream_1.pipeline)(membersQuery.stream(), batchingTransform, processingTransform, (err) => {
|
|
68
|
+
if (err) {
|
|
69
|
+
logging.error({ event: { name: 'members-export.stream.error' }, err }, 'Members export stream failed');
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
logging.info('[MembersExporter] Total time taken for member export: ' + (Date.now() - start) / 1000 + 's');
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
return processingTransform;
|
|
76
|
+
}
|
|
77
|
+
// products and labels are small, stable tables, read once up front as id->name
|
|
78
|
+
// maps. Custom field definitions are read once too, which fixes the column set for
|
|
79
|
+
// the whole file: archiving a field mid-export cannot leave the header ragged. It
|
|
80
|
+
// can still leave that field's cells empty from the batch after the archive
|
|
81
|
+
// onwards, because the per-batch value read applies its own active filter.
|
|
82
|
+
async fetchReferenceData() {
|
|
83
|
+
const start = Date.now();
|
|
84
|
+
const allProducts = await this._knex('products').select('id', 'name').then(rows => rows.reduce((acc, product) => {
|
|
85
|
+
acc[product.id] = product.name;
|
|
86
|
+
return acc;
|
|
87
|
+
}, {}));
|
|
88
|
+
const allLabels = await this._knex('labels').select('id', 'name').then(rows => rows.reduce((acc, label) => {
|
|
89
|
+
acc[label.id] = label.name;
|
|
90
|
+
return acc;
|
|
91
|
+
}, {}));
|
|
92
|
+
logging.info('[MembersExporter] Fetched products and labels in ' + (Date.now() - start) + 'ms');
|
|
93
|
+
const activeCustomFields = await this._customFields.activeDefinitions();
|
|
94
|
+
return { allProducts, allLabels, activeCustomFields };
|
|
95
|
+
}
|
|
96
|
+
// Group the member stream into batches so the related-data reads are one query per
|
|
97
|
+
// batch rather than one per member.
|
|
98
|
+
createBatchingTransform() {
|
|
99
|
+
let currentBatch = [];
|
|
100
|
+
return new node_stream_1.Transform({
|
|
101
|
+
objectMode: true,
|
|
102
|
+
transform(member, encoding, callback) {
|
|
103
|
+
currentBatch.push(member);
|
|
104
|
+
if (currentBatch.length >= BATCH_SIZE) {
|
|
105
|
+
this.push(currentBatch);
|
|
106
|
+
currentBatch = [];
|
|
107
|
+
}
|
|
108
|
+
callback();
|
|
109
|
+
},
|
|
110
|
+
flush(callback) {
|
|
111
|
+
if (currentBatch.length > 0) {
|
|
112
|
+
this.push(currentBatch);
|
|
113
|
+
}
|
|
114
|
+
callback();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
// Each flattened row is pushed individually so a batch's rows do not pile up in memory.
|
|
119
|
+
createProcessingTransform(reference) {
|
|
120
|
+
const assembleRelatedData = (memberIds) => this.assembleRelatedData(memberIds, reference.activeCustomFields);
|
|
121
|
+
const flattenBatch = (members, related) => this.flattenBatch(members, related, reference);
|
|
122
|
+
return new node_stream_1.Transform({
|
|
123
|
+
objectMode: true,
|
|
124
|
+
highWaterMark: BATCH_SIZE,
|
|
125
|
+
async transform(batch, encoding, callback) {
|
|
126
|
+
try {
|
|
127
|
+
const memberIds = batch.map(member => member.id);
|
|
128
|
+
const related = await assembleRelatedData(memberIds);
|
|
129
|
+
const rows = flattenBatch(batch, related);
|
|
130
|
+
rows.forEach(row => this.push(row));
|
|
131
|
+
callback();
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
callback(err instanceof Error ? err : new errors.InternalServerError({ message: String(err) }));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
// One query per related table for the whole batch, not one per member. Custom field
|
|
140
|
+
// values are read only when there are columns to fill.
|
|
141
|
+
async assembleRelatedData(memberIds, activeCustomFields) {
|
|
142
|
+
const knex = this._knex;
|
|
143
|
+
const [tiers, labels, stripeCustomers, subscriptions, gifts, customFieldValuesMap] = await Promise.all([
|
|
144
|
+
knex('members_products')
|
|
145
|
+
.select('member_id', knex.raw('GROUP_CONCAT(product_id) as tiers'))
|
|
146
|
+
.whereIn('member_id', memberIds)
|
|
147
|
+
.groupBy('member_id'),
|
|
148
|
+
knex('members_labels')
|
|
149
|
+
.select('member_id', knex.raw('GROUP_CONCAT(label_id) as labels'))
|
|
150
|
+
.whereIn('member_id', memberIds)
|
|
151
|
+
.groupBy('member_id'),
|
|
152
|
+
knex('members_stripe_customers')
|
|
153
|
+
.select('member_id', knex.raw('MIN(customer_id) as stripe_customer_id'))
|
|
154
|
+
.whereIn('member_id', memberIds)
|
|
155
|
+
.groupBy('member_id'),
|
|
156
|
+
knex('members_newsletters')
|
|
157
|
+
.distinct('member_id')
|
|
158
|
+
.whereIn('member_id', memberIds),
|
|
159
|
+
knex('gifts')
|
|
160
|
+
.select('id', 'redeemer_member_id')
|
|
161
|
+
.where('status', 'redeemed')
|
|
162
|
+
.whereIn('redeemer_member_id', memberIds),
|
|
163
|
+
activeCustomFields.length > 0
|
|
164
|
+
? this._customFields.valuesForMembers(memberIds)
|
|
165
|
+
: new Map()
|
|
166
|
+
]);
|
|
167
|
+
return {
|
|
168
|
+
tiersMap: new Map(tiers.map((row) => [row.member_id, row.tiers])),
|
|
169
|
+
labelsMap: new Map(labels.map((row) => [row.member_id, row.labels])),
|
|
170
|
+
stripeCustomerMap: new Map(stripeCustomers.map((row) => [row.member_id, row.stripe_customer_id])),
|
|
171
|
+
subscribedSet: new Set(subscriptions.map((row) => row.member_id)),
|
|
172
|
+
giftIdMap: new Map(gifts.map((row) => [row.redeemer_member_id, row.id])),
|
|
173
|
+
customFieldValuesMap
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
flattenBatch(members, related, reference) {
|
|
177
|
+
const { tiersMap, labelsMap, stripeCustomerMap, subscribedSet, giftIdMap, customFieldValuesMap } = related;
|
|
178
|
+
const { allProducts, allLabels, activeCustomFields } = reference;
|
|
179
|
+
return members.map((row) => {
|
|
180
|
+
const tierConcat = tiersMap.get(row.id);
|
|
181
|
+
const tierIds = tierConcat ? tierConcat.split(',') : [];
|
|
182
|
+
const labelConcat = labelsMap.get(row.id);
|
|
183
|
+
const labelIds = labelConcat ? labelConcat.split(',') : [];
|
|
184
|
+
return {
|
|
185
|
+
...row,
|
|
186
|
+
subscribed: subscribedSet.has(row.id),
|
|
187
|
+
comped: row.status === 'comped',
|
|
188
|
+
gift_id: giftIdMap.get(row.id) || null,
|
|
189
|
+
stripe_customer_id: stripeCustomerMap.get(row.id) || null,
|
|
190
|
+
created_at: (0, moment_1.default)(row.created_at).toISOString(),
|
|
191
|
+
tiers: tierIds.map(id => ({ name: allProducts[id] })),
|
|
192
|
+
labels: labelIds.map(id => ({ name: allLabels[id] })),
|
|
193
|
+
// Flattened here rather than in the serializer because the column set is
|
|
194
|
+
// only knowable from the database. Every member carries a cell for every
|
|
195
|
+
// active field's column, so a member with no values still contributes the
|
|
196
|
+
// full set -- the CSV header is taken from whichever row streams first.
|
|
197
|
+
custom_field_cells: csvCellsForFields(activeCustomFields, customFieldValuesMap.get(row.id) || {})
|
|
198
|
+
};
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
exports.default = MembersCSVExporter;
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import moment from 'moment';
|
|
2
|
+
import {Transform, pipeline, type Readable} from 'node:stream';
|
|
3
|
+
import type {Knex} from 'knex';
|
|
4
|
+
|
|
5
|
+
const logging = require('@tryghost/logging');
|
|
6
|
+
const errors = require('@tryghost/errors');
|
|
7
|
+
const {csvCellsForFields} = require('@tryghost/custom-field-types/csv');
|
|
8
|
+
|
|
9
|
+
// Options accepted by the export, forwarded to the members query for filtering.
|
|
10
|
+
export interface ExportOptions {
|
|
11
|
+
limit?: number | 'all';
|
|
12
|
+
filter?: string;
|
|
13
|
+
search?: string;
|
|
14
|
+
[option: string]: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// A custom field definition, opaque to the exporter: it only counts the set and hands
|
|
18
|
+
// it to the values codec, which decides the columns. Named for intent; its shape is
|
|
19
|
+
// the custom fields service's concern, not this one's.
|
|
20
|
+
export type CustomFieldDefinition = Record<string, unknown>;
|
|
21
|
+
|
|
22
|
+
// The custom fields collaborator: the active column set for this export, and the
|
|
23
|
+
// per-member values that fill those columns. Both resolve empty when the feature is
|
|
24
|
+
// off, so the exporter carries no flag of its own.
|
|
25
|
+
export interface CustomFieldsService {
|
|
26
|
+
activeDefinitions(): Promise<CustomFieldDefinition[]>;
|
|
27
|
+
valuesForMembers(memberIds: string[]): Promise<Map<string, Record<string, unknown>>>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The members aggregate, narrowed to the one read the export makes: the ids matching
|
|
31
|
+
// a filter, resolved without any of the related data the stream fetches itself.
|
|
32
|
+
export interface MembersRepository {
|
|
33
|
+
findFilteredIds(options: ExportOptions): Promise<string[]>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ExporterDeps {
|
|
37
|
+
knex: Knex;
|
|
38
|
+
members: MembersRepository;
|
|
39
|
+
customFields: CustomFieldsService;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface BatchRelatedData {
|
|
43
|
+
tiersMap: Map<string, string>;
|
|
44
|
+
labelsMap: Map<string, string>;
|
|
45
|
+
stripeCustomerMap: Map<string, string>;
|
|
46
|
+
subscribedSet: Set<string>;
|
|
47
|
+
giftIdMap: Map<string, string>;
|
|
48
|
+
customFieldValuesMap: Map<string, Record<string, unknown>>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The reference data read once up front and shared across every batch.
|
|
52
|
+
interface ReferenceData {
|
|
53
|
+
allProducts: Record<string, string>;
|
|
54
|
+
allLabels: Record<string, string>;
|
|
55
|
+
activeCustomFields: CustomFieldDefinition[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// A member row as the export query selects it -- the shape the stream emits, and the
|
|
59
|
+
// contract flattenBatch is total over. Its value types are declared here (knex streams
|
|
60
|
+
// rows untyped); a change to the members table that this export should carry is made by
|
|
61
|
+
// changing this type, which then ripples into flattenBatch until every field is handled.
|
|
62
|
+
// The end-to-end e2e export test is what catches the table drifting from this shape.
|
|
63
|
+
interface MemberDbRow {
|
|
64
|
+
id: string;
|
|
65
|
+
email: string;
|
|
66
|
+
name: string | null;
|
|
67
|
+
note: string | null;
|
|
68
|
+
status: string;
|
|
69
|
+
created_at: Date | string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The columns the export reads off the members table, checked against MemberDbRow so the
|
|
73
|
+
// query cannot select a column the row type does not declare.
|
|
74
|
+
const MEMBER_COLUMNS = ['id', 'email', 'name', 'note', 'status', 'created_at'] satisfies Array<keyof MemberDbRow>;
|
|
75
|
+
|
|
76
|
+
// The db row with its related data resolved onto it. custom_field_cells later spreads
|
|
77
|
+
// into one column per active custom field.
|
|
78
|
+
interface MemberExportRow extends MemberDbRow {
|
|
79
|
+
created_at: string;
|
|
80
|
+
tiers: Array<{name: string}>;
|
|
81
|
+
labels: Array<{name: string}>;
|
|
82
|
+
subscribed: boolean;
|
|
83
|
+
comped: boolean;
|
|
84
|
+
gift_id: string | null;
|
|
85
|
+
stripe_customer_id: string | null;
|
|
86
|
+
custom_field_cells: Record<string, unknown>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// One row of the members export CSV: a flattened export row encoded into cells. This
|
|
90
|
+
// type is the export's column contract -- the output serializer takes the columns from
|
|
91
|
+
// these keys. The fixed member columns are typed; custom_field_cells spreads in whatever
|
|
92
|
+
// per-site columns the database holds, so the row also carries an open string index.
|
|
93
|
+
type ExportCsvRow = {
|
|
94
|
+
id: string;
|
|
95
|
+
email: string;
|
|
96
|
+
name: string | null;
|
|
97
|
+
note: string | null;
|
|
98
|
+
subscribed_to_emails: string;
|
|
99
|
+
complimentary_plan: string;
|
|
100
|
+
stripe_customer_id: string | null;
|
|
101
|
+
created_at: string;
|
|
102
|
+
deleted_at: null;
|
|
103
|
+
labels: string;
|
|
104
|
+
tiers: string;
|
|
105
|
+
gift_id: string;
|
|
106
|
+
} & Record<string, unknown>;
|
|
107
|
+
|
|
108
|
+
function namesToCsv(items: Array<{name: string}>): string {
|
|
109
|
+
return items.map(item => item.name).join(',');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Encode a flattened export row into its CSV cells, type-checked against MemberExportRow:
|
|
113
|
+
// a change to that row must be handled here or this stops compiling. deleted_at is always
|
|
114
|
+
// null -- the export reads live members -- and complimentary_plan is written from comped.
|
|
115
|
+
export function toExportCsvRow(row: MemberExportRow): ExportCsvRow {
|
|
116
|
+
return {
|
|
117
|
+
id: row.id,
|
|
118
|
+
email: row.email,
|
|
119
|
+
name: row.name,
|
|
120
|
+
note: row.note,
|
|
121
|
+
subscribed_to_emails: row.subscribed === true ? 'true' : 'false',
|
|
122
|
+
complimentary_plan: row.comped === true ? 'true' : '',
|
|
123
|
+
stripe_customer_id: row.stripe_customer_id,
|
|
124
|
+
created_at: row.created_at,
|
|
125
|
+
deleted_at: null,
|
|
126
|
+
labels: namesToCsv(row.labels),
|
|
127
|
+
tiers: namesToCsv(row.tiers),
|
|
128
|
+
gift_id: row.gift_id || '',
|
|
129
|
+
...row.custom_field_cells
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const BATCH_SIZE = 1000;
|
|
134
|
+
|
|
135
|
+
// Streams the matching members as flat rows, each carrying its related data (tiers,
|
|
136
|
+
// labels, subscription, gift, Stripe customer, custom fields) for the output serializer
|
|
137
|
+
// to turn into CSV lines. The members aggregate and custom fields are reached only
|
|
138
|
+
// through the ports above, so nothing Bookshelf- or flag-shaped leaks into the export.
|
|
139
|
+
export default class MembersCSVExporter {
|
|
140
|
+
private _knex: Knex;
|
|
141
|
+
private _members: MembersRepository;
|
|
142
|
+
private _customFields: CustomFieldsService;
|
|
143
|
+
|
|
144
|
+
constructor({knex, members, customFields}: ExporterDeps) {
|
|
145
|
+
this._knex = knex;
|
|
146
|
+
this._members = members;
|
|
147
|
+
this._customFields = customFields;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async export(options: ExportOptions = {}): Promise<Readable> {
|
|
151
|
+
const start = Date.now();
|
|
152
|
+
const hasFilter = options.limit !== 'all' || options.filter || options.search;
|
|
153
|
+
const ids = hasFilter ? await this._members.findFilteredIds(options) : null;
|
|
154
|
+
if (ids) {
|
|
155
|
+
logging.info(`[MembersExporter] Found ${ids.length} members matching filter criteria`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const reference = await this.fetchReferenceData();
|
|
159
|
+
|
|
160
|
+
const membersQuery = this._knex('members').select(...MEMBER_COLUMNS);
|
|
161
|
+
if (ids) {
|
|
162
|
+
membersQuery.whereIn('id', ids);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
logging.info('[MembersExporter] Starting streaming export of members');
|
|
166
|
+
const batchingTransform = this.createBatchingTransform();
|
|
167
|
+
const processingTransform = this.createProcessingTransform(reference);
|
|
168
|
+
|
|
169
|
+
pipeline(membersQuery.stream(), batchingTransform, processingTransform, (err) => {
|
|
170
|
+
if (err) {
|
|
171
|
+
logging.error({event: {name: 'members-export.stream.error'}, err}, 'Members export stream failed');
|
|
172
|
+
} else {
|
|
173
|
+
logging.info('[MembersExporter] Total time taken for member export: ' + (Date.now() - start) / 1000 + 's');
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return processingTransform;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// products and labels are small, stable tables, read once up front as id->name
|
|
181
|
+
// maps. Custom field definitions are read once too, which fixes the column set for
|
|
182
|
+
// the whole file: archiving a field mid-export cannot leave the header ragged. It
|
|
183
|
+
// can still leave that field's cells empty from the batch after the archive
|
|
184
|
+
// onwards, because the per-batch value read applies its own active filter.
|
|
185
|
+
private async fetchReferenceData(): Promise<ReferenceData> {
|
|
186
|
+
const start = Date.now();
|
|
187
|
+
|
|
188
|
+
const allProducts = await this._knex('products').select('id', 'name').then(rows => rows.reduce((acc: Record<string, string>, product: {id: string; name: string}) => {
|
|
189
|
+
acc[product.id] = product.name;
|
|
190
|
+
return acc;
|
|
191
|
+
}, {}));
|
|
192
|
+
|
|
193
|
+
const allLabels = await this._knex('labels').select('id', 'name').then(rows => rows.reduce((acc: Record<string, string>, label: {id: string; name: string}) => {
|
|
194
|
+
acc[label.id] = label.name;
|
|
195
|
+
return acc;
|
|
196
|
+
}, {}));
|
|
197
|
+
|
|
198
|
+
logging.info('[MembersExporter] Fetched products and labels in ' + (Date.now() - start) + 'ms');
|
|
199
|
+
|
|
200
|
+
const activeCustomFields = await this._customFields.activeDefinitions();
|
|
201
|
+
|
|
202
|
+
return {allProducts, allLabels, activeCustomFields};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Group the member stream into batches so the related-data reads are one query per
|
|
206
|
+
// batch rather than one per member.
|
|
207
|
+
private createBatchingTransform(): Transform {
|
|
208
|
+
let currentBatch: MemberDbRow[] = [];
|
|
209
|
+
|
|
210
|
+
return new Transform({
|
|
211
|
+
objectMode: true,
|
|
212
|
+
transform(member: MemberDbRow, encoding, callback) {
|
|
213
|
+
currentBatch.push(member);
|
|
214
|
+
|
|
215
|
+
if (currentBatch.length >= BATCH_SIZE) {
|
|
216
|
+
this.push(currentBatch);
|
|
217
|
+
currentBatch = [];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
callback();
|
|
221
|
+
},
|
|
222
|
+
flush(callback) {
|
|
223
|
+
if (currentBatch.length > 0) {
|
|
224
|
+
this.push(currentBatch);
|
|
225
|
+
}
|
|
226
|
+
callback();
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Each flattened row is pushed individually so a batch's rows do not pile up in memory.
|
|
232
|
+
private createProcessingTransform(reference: ReferenceData): Transform {
|
|
233
|
+
const assembleRelatedData = (memberIds: string[]) => this.assembleRelatedData(memberIds, reference.activeCustomFields);
|
|
234
|
+
const flattenBatch = (members: MemberDbRow[], related: BatchRelatedData) => this.flattenBatch(members, related, reference);
|
|
235
|
+
|
|
236
|
+
return new Transform({
|
|
237
|
+
objectMode: true,
|
|
238
|
+
highWaterMark: BATCH_SIZE,
|
|
239
|
+
async transform(batch: MemberDbRow[], encoding, callback) {
|
|
240
|
+
try {
|
|
241
|
+
const memberIds = batch.map(member => member.id);
|
|
242
|
+
const related = await assembleRelatedData(memberIds);
|
|
243
|
+
const rows = flattenBatch(batch, related);
|
|
244
|
+
rows.forEach(row => this.push(row));
|
|
245
|
+
callback();
|
|
246
|
+
} catch (err) {
|
|
247
|
+
callback(err instanceof Error ? err : new errors.InternalServerError({message: String(err)}));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// One query per related table for the whole batch, not one per member. Custom field
|
|
254
|
+
// values are read only when there are columns to fill.
|
|
255
|
+
private async assembleRelatedData(memberIds: string[], activeCustomFields: CustomFieldDefinition[]): Promise<BatchRelatedData> {
|
|
256
|
+
const knex = this._knex;
|
|
257
|
+
|
|
258
|
+
const [tiers, labels, stripeCustomers, subscriptions, gifts, customFieldValuesMap] = await Promise.all([
|
|
259
|
+
knex('members_products')
|
|
260
|
+
.select('member_id', knex.raw('GROUP_CONCAT(product_id) as tiers'))
|
|
261
|
+
.whereIn('member_id', memberIds)
|
|
262
|
+
.groupBy('member_id'),
|
|
263
|
+
|
|
264
|
+
knex('members_labels')
|
|
265
|
+
.select('member_id', knex.raw('GROUP_CONCAT(label_id) as labels'))
|
|
266
|
+
.whereIn('member_id', memberIds)
|
|
267
|
+
.groupBy('member_id'),
|
|
268
|
+
|
|
269
|
+
knex('members_stripe_customers')
|
|
270
|
+
.select('member_id', knex.raw('MIN(customer_id) as stripe_customer_id'))
|
|
271
|
+
.whereIn('member_id', memberIds)
|
|
272
|
+
.groupBy('member_id'),
|
|
273
|
+
|
|
274
|
+
knex('members_newsletters')
|
|
275
|
+
.distinct('member_id')
|
|
276
|
+
.whereIn('member_id', memberIds),
|
|
277
|
+
|
|
278
|
+
knex('gifts')
|
|
279
|
+
.select('id', 'redeemer_member_id')
|
|
280
|
+
.where('status', 'redeemed')
|
|
281
|
+
.whereIn('redeemer_member_id', memberIds),
|
|
282
|
+
|
|
283
|
+
activeCustomFields.length > 0
|
|
284
|
+
? this._customFields.valuesForMembers(memberIds)
|
|
285
|
+
: new Map<string, Record<string, unknown>>()
|
|
286
|
+
]);
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
tiersMap: new Map(tiers.map((row: {member_id: string; tiers: string}) => [row.member_id, row.tiers])),
|
|
290
|
+
labelsMap: new Map(labels.map((row: {member_id: string; labels: string}) => [row.member_id, row.labels])),
|
|
291
|
+
stripeCustomerMap: new Map(stripeCustomers.map((row: {member_id: string; stripe_customer_id: string}) => [row.member_id, row.stripe_customer_id])),
|
|
292
|
+
subscribedSet: new Set(subscriptions.map((row: {member_id: string}) => row.member_id)),
|
|
293
|
+
giftIdMap: new Map(gifts.map((row: {id: string; redeemer_member_id: string}) => [row.redeemer_member_id, row.id])),
|
|
294
|
+
customFieldValuesMap
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private flattenBatch(members: MemberDbRow[], related: BatchRelatedData, reference: ReferenceData): MemberExportRow[] {
|
|
299
|
+
const {tiersMap, labelsMap, stripeCustomerMap, subscribedSet, giftIdMap, customFieldValuesMap} = related;
|
|
300
|
+
const {allProducts, allLabels, activeCustomFields} = reference;
|
|
301
|
+
|
|
302
|
+
return members.map((row) => {
|
|
303
|
+
const tierConcat = tiersMap.get(row.id);
|
|
304
|
+
const tierIds = tierConcat ? tierConcat.split(',') : [];
|
|
305
|
+
const labelConcat = labelsMap.get(row.id);
|
|
306
|
+
const labelIds = labelConcat ? labelConcat.split(',') : [];
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
...row,
|
|
310
|
+
subscribed: subscribedSet.has(row.id),
|
|
311
|
+
comped: row.status === 'comped',
|
|
312
|
+
gift_id: giftIdMap.get(row.id) || null,
|
|
313
|
+
stripe_customer_id: stripeCustomerMap.get(row.id) || null,
|
|
314
|
+
created_at: moment(row.created_at).toISOString(),
|
|
315
|
+
tiers: tierIds.map(id => ({name: allProducts[id]})),
|
|
316
|
+
labels: labelIds.map(id => ({name: allLabels[id]})),
|
|
317
|
+
// Flattened here rather than in the serializer because the column set is
|
|
318
|
+
// only knowable from the database. Every member carries a cell for every
|
|
319
|
+
// active field's column, so a member with no values still contributes the
|
|
320
|
+
// full set -- the CSV header is taken from whichever row streams first.
|
|
321
|
+
custom_field_cells: csvCellsForFields(activeCustomFields, customFieldValuesMap.get(row.id) || {})
|
|
322
|
+
};
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.default = buildCompletionEmail;
|
|
4
|
+
const csv_1 = require("../csv");
|
|
5
|
+
const csv_2 = require("@tryghost/custom-field-types/csv");
|
|
6
|
+
const emailTemplate = require('./email-template');
|
|
7
|
+
// Turn an ORM validation error into copy a member manager can act on. Presentation
|
|
8
|
+
// only: the API response keeps the raw messages, and just the emailed report is
|
|
9
|
+
// rewritten.
|
|
10
|
+
function humaniseError(message) {
|
|
11
|
+
return message
|
|
12
|
+
.replace('Value in [members.email] cannot be blank.', 'Missing email address')
|
|
13
|
+
.replace('Value in [members.note] exceeds maximum length of 2000 characters.', '"Note" exceeds maximum length of 2000 characters')
|
|
14
|
+
.replace('Value in [members.subscribed] must be one of true, false, 0 or 1.', 'Value in "Subscribed to emails" must be "true" or "false"')
|
|
15
|
+
.replace('Validation (isEmail) failed for email', 'Invalid email address')
|
|
16
|
+
.replace(/No such customer:[^,]*/, 'Could not find Stripe customer');
|
|
17
|
+
}
|
|
18
|
+
function stringifyLabels(labels) {
|
|
19
|
+
return labels.map(label => (typeof label === 'string' ? label : label.name)).join(',');
|
|
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.
|
|
29
|
+
function toErrorReportRow(row) {
|
|
30
|
+
return {
|
|
31
|
+
id: row.id,
|
|
32
|
+
email: row.email,
|
|
33
|
+
name: row.name,
|
|
34
|
+
note: row.note,
|
|
35
|
+
subscribed_to_emails: row.subscribed,
|
|
36
|
+
complimentary_plan: row.complimentary_plan,
|
|
37
|
+
stripe_customer_id: row.stripe_customer_id,
|
|
38
|
+
created_at: row.created_at,
|
|
39
|
+
deleted_at: undefined,
|
|
40
|
+
labels: stringifyLabels(row.labels),
|
|
41
|
+
tiers: '',
|
|
42
|
+
gift_id: row.gift_id || null,
|
|
43
|
+
error: humaniseError(row.error)
|
|
44
|
+
};
|
|
45
|
+
}
|
|
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.
|
|
51
|
+
function buildErrorReport(errors) {
|
|
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 });
|
|
60
|
+
}
|
|
61
|
+
// Compose the completion email for a finished import: the summary and its links,
|
|
62
|
+
// plus the attached error report. Owns how the outcome is presented, so the
|
|
63
|
+
// importer yields only the result and never touches email or CSV formatting.
|
|
64
|
+
function buildCompletionEmail({ result, recipient, labelName, importLabel, urlFor }) {
|
|
65
|
+
const siteUrl = new URL(urlFor('home', null, true));
|
|
66
|
+
const membersUrl = new URL('members', urlFor('admin', null, true));
|
|
67
|
+
if (importLabel) {
|
|
68
|
+
membersUrl.searchParams.set('label', importLabel.slug);
|
|
69
|
+
}
|
|
70
|
+
const html = emailTemplate({ result, siteUrl, membersUrl, emailRecipient: recipient, importLabel });
|
|
71
|
+
const subject = result.imported > 0 ? 'Your member import is complete' : 'Your member import was unsuccessful';
|
|
72
|
+
return {
|
|
73
|
+
to: recipient,
|
|
74
|
+
subject,
|
|
75
|
+
html,
|
|
76
|
+
forceTextContent: true,
|
|
77
|
+
attachments: [{
|
|
78
|
+
filename: `${labelName} - Errors.csv`,
|
|
79
|
+
content: buildErrorReport(result.errors),
|
|
80
|
+
contentType: 'text/csv',
|
|
81
|
+
contentDisposition: 'attachment'
|
|
82
|
+
}]
|
|
83
|
+
};
|
|
84
|
+
}
|