ghost 6.54.0 → 6.54.1
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-kg-default-nodes-2.1.3.tgz +0 -0
- package/core/built/admin/assets/Growth-BTP1LthE.js +3 -0
- package/core/built/admin/assets/Newsletters-CSEX0myc.js +1 -0
- package/core/built/admin/assets/Overview-BjACEsJF.js +1 -0
- package/core/built/admin/assets/Web-CxxxjCcv.js +2 -0
- package/core/built/admin/assets/{account-migration-DLvVA5yq.js → account-migration-BklF2I_I.js} +1 -1
- package/core/built/admin/assets/activitypub/{account-migration-DHbZcxGM.js → account-migration-CNovr6W-.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-Ceslb1_M.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-DHAjBcV3.js} +2 -2
- package/core/built/admin/assets/activitypub/{domain-CmwSZLHK.js → domain-C1qQvJ8C.js} +5 -5
- package/core/built/admin/assets/activitypub/{edit-profile-CXdwNmVG.js → edit-profile-BcH-jEfM.js} +3 -3
- package/core/built/admin/assets/activitypub/{explore-DfbjzGM1.js → explore-AxwBh9dI.js} +3 -3
- package/core/built/admin/assets/activitypub/{feed-DSEBJOqf.js → feed-BaOlbh7a.js} +3 -3
- package/core/built/admin/assets/activitypub/{field-CY7XVcx6.js → field-B7mYYQpN.js} +2 -2
- package/core/built/admin/assets/activitypub/{inbox-DtQ0kytT.js → inbox-vXez-5uJ.js} +4 -4
- package/core/built/admin/assets/activitypub/{label-Dw96GQ7u.js → label-Chv5T8bw.js} +2 -2
- package/core/built/admin/assets/activitypub/layout-CziktJXo.js +1 -0
- package/core/built/admin/assets/activitypub/{moderation-Dg_-I-8C.js → moderation-DtSM2i1I.js} +4 -4
- package/core/built/admin/assets/activitypub/{note-Cy1-uKMY.js → note-Dckp4S-2.js} +3 -3
- package/core/built/admin/assets/activitypub/{notifications-B_fvoLIM.js → notifications-CG5T22vg.js} +3 -3
- package/core/built/admin/assets/activitypub/{preferences-ByJseZp6.js → preferences-D_799jdl.js} +6 -6
- package/core/built/admin/assets/activitypub/{profile-BFtbnIjz.js → profile-D2JfXBQl.js} +6 -6
- package/core/built/admin/assets/activitypub/{routes-Dh3xXfce.js → routes-CCHe2dQp.js} +28 -25
- package/core/built/admin/assets/activitypub/{settings-DTgqJPxM.js → settings-kx-9Enkz.js} +3 -3
- package/core/built/admin/assets/activitypub/{site-CdL9ZLqm.js → site-x4_IjVJ0.js} +2 -2
- package/core/built/admin/assets/activitypub/{tabs-ogqwoBlb.js → tabs-CyPRQQ1N.js} +3 -3
- package/core/built/admin/assets/activitypub/{use-reply-chain-data-CxonteFD.js → use-reply-chain-data-BAB6fNGt.js} +2 -2
- package/core/built/admin/assets/{audience-BKRdsFmy.js → audience-X_XsZivI.js} +1 -1
- package/core/built/admin/assets/{automated-emails-Cn87L5Md.js → automated-emails-of1MvVsv.js} +1 -1
- package/core/built/admin/assets/{automations-BP_aHamJ.js → automations-DRFjW5fw.js} +1 -1
- package/core/built/admin/assets/{bluesky-sharing-Bh36XPh4.js → bluesky-sharing-BbbjWdzs.js} +1 -1
- package/core/built/admin/assets/{breadcrumb-D34YWnaw.js → breadcrumb-B-M9Ip_2.js} +1 -1
- package/core/built/admin/assets/{chart-helpers-DbklaJw9.js → chart-helpers-xiSsW8r0.js} +1 -1
- package/core/built/admin/assets/checkbox-EhJKHFdZ.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.361.3cdb584b973959e52ccc.js} +3 -3
- package/core/built/admin/assets/chunk.524.8365cd9280405c0f6216.js +25 -0
- package/core/built/admin/assets/chunk.582.61d078937e6102769f2c.js +27 -0
- package/core/built/admin/assets/code-editor-view-CZtJFGhR.js +1 -0
- package/core/built/admin/assets/comments-CvyXxOyN.js +1 -0
- package/core/built/admin/assets/{date-range-select-B5vSsrpx.js → date-range-select-CTlyo8zW.js} +1 -1
- package/core/built/admin/assets/{disabled-sources-indicator-NkOIoMue.js → disabled-sources-indicator-qs2GfLvl.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-B7_n5nfx.js → domain-CPNXKVOV.js} +1 -1
- package/core/built/admin/assets/dropzone-BmCXnw6J.js +5 -0
- package/core/built/admin/assets/{edit-profile-rvDGrS8J.js → edit-profile-SGrJve5M.js} +1 -1
- package/core/built/admin/assets/editor-BOG2YPUm.js +8 -0
- package/core/built/admin/assets/{explore-BZAZOg5X.js → explore-CUYLv9G9.js} +1 -1
- package/core/built/admin/assets/{feed-CFV8xDw9.js → feed-CPdJMttL.js} +1 -1
- package/core/built/admin/assets/{field-BW-M2Zwp.js → field-D0XISCK2.js} +1 -1
- package/core/built/admin/assets/{filter-sources-Fx8wiYYw.js → filter-sources-Bieps75g.js} +1 -1
- package/core/built/admin/assets/{filters-CGX_pf2N.js → filters-CuB-A7ma.js} +1 -1
- package/core/built/admin/assets/filters-bXkkWro8.js +1 -0
- package/core/built/admin/assets/{ghost-b1dce91ad3232cc259ee5fbbf9526243.js → ghost-05f9a7182ed56903dba05878a5b8422b.js} +290 -289
- package/core/built/admin/assets/{gift-link-modal-Bk8qpnMw.js → gift-link-modal-su8MsRdg.js} +1 -1
- package/core/built/admin/assets/{growth-Ck2s3JjR.js → growth-Ba6S5FQX.js} +1 -1
- package/core/built/admin/assets/{inbox-BRp1_aaC.js → inbox-Xx1CdnfO.js} +8 -8
- package/core/built/admin/assets/index-D8PbzuT9.css +1 -0
- package/core/built/admin/assets/index-ukW_0Uie.js +67 -0
- package/core/built/admin/assets/input-group-CGOXcy4l.js +1 -0
- package/core/built/admin/assets/input-surface-BpAjZXY5.js +1 -0
- package/core/built/admin/assets/{koenig-lexical-CM7UFNdL.js → koenig-lexical-B61IALf5.js} +1 -1
- package/core/built/admin/assets/{kpi-card-CIGyijjx.js → kpi-card-BlZaG1Lo.js} +1 -1
- package/core/built/admin/assets/{kpi-card-BrGH6FsT.js → kpi-card-DCdlyaSS.js} +1 -1
- package/core/built/admin/assets/{kpi-helpers-_HRRsklE.js → kpi-helpers-DpAuS1Nb.js} +1 -1
- package/core/built/admin/assets/{kpi-tabs-BSvh9eoq.js → kpi-tabs-Dme44bQS.js} +1 -1
- package/core/built/admin/assets/{label-BOR9XdKl.js → label-dSs5aepE.js} +1 -1
- package/core/built/admin/assets/{label-picker-BdNAWuUX.js → label-picker-Uq91atex.js} +1 -1
- package/core/built/admin/assets/layout-WUcz6tu1.js +1 -0
- package/core/built/admin/assets/{member-custom-fields-DvFyamLu.js → member-custom-fields-NCzrMuba.js} +1 -1
- package/core/built/admin/assets/member-detail-DPWDtYnN.js +1 -0
- package/core/built/admin/assets/{members-DsPxYAVm.js → members-B9rO7qr4.js} +2 -2
- package/core/built/admin/assets/modals-CxRaan1J.js +31 -0
- package/core/built/admin/assets/{moderation-Dlp7M5sa.js → moderation-D8zon60E.js} +1 -1
- package/core/built/admin/assets/{multi-select-combobox-CH1o8XXQ.js → multi-select-combobox-1wYm3nle.js} +1 -1
- package/core/built/admin/assets/newsletter-DbYMRxRS.js +1 -0
- package/core/built/admin/assets/{note-BrAZ7Z0o.js → note-PiWPnnrh.js} +1 -1
- package/core/built/admin/assets/{notifications-BnbHLOPC.js → notifications-DEgqqB9G.js} +1 -1
- package/core/built/admin/assets/{onboarding-route-eU146kuA.js → onboarding-route-BzF6HeVn.js} +1 -1
- package/core/built/admin/assets/{overview-BpbaK-xu.js → overview-DixLeIqY.js} +1 -1
- package/core/built/admin/assets/{page-header-C0jVxtS8.js → page-header-op0eN36I.js} +1 -1
- package/core/built/admin/assets/{post-analytics-Iw7dk1mT.js → post-analytics-WwRDEnrV.js} +1 -1
- package/core/built/admin/assets/post-analytics-header-DsMoLx0R.js +1 -0
- package/core/built/admin/assets/{post-analytics-provider-BDqcfBor.js → post-analytics-provider-BJCPLMIb.js} +1 -1
- package/core/built/admin/assets/{post-share-modal-DBfKl2GZ.js → post-share-modal-Dx-MEjhI.js} +1 -1
- package/core/built/admin/assets/preferences-D7-j6A4C.js +5 -0
- package/core/built/admin/assets/profile-Dl69QPp0.js +1 -0
- package/core/built/admin/assets/select-C1qDZnW4.js +1 -0
- package/core/built/admin/assets/settings-BbmC_Iqq.js +4 -0
- package/core/built/admin/assets/{settings-Bz3RZRmE.js → settings-H6HxFgfI.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-BJCadJyp.js} +1 -1
- package/core/built/admin/assets/social-urls-BPL1nADp.js +8 -0
- package/core/built/admin/assets/{sort-button-DZp9Y2r1.js → sort-button-DyYlfYp7.js} +1 -1
- package/core/built/admin/assets/{source-icon-CHr9qJ2Y.js → source-icon-_ubRDbg_.js} +1 -1
- package/core/built/admin/assets/{stats-DGoXZxWE.js → stats-BQZMGMvn.js} +1 -1
- package/core/built/admin/assets/{stats-view-D2-1L0ms.js → stats-view-Doei-qCX.js} +1 -1
- package/core/built/admin/assets/{step-1-RdahbwyG.js → step-1-IKiLBPoC.js} +1 -1
- package/core/built/admin/assets/{step-2-D83PeRwd.js → step-2-jJ41PsDN.js} +1 -1
- package/core/built/admin/assets/{step-3-p_Op4wv4.js → step-3-CaqFAEqf.js} +2 -2
- package/core/built/admin/assets/switch-t-WyENU2.js +1 -0
- package/core/built/admin/assets/{table-B8LgTQJZ.js → table-DxUL78jU.js} +1 -1
- package/core/built/admin/assets/{tabs-D3ZOYkM7.js → tabs-BQcJQ1ke.js} +1 -1
- package/core/built/admin/assets/{tags-B4l891z2.js → tags-CNujGwkT.js} +1 -1
- package/core/built/admin/assets/textarea-Bjccvh_E.js +1 -0
- package/core/built/admin/assets/{toggle-group-DNXDkAwf.js → toggle-group-BeEpQ69p.js} +1 -1
- package/core/built/admin/assets/{topic-filter-DZGjEcz3.js → topic-filter-Bb5MOD-1.js} +1 -1
- package/core/built/admin/assets/{use-analytics-data-CaT8RirX.js → use-analytics-data-Cds6XDjg.js} +1 -1
- package/core/built/admin/assets/use-filter-params-DjXkhO54.js +1 -0
- package/core/built/admin/assets/{use-growth-stats-9Db2CuJe.js → use-growth-stats-D8uf98-G.js} +1 -1
- package/core/built/admin/assets/{use-post-referrers-BV54YcQS.js → use-post-referrers-CRtgyuXB.js} +1 -1
- package/core/built/admin/assets/{use-reply-chain-data-DuYZGDan.js → use-reply-chain-data-DbKYX50v.js} +1 -1
- package/core/built/admin/assets/{use-simple-pagination-8fTua9Nt.js → use-simple-pagination-DC01kpo0.js} +1 -1
- package/core/built/admin/assets/{use-tinybird-query-Dz156HvZ.js → use-tinybird-query-C7bQRJWw.js} +1 -1
- package/core/built/admin/assets/{use-tinybird-token-C9RUdL_n.js → use-tinybird-token-Az4tam7_.js} +1 -1
- package/core/built/admin/assets/{virtual-list-BCvP0YF-.js → virtual-list-Dhuh9fGx.js} +1 -1
- package/core/built/admin/assets/web-LtHNiNBV.js +1 -0
- package/core/built/admin/index.html +11 -7
- package/core/frontend/services/proxy.js +11 -1
- 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/db.js +6 -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/utils/serializers/output/mappers/comments.js +4 -1
- package/core/server/api/endpoints/utils/serializers/output/members.js +18 -87
- 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/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 +6 -6
- package/core/server/services/automations/automations-api.ts +6 -6
- package/core/server/services/automations/automations-repository.ts +1 -0
- package/core/server/services/automations/database-automations-repository.js +7 -2
- package/core/server/services/automations/database-automations-repository.ts +8 -2
- package/core/server/services/comments/comments-service-emails.js +4 -1
- 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/{csv → import-export/csv}/index.js +3 -3
- package/core/server/services/members/import-export/csv/index.ts +2 -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 +65 -0
- package/core/server/services/members/import-export/import/completion-email.ts +119 -0
- package/core/server/services/members/import-export/import/importer.js +276 -0
- package/core/server/services/members/import-export/import/importer.ts +430 -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 +90 -0
- package/core/server/services/members/import-export/index.ts +118 -0
- package/core/server/services/members/members-api/controllers/router-controller.js +7 -0
- package/core/server/services/members/service.js +17 -19
- package/core/server/services/posts/posts-exporter.js +6 -4
- package/core/server/services/posts/posts-service.js +2 -2
- package/core/server/services/themes/storage.js +9 -0
- package/core/server/services/themes/theme-storage.js +1 -1
- 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 +9 -9
- package/pnpm-lock.yaml +17 -17
- package/pnpm-workspace.yaml +7 -7
- package/components/tryghost-adapter-base-scheduling-0.1.1.tgz +0 -0
- package/core/built/admin/assets/Growth-CSaHoSVD.js +0 -3
- package/core/built/admin/assets/Newsletters-C5cs6FGo.js +0 -1
- 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/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/dist-CT46_f_J.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/filters-DtcXMZDU.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/layout-Bw4GnxXp.js +0 -1
- package/core/built/admin/assets/member-detail-Cg2vTqvJ.js +0 -1
- package/core/built/admin/assets/modals-Bf_fyvHZ.js +0 -31
- package/core/built/admin/assets/newsletter-CQ5CAYUI.js +0 -1
- package/core/built/admin/assets/post-analytics-header-D9pJ4iNm.js +0 -1
- package/core/built/admin/assets/preferences-D4EANGoc.js +0 -5
- 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/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.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/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,430 @@
|
|
|
1
|
+
import moment from 'moment-timezone';
|
|
2
|
+
import buildCompletionEmail from './completion-email';
|
|
3
|
+
import type {Knex} from 'knex';
|
|
4
|
+
import type {MemberImportRow, ImportErrorRow, ImportLabel, Label} from './row';
|
|
5
|
+
import type {RowSpool, SpooledRows} from './spool';
|
|
6
|
+
|
|
7
|
+
const metrics = require('@tryghost/metrics');
|
|
8
|
+
const errors = require('@tryghost/errors');
|
|
9
|
+
const tpl = require('@tryghost/tpl');
|
|
10
|
+
const logging = require('@tryghost/logging');
|
|
11
|
+
|
|
12
|
+
// The members CSV importer, sliced into one concern per method. Two entry points by
|
|
13
|
+
// design: importCSV decides inline-vs-deferred by load, since a large import must not
|
|
14
|
+
// hold a request open; importInline always runs now, for callers with no request to
|
|
15
|
+
// protect (the Revue data import). The kernel takes a plain MemberImportRow array -- the
|
|
16
|
+
// CSV reader and the deferred spool both produce one -- so a test can drive it from a
|
|
17
|
+
// literal array. Collaborators are injected one per concern; knex is first-class, and no
|
|
18
|
+
// Bookshelf model is referenced below the boundary that owns it.
|
|
19
|
+
|
|
20
|
+
// The import service's arguments, built by the endpoint from the request frame.
|
|
21
|
+
// requestUserEmail is null when the request carried no user, so the deferred path falls
|
|
22
|
+
// back to the site owner.
|
|
23
|
+
export interface ImportRequest {
|
|
24
|
+
filePath: string;
|
|
25
|
+
mapping?: Record<string, string>;
|
|
26
|
+
extraLabels?: Label[];
|
|
27
|
+
requestUserEmail: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface VerificationTrigger {
|
|
31
|
+
testImportThreshold(): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A tier, narrowed to the id the import reads off it.
|
|
35
|
+
export interface Tier {
|
|
36
|
+
id: {toString(): string};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A member model, narrowed to what the import loop touches. Relations are loaded
|
|
40
|
+
// via withRelated before the loop reads them, so related() is treated as present.
|
|
41
|
+
interface MemberModel {
|
|
42
|
+
id: string;
|
|
43
|
+
name: string;
|
|
44
|
+
note: string;
|
|
45
|
+
related(relation: string): {toJSON(): Array<{name: string}>; length: number};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Reads a source into import rows. The CSV reader is the only implementation today;
|
|
49
|
+
// a test can inject one that returns a fixed rows array with no file involved.
|
|
50
|
+
type ReadRows = (path: string, mapping?: Record<string, string>) => Promise<MemberImportRow[]>;
|
|
51
|
+
|
|
52
|
+
// The members aggregate: the one thing the import writes. Its methods are the
|
|
53
|
+
// member operations a row performs, plus the import label lookup, which is a
|
|
54
|
+
// member-tagging concern rather than a store of its own. Hides the Member and
|
|
55
|
+
// Label models behind the shape the import needs.
|
|
56
|
+
export interface MembersRepository {
|
|
57
|
+
get(data: {email?: string}, options: object): Promise<MemberModel | null>;
|
|
58
|
+
create(values: object, options: object): Promise<MemberModel>;
|
|
59
|
+
update(values: object, options: object): Promise<MemberModel>;
|
|
60
|
+
getCustomerIdByEmail(email: string): Promise<string | undefined>;
|
|
61
|
+
linkStripeCustomer(data: {customer_id: string; member_id: string}, options: object): Promise<void>;
|
|
62
|
+
getImportLabel(name: string): Promise<{toJSON(): ImportLabel} | null>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Tiers: the tier catalog, an aggregate of its own, consulted to resolve the
|
|
66
|
+
// default tier and a tier a row names.
|
|
67
|
+
interface TiersRepository {
|
|
68
|
+
getDefault(): Promise<Tier>;
|
|
69
|
+
getByName(name: string): Promise<Tier | null>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Stripe: the external subscription work a row with Stripe data triggers.
|
|
73
|
+
interface StripeSubscriptions {
|
|
74
|
+
forceStripeSubscriptionToProduct(
|
|
75
|
+
args: {customer_id: string; product_id?: string},
|
|
76
|
+
options: object
|
|
77
|
+
): Promise<{isNewStripePrice: boolean; stripePriceId: string}>;
|
|
78
|
+
archivePrice(stripePriceId: string): Promise<void>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Gifts: reassigning a redeemed gift to the imported member.
|
|
82
|
+
export interface GiftService {
|
|
83
|
+
reassignRedeemer(giftId: string, memberId: string, options: object): Promise<void>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The completion email concern: who it goes to, the links it carries, and sending
|
|
87
|
+
// it. The default recipient is the site owner, used when the request had no user.
|
|
88
|
+
export interface EmailNotifications {
|
|
89
|
+
send(options: object): Promise<unknown>;
|
|
90
|
+
getDefaultRecipient(): Promise<string>;
|
|
91
|
+
urlFor(type: string, data: unknown, absolute: boolean): string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The collaborators the import depends on, one per concern.
|
|
95
|
+
interface ImporterDeps {
|
|
96
|
+
knex: Knex;
|
|
97
|
+
readRows: ReadRows;
|
|
98
|
+
spool: RowSpool;
|
|
99
|
+
members: MembersRepository;
|
|
100
|
+
tiers: TiersRepository;
|
|
101
|
+
stripe: StripeSubscriptions;
|
|
102
|
+
gifts: GiftService;
|
|
103
|
+
email: EmailNotifications;
|
|
104
|
+
addJob: (job: {job: () => Promise<void>; offloaded: boolean; name: string}) => void;
|
|
105
|
+
getTimezone: () => string;
|
|
106
|
+
getInlineThreshold: () => number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The values built for one row and handed to the members repository. Indexed off
|
|
110
|
+
// MemberImportRow so a change to the import schema surfaces here: rename or retype a
|
|
111
|
+
// field the kernel writes and this stops compiling until the mapping is updated. name
|
|
112
|
+
// and note may be replaced with the existing member's values (both strings, already in
|
|
113
|
+
// range); created_at may be clamped to now when the row dates a member in the future;
|
|
114
|
+
// newsletters is opaque ORM relation JSON, an honest unknown at the model boundary.
|
|
115
|
+
interface MemberImportValues {
|
|
116
|
+
email: MemberImportRow['email'];
|
|
117
|
+
name: MemberImportRow['name'];
|
|
118
|
+
note: MemberImportRow['note'];
|
|
119
|
+
subscribed: MemberImportRow['subscribed'];
|
|
120
|
+
created_at: MemberImportRow['created_at'] | Date;
|
|
121
|
+
labels: Label[];
|
|
122
|
+
newsletters?: unknown;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The kernel does the importing, so it is what knows whether anything imported and,
|
|
126
|
+
// if so, which label the members were tagged with (a label is only persisted once
|
|
127
|
+
// attached to a member). The label is absent when nothing imported; the orchestrator
|
|
128
|
+
// reads it as null and never fetches it itself.
|
|
129
|
+
interface ImportResult {
|
|
130
|
+
imported: number;
|
|
131
|
+
errors: ImportErrorRow[];
|
|
132
|
+
importLabel?: ImportLabel;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// What the domain service returns: an import that ran inline carries its stats and
|
|
136
|
+
// label for the response; a deferred one carries only the size accepted, because
|
|
137
|
+
// the work -- and its email -- finishes after the request is answered.
|
|
138
|
+
export type ImportOutcome =
|
|
139
|
+
| {deferred: false; originalImportSize: number; result: ImportResult}
|
|
140
|
+
| {deferred: true; originalImportSize: number};
|
|
141
|
+
|
|
142
|
+
const messages = {
|
|
143
|
+
freeMemberNotAllowedImportTier: 'You cannot import a free member with a specified tier.',
|
|
144
|
+
invalidImportTier: '"{tier}" is not a valid tier.',
|
|
145
|
+
giftCannotCombineWithImportTier: 'Cannot specify both gift_id and import_tier.',
|
|
146
|
+
giftCannotCombineWithComplimentary: 'Cannot specify both gift_id and complimentary_plan.',
|
|
147
|
+
giftReassignFailed: 'Failed to reassign gift to member.'
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// Columns whose presence makes a row slow to import (they reach out to Stripe), so
|
|
151
|
+
// a file carrying any of them is deferred regardless of size. The routing decision
|
|
152
|
+
// only ever sees the boolean this produces, never the column names themselves.
|
|
153
|
+
const EXPENSIVE_COLUMNS = ['stripe_customer_id'];
|
|
154
|
+
|
|
155
|
+
// The context each repository call runs under: the import identifies itself, and a
|
|
156
|
+
// created member is additionally flagged as imported so downstream hooks can tell.
|
|
157
|
+
const IMPORT_CONTEXT = {importer: true};
|
|
158
|
+
const CREATE_CONTEXT = {import: true};
|
|
159
|
+
|
|
160
|
+
// The import label members are tagged with, named for the request time. Shared by
|
|
161
|
+
// the kernel (the label) and the deferred email (the error CSV filename).
|
|
162
|
+
function buildImportLabelName(timezone: string): string {
|
|
163
|
+
return `Import ${moment().tz(timezone).format('YYYY-MM-DD HH:mm')}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function hasExpensiveColumns(rows: MemberImportRow[]): boolean {
|
|
167
|
+
return rows.some(row => EXPENSIVE_COLUMNS.some(column => !!row[column]));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// The routing decision, in terms it can reason about without knowing what a member
|
|
171
|
+
// or a Stripe customer is: an empty or small, cheap file imports inline (an empty
|
|
172
|
+
// one simply imports nothing); a large one, or one carrying expensive columns, is
|
|
173
|
+
// too slow to hold a request open, so it is deferred to a background job.
|
|
174
|
+
function canImportInline(rowCount: number, expensive: boolean, inlineThreshold: number): boolean {
|
|
175
|
+
return rowCount <= inlineThreshold && !expensive;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
class MembersCSVImporter {
|
|
179
|
+
private _knex: Knex;
|
|
180
|
+
private _readRows: ReadRows;
|
|
181
|
+
private _spool: RowSpool;
|
|
182
|
+
private _members: MembersRepository;
|
|
183
|
+
private _tiers: TiersRepository;
|
|
184
|
+
private _stripe: StripeSubscriptions;
|
|
185
|
+
private _gifts: GiftService;
|
|
186
|
+
private _email: EmailNotifications;
|
|
187
|
+
private _addJob: (job: {job: () => Promise<void>; offloaded: boolean; name: string}) => void;
|
|
188
|
+
private _getTimezone: () => string;
|
|
189
|
+
private _getInlineThreshold: () => number;
|
|
190
|
+
|
|
191
|
+
constructor({knex, readRows, spool, members, tiers, stripe, gifts, email, addJob, getTimezone, getInlineThreshold}: ImporterDeps) {
|
|
192
|
+
this._knex = knex;
|
|
193
|
+
this._readRows = readRows;
|
|
194
|
+
this._spool = spool;
|
|
195
|
+
this._members = members;
|
|
196
|
+
this._tiers = tiers;
|
|
197
|
+
this._stripe = stripe;
|
|
198
|
+
this._gifts = gifts;
|
|
199
|
+
this._email = email;
|
|
200
|
+
this._addJob = addJob;
|
|
201
|
+
this._getTimezone = getTimezone;
|
|
202
|
+
this._getInlineThreshold = getInlineThreshold;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async importCSV(request: ImportRequest, verificationTrigger: VerificationTrigger): Promise<ImportOutcome> {
|
|
206
|
+
const rows = await this._readRows(request.filePath, request.mapping);
|
|
207
|
+
const labelName = buildImportLabelName(this._getTimezone());
|
|
208
|
+
const extraLabels = request.extraLabels ?? [];
|
|
209
|
+
|
|
210
|
+
if (canImportInline(rows.length, hasExpensiveColumns(rows), this._getInlineThreshold())) {
|
|
211
|
+
const result = await this.importRows(rows, labelName, extraLabels);
|
|
212
|
+
await verificationTrigger.testImportThreshold();
|
|
213
|
+
return {deferred: false, originalImportSize: rows.length, result};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
await this.deferImport(rows, {labelName, extraLabels, requestUserEmail: request.requestUserEmail}, verificationTrigger);
|
|
217
|
+
return {deferred: true, originalImportSize: rows.length};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async importInline(request: ImportRequest, verificationTrigger: VerificationTrigger): Promise<ImportResult> {
|
|
221
|
+
const rows = await this._readRows(request.filePath, request.mapping);
|
|
222
|
+
const result = await this.importRows(rows, buildImportLabelName(this._getTimezone()), request.extraLabels ?? []);
|
|
223
|
+
await verificationTrigger.testImportThreshold();
|
|
224
|
+
return result;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private async deferImport(
|
|
228
|
+
rows: MemberImportRow[],
|
|
229
|
+
{labelName, extraLabels, requestUserEmail}: {labelName: string; extraLabels: Label[]; requestUserEmail: string | null},
|
|
230
|
+
verificationTrigger: VerificationTrigger
|
|
231
|
+
): Promise<void> {
|
|
232
|
+
// Resolved here, not at the API boundary, so the owner lookup only runs when
|
|
233
|
+
// a request without a user actually reaches the deferred path.
|
|
234
|
+
const emailRecipient: string = requestUserEmail ?? await this._email.getDefaultRecipient();
|
|
235
|
+
const spooled = await this._spool.write(rows);
|
|
236
|
+
|
|
237
|
+
this._addJob({
|
|
238
|
+
job: () => this.runImportJob(spooled, {labelName, extraLabels, emailRecipient}, verificationTrigger),
|
|
239
|
+
offloaded: false,
|
|
240
|
+
name: 'members-import'
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Swallows its own errors: a failed import or email must not leave the queued job
|
|
245
|
+
// rejected, and the verification trigger must still run afterwards either way.
|
|
246
|
+
private async runImportJob(
|
|
247
|
+
spooled: SpooledRows,
|
|
248
|
+
{labelName, extraLabels, emailRecipient}: {labelName: string; extraLabels: Label[]; emailRecipient: string},
|
|
249
|
+
verificationTrigger: VerificationTrigger
|
|
250
|
+
): Promise<void> {
|
|
251
|
+
try {
|
|
252
|
+
const spooledRows = await spooled.read();
|
|
253
|
+
const result = await this.importRows(spooledRows, labelName, extraLabels);
|
|
254
|
+
const importLabel = result.importLabel ?? null;
|
|
255
|
+
await this._email.send(buildCompletionEmail({
|
|
256
|
+
result,
|
|
257
|
+
recipient: emailRecipient,
|
|
258
|
+
labelName,
|
|
259
|
+
importLabel,
|
|
260
|
+
urlFor: this._email.urlFor
|
|
261
|
+
}));
|
|
262
|
+
} catch (e) {
|
|
263
|
+
logging.error('Error in members import job');
|
|
264
|
+
logging.error(e);
|
|
265
|
+
} finally {
|
|
266
|
+
await spooled.remove();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
await verificationTrigger.testImportThreshold();
|
|
271
|
+
} catch (e) {
|
|
272
|
+
logging.error('Error in members import job when testing import threshold');
|
|
273
|
+
logging.error(e);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// The members import kernel, shared by the inline and deferred paths. Each row is
|
|
278
|
+
// created or updated in its own transaction, so one row's failure rolls back only
|
|
279
|
+
// itself; failures are collected, and any Stripe prices an import tier created are
|
|
280
|
+
// archived at the end.
|
|
281
|
+
private async importRows(rows: MemberImportRow[], labelName: string, extraLabels: Label[]): Promise<ImportResult> {
|
|
282
|
+
const importLabel: Label = {name: labelName};
|
|
283
|
+
const globalLabels: Label[] = [importLabel, ...extraLabels];
|
|
284
|
+
const performStart = Date.now();
|
|
285
|
+
const defaultTier = await this._tiers.getDefault();
|
|
286
|
+
const tierIdCache = new Map();
|
|
287
|
+
const archivableStripePriceIds: string[] = [];
|
|
288
|
+
// Copied per row: the member model stamps ids and trims names onto these in
|
|
289
|
+
// place, and each row runs in its own transaction that can roll back. A
|
|
290
|
+
// caller can hand in a nameless label, which the model would drop anyway.
|
|
291
|
+
const cloneGlobalLabels = (): Label[] => globalLabels
|
|
292
|
+
.map(label => ({...label}))
|
|
293
|
+
.filter(label => label.name);
|
|
294
|
+
|
|
295
|
+
let imported = 0;
|
|
296
|
+
const importErrors: ImportErrorRow[] = [];
|
|
297
|
+
for (const row of rows) {
|
|
298
|
+
const trx = await this._knex.transaction(undefined, {doNotRejectOnRollback: false});
|
|
299
|
+
const options = {transacting: trx, context: IMPORT_CONTEXT};
|
|
300
|
+
try {
|
|
301
|
+
if (row.gift_id) {
|
|
302
|
+
if (row.import_tier) {
|
|
303
|
+
throw wrapGiftError(new errors.DataImportError({message: tpl(messages.giftCannotCombineWithImportTier)}));
|
|
304
|
+
}
|
|
305
|
+
if (row.complimentary_plan) {
|
|
306
|
+
throw wrapGiftError(new errors.DataImportError({message: tpl(messages.giftCannotCombineWithComplimentary)}));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const createdAt = (row.created_at && moment(row.created_at).isAfter(moment())) ? moment().toDate() : row.created_at;
|
|
311
|
+
const memberValues: MemberImportValues = {
|
|
312
|
+
email: row.email,
|
|
313
|
+
name: row.name,
|
|
314
|
+
note: row.note,
|
|
315
|
+
subscribed: row.subscribed,
|
|
316
|
+
created_at: createdAt,
|
|
317
|
+
labels: [...row.labels, ...cloneGlobalLabels()]
|
|
318
|
+
};
|
|
319
|
+
const existingMember = row.email
|
|
320
|
+
? await this._members.get({email: row.email}, {...options, withRelated: ['labels', 'newsletters']})
|
|
321
|
+
: null;
|
|
322
|
+
let member;
|
|
323
|
+
if (existingMember) {
|
|
324
|
+
const existingLabels = existingMember.related('labels').toJSON();
|
|
325
|
+
const existingNewsletters = existingMember.related('newsletters');
|
|
326
|
+
if (existingNewsletters.length > 0 && memberValues.subscribed) {
|
|
327
|
+
memberValues.newsletters = existingNewsletters.toJSON();
|
|
328
|
+
}
|
|
329
|
+
if (!existingNewsletters.length && memberValues.subscribed) {
|
|
330
|
+
memberValues.subscribed = false;
|
|
331
|
+
}
|
|
332
|
+
if (!row.name) {
|
|
333
|
+
memberValues.name = existingMember.name;
|
|
334
|
+
}
|
|
335
|
+
if (!row.note) {
|
|
336
|
+
memberValues.note = existingMember.note;
|
|
337
|
+
}
|
|
338
|
+
member = await this._members.update({
|
|
339
|
+
...memberValues,
|
|
340
|
+
labels: existingLabels.concat(memberValues.labels)
|
|
341
|
+
}, {...options, id: existingMember.id});
|
|
342
|
+
} else {
|
|
343
|
+
member = await this._members.create(memberValues, Object.assign({}, options, {context: CREATE_CONTEXT}));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
let importTierId;
|
|
347
|
+
if (row.import_tier) {
|
|
348
|
+
if (!tierIdCache.has(row.import_tier)) {
|
|
349
|
+
const tier = await this._tiers.getByName(row.import_tier);
|
|
350
|
+
tierIdCache.set(row.import_tier, tier ? tier.id.toString() : null);
|
|
351
|
+
}
|
|
352
|
+
importTierId = tierIdCache.get(row.import_tier);
|
|
353
|
+
if (!importTierId) {
|
|
354
|
+
throw new errors.DataImportError({message: tpl(messages.invalidImportTier, {tier: row.import_tier})});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (row.stripe_customer_id) {
|
|
359
|
+
let stripeCustomerId;
|
|
360
|
+
if (row.stripe_customer_id.toLowerCase() === 'auto') {
|
|
361
|
+
stripeCustomerId = row.email ? await this._members.getCustomerIdByEmail(row.email) : undefined;
|
|
362
|
+
} else {
|
|
363
|
+
stripeCustomerId = row.stripe_customer_id;
|
|
364
|
+
}
|
|
365
|
+
if (stripeCustomerId) {
|
|
366
|
+
if (row.import_tier) {
|
|
367
|
+
const {isNewStripePrice, stripePriceId} = await this._stripe.forceStripeSubscriptionToProduct({
|
|
368
|
+
customer_id: stripeCustomerId,
|
|
369
|
+
product_id: importTierId
|
|
370
|
+
}, options);
|
|
371
|
+
if (isNewStripePrice) {
|
|
372
|
+
archivableStripePriceIds.push(stripePriceId);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
await this._members.linkStripeCustomer({customer_id: stripeCustomerId, member_id: member.id}, options);
|
|
376
|
+
}
|
|
377
|
+
} else if (row.complimentary_plan) {
|
|
378
|
+
const products = [];
|
|
379
|
+
if (row.import_tier) {
|
|
380
|
+
products.push({id: importTierId});
|
|
381
|
+
} else {
|
|
382
|
+
products.push({id: defaultTier.id.toString()});
|
|
383
|
+
}
|
|
384
|
+
await this._members.update({products}, {...options, id: member.id});
|
|
385
|
+
} else if (row.import_tier) {
|
|
386
|
+
throw new errors.DataImportError({message: tpl(messages.freeMemberNotAllowedImportTier)});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (row.gift_id) {
|
|
390
|
+
try {
|
|
391
|
+
await this._gifts.reassignRedeemer(row.gift_id, member.id, {transacting: trx});
|
|
392
|
+
} catch (giftError) {
|
|
393
|
+
throw wrapGiftError(giftError);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
await trx.commit();
|
|
398
|
+
imported += 1;
|
|
399
|
+
} catch (error) {
|
|
400
|
+
const errorList: unknown[] = Array.isArray(error) ? error : [error];
|
|
401
|
+
const errorMessage = errorList
|
|
402
|
+
.map(e => (typeof e === 'object' && e !== null && 'message' in e ? e.message : undefined))
|
|
403
|
+
.join(', ');
|
|
404
|
+
await trx.rollback();
|
|
405
|
+
importErrors.push({...row, error: errorMessage});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
await Promise.all(archivableStripePriceIds.map(id => this._stripe.archivePrice(id)));
|
|
410
|
+
metrics.metric('members-import', {imported, errors: importErrors.length, value: Date.now() - performStart});
|
|
411
|
+
|
|
412
|
+
if (imported > 0) {
|
|
413
|
+
// The import label exists now that members carry it, so fetch it to report.
|
|
414
|
+
// Guarded against a null lookup in case a row is ever counted without it.
|
|
415
|
+
const importLabelModel = await this._members.getImportLabel(labelName);
|
|
416
|
+
return {imported, errors: importErrors, importLabel: importLabelModel?.toJSON()};
|
|
417
|
+
}
|
|
418
|
+
return {imported: 0, errors: importErrors};
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function wrapGiftError(error: unknown) {
|
|
423
|
+
const err = error as {message?: unknown};
|
|
424
|
+
const message = (err && typeof err.message === 'string' && err.message) || tpl(messages.giftReassignFailed);
|
|
425
|
+
return new errors.DataImportError({
|
|
426
|
+
message: `Member cannot be assigned to a gift: ${message}`
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export default MembersCSVImporter;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.default = readMemberRows;
|
|
4
|
+
const csv_1 = require("../csv");
|
|
5
|
+
const row_1 = require("./row");
|
|
6
|
+
// Each accepted CSV header (the key) maps to the member field it fills (the value):
|
|
7
|
+
// a "subscribed_to_emails" column fills the "subscribed" field, and so on.
|
|
8
|
+
const FIELD_BY_HEADER = {
|
|
9
|
+
email: 'email',
|
|
10
|
+
name: 'name',
|
|
11
|
+
note: 'note',
|
|
12
|
+
subscribed_to_emails: 'subscribed',
|
|
13
|
+
created_at: 'created_at',
|
|
14
|
+
complimentary_plan: 'complimentary_plan',
|
|
15
|
+
stripe_customer_id: 'stripe_customer_id',
|
|
16
|
+
labels: 'labels',
|
|
17
|
+
import_tier: 'import_tier',
|
|
18
|
+
gift_id: 'gift_id'
|
|
19
|
+
};
|
|
20
|
+
// Resolve a caller mapping's target to the member field it fills. The target is usually
|
|
21
|
+
// a field already, but a caller may name a default header (e.g. "subscribed_to_emails"),
|
|
22
|
+
// which resolves to its field. hasOwn keeps a prototype name from matching a method.
|
|
23
|
+
function toMemberField(target) {
|
|
24
|
+
return Object.hasOwn(FIELD_BY_HEADER, target) ? FIELD_BY_HEADER[target] : target;
|
|
25
|
+
}
|
|
26
|
+
async function readMemberRows(path, mapping) {
|
|
27
|
+
const headerToField = mapping
|
|
28
|
+
? Object.fromEntries(Object.entries(mapping).map(([header, target]) => [header, toMemberField(target)]))
|
|
29
|
+
: FIELD_BY_HEADER;
|
|
30
|
+
const rows = await (0, csv_1.parse)(path, headerToField);
|
|
31
|
+
return rows.map(row => row_1.memberImportRowSchema.parse(row));
|
|
32
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import {parse as parseCSV} from '../csv';
|
|
2
|
+
import {memberImportRowSchema, type MemberImportRow} from './row';
|
|
3
|
+
|
|
4
|
+
// Each accepted CSV header (the key) maps to the member field it fills (the value):
|
|
5
|
+
// a "subscribed_to_emails" column fills the "subscribed" field, and so on.
|
|
6
|
+
const FIELD_BY_HEADER: Record<string, string> = {
|
|
7
|
+
email: 'email',
|
|
8
|
+
name: 'name',
|
|
9
|
+
note: 'note',
|
|
10
|
+
subscribed_to_emails: 'subscribed',
|
|
11
|
+
created_at: 'created_at',
|
|
12
|
+
complimentary_plan: 'complimentary_plan',
|
|
13
|
+
stripe_customer_id: 'stripe_customer_id',
|
|
14
|
+
labels: 'labels',
|
|
15
|
+
import_tier: 'import_tier',
|
|
16
|
+
gift_id: 'gift_id'
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Resolve a caller mapping's target to the member field it fills. The target is usually
|
|
20
|
+
// a field already, but a caller may name a default header (e.g. "subscribed_to_emails"),
|
|
21
|
+
// which resolves to its field. hasOwn keeps a prototype name from matching a method.
|
|
22
|
+
function toMemberField(target: string): string {
|
|
23
|
+
return Object.hasOwn(FIELD_BY_HEADER, target) ? FIELD_BY_HEADER[target] : target;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default async function readMemberRows(path: string, mapping?: Record<string, string>): Promise<MemberImportRow[]> {
|
|
27
|
+
const headerToField: Record<string, string> = mapping
|
|
28
|
+
? Object.fromEntries(Object.entries(mapping).map(([header, target]): [string, string] => [header, toMemberField(target)]))
|
|
29
|
+
: FIELD_BY_HEADER;
|
|
30
|
+
|
|
31
|
+
const rows = await parseCSV(path, headerToField);
|
|
32
|
+
return rows.map(row => memberImportRowSchema.parse(row));
|
|
33
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.memberImportRowSchema = void 0;
|
|
4
|
+
const zod_1 = require("zod");
|
|
5
|
+
function splitLabels(cell) {
|
|
6
|
+
return cell ? cell.split(',').map(name => ({ name })) : [];
|
|
7
|
+
}
|
|
8
|
+
// An empty cell (or the literal 'undefined') reads as absent, not as a value -- so an
|
|
9
|
+
// empty created_at is a missing date, not the invalid empty string.
|
|
10
|
+
const optionalCell = zod_1.z.string()
|
|
11
|
+
.transform(cell => (cell === '' || cell === 'undefined' ? undefined : cell))
|
|
12
|
+
.optional();
|
|
13
|
+
// Present cells are lenient in opposite directions: subscribed unless the cell reads
|
|
14
|
+
// 'false', comped only when it reads 'true'. An absent column stays undefined (optional,
|
|
15
|
+
// not defaulted), so a CSV lacking these columns leaves the member's state untouched.
|
|
16
|
+
const isSubscribed = (cell) => cell.toLowerCase() !== 'false';
|
|
17
|
+
const isComplimentary = (cell) => cell.toLowerCase() === 'true';
|
|
18
|
+
// The member import vocabulary as a schema: the single source of truth that coerces the
|
|
19
|
+
// raw string Row into typed member fields. Unknown columns (custom_fields.*, and anything
|
|
20
|
+
// else a source carries) pass through via .loose() so nothing is lost before the kernel.
|
|
21
|
+
exports.memberImportRowSchema = zod_1.z.object({
|
|
22
|
+
id: optionalCell,
|
|
23
|
+
email: optionalCell,
|
|
24
|
+
name: optionalCell,
|
|
25
|
+
note: optionalCell,
|
|
26
|
+
subscribed: zod_1.z.string().transform(isSubscribed).optional(),
|
|
27
|
+
complimentary_plan: zod_1.z.string().transform(isComplimentary).optional(),
|
|
28
|
+
stripe_customer_id: optionalCell,
|
|
29
|
+
created_at: optionalCell,
|
|
30
|
+
import_tier: optionalCell,
|
|
31
|
+
gift_id: optionalCell,
|
|
32
|
+
labels: zod_1.z.string().default('').transform(splitLabels)
|
|
33
|
+
}).loose();
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import {z} from 'zod';
|
|
2
|
+
|
|
3
|
+
// Labels reach the import as a comma-separated cell and are split into objects here.
|
|
4
|
+
export interface Label {
|
|
5
|
+
name: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function splitLabels(cell: string): Label[] {
|
|
9
|
+
return cell ? cell.split(',').map(name => ({name})) : [];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// An empty cell (or the literal 'undefined') reads as absent, not as a value -- so an
|
|
13
|
+
// empty created_at is a missing date, not the invalid empty string.
|
|
14
|
+
const optionalCell = z.string()
|
|
15
|
+
.transform(cell => (cell === '' || cell === 'undefined' ? undefined : cell))
|
|
16
|
+
.optional();
|
|
17
|
+
|
|
18
|
+
// Present cells are lenient in opposite directions: subscribed unless the cell reads
|
|
19
|
+
// 'false', comped only when it reads 'true'. An absent column stays undefined (optional,
|
|
20
|
+
// not defaulted), so a CSV lacking these columns leaves the member's state untouched.
|
|
21
|
+
const isSubscribed = (cell: string): boolean => cell.toLowerCase() !== 'false';
|
|
22
|
+
const isComplimentary = (cell: string): boolean => cell.toLowerCase() === 'true';
|
|
23
|
+
|
|
24
|
+
// The member import vocabulary as a schema: the single source of truth that coerces the
|
|
25
|
+
// raw string Row into typed member fields. Unknown columns (custom_fields.*, and anything
|
|
26
|
+
// else a source carries) pass through via .loose() so nothing is lost before the kernel.
|
|
27
|
+
export const memberImportRowSchema = z.object({
|
|
28
|
+
id: optionalCell,
|
|
29
|
+
email: optionalCell,
|
|
30
|
+
name: optionalCell,
|
|
31
|
+
note: optionalCell,
|
|
32
|
+
subscribed: z.string().transform(isSubscribed).optional(),
|
|
33
|
+
complimentary_plan: z.string().transform(isComplimentary).optional(),
|
|
34
|
+
stripe_customer_id: optionalCell,
|
|
35
|
+
created_at: optionalCell,
|
|
36
|
+
import_tier: optionalCell,
|
|
37
|
+
gift_id: optionalCell,
|
|
38
|
+
labels: z.string().default('').transform(splitLabels)
|
|
39
|
+
}).loose();
|
|
40
|
+
|
|
41
|
+
export type MemberImportRow = z.infer<typeof memberImportRowSchema>;
|
|
42
|
+
|
|
43
|
+
// A row that failed to import, carrying the raw message that stopped it. The message is
|
|
44
|
+
// left raw here; turning it into human copy is a presentation concern.
|
|
45
|
+
export type ImportErrorRow = MemberImportRow & {error: string};
|
|
46
|
+
|
|
47
|
+
// The persisted import label as plain data -- taken off the Bookshelf model before it
|
|
48
|
+
// leaves the kernel, so the result and the email carry only what they show.
|
|
49
|
+
export interface ImportLabel {
|
|
50
|
+
name: string;
|
|
51
|
+
slug: string;
|
|
52
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
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.createRowSpool = createRowSpool;
|
|
7
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
8
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
9
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
10
|
+
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
11
|
+
// Spools import rows to a private JSON file under the OS temp dir, so a deferred
|
|
12
|
+
// import can hand them to a background job and read them back after the request has
|
|
13
|
+
// already returned. The rows go in and come out as MemberImportRow, so nothing but
|
|
14
|
+
// the import's own row shape crosses this boundary.
|
|
15
|
+
function createRowSpool() {
|
|
16
|
+
return {
|
|
17
|
+
async write(rows) {
|
|
18
|
+
const spoolPath = node_path_1.default.join(node_os_1.default.tmpdir(), `members-import-${node_crypto_1.default.randomUUID()}.json`);
|
|
19
|
+
await fs_extra_1.default.writeFile(spoolPath, JSON.stringify(rows), { mode: 0o600 });
|
|
20
|
+
return {
|
|
21
|
+
async read() {
|
|
22
|
+
return JSON.parse(await fs_extra_1.default.readFile(spoolPath, 'utf8'));
|
|
23
|
+
},
|
|
24
|
+
async remove() {
|
|
25
|
+
await fs_extra_1.default.remove(spoolPath).catch(() => { });
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import fs from 'fs-extra';
|
|
5
|
+
import type {MemberImportRow} from './row';
|
|
6
|
+
|
|
7
|
+
// A handle to rows written to a spool: the deferred job reads them back once it
|
|
8
|
+
// runs, then removes the file. Removal swallows errors -- a failed cleanup must
|
|
9
|
+
// never fail the import it was cleaning up after.
|
|
10
|
+
export interface SpooledRows {
|
|
11
|
+
read(): Promise<MemberImportRow[]>;
|
|
12
|
+
remove(): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface RowSpool {
|
|
16
|
+
write(rows: MemberImportRow[]): Promise<SpooledRows>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Spools import rows to a private JSON file under the OS temp dir, so a deferred
|
|
20
|
+
// import can hand them to a background job and read them back after the request has
|
|
21
|
+
// already returned. The rows go in and come out as MemberImportRow, so nothing but
|
|
22
|
+
// the import's own row shape crosses this boundary.
|
|
23
|
+
export function createRowSpool(): RowSpool {
|
|
24
|
+
return {
|
|
25
|
+
async write(rows) {
|
|
26
|
+
const spoolPath = path.join(os.tmpdir(), `members-import-${crypto.randomUUID()}.json`);
|
|
27
|
+
await fs.writeFile(spoolPath, JSON.stringify(rows), {mode: 0o600});
|
|
28
|
+
return {
|
|
29
|
+
async read() {
|
|
30
|
+
return JSON.parse(await fs.readFile(spoolPath, 'utf8'));
|
|
31
|
+
},
|
|
32
|
+
async remove() {
|
|
33
|
+
await fs.remove(spoolPath).catch(() => {});
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|