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.
Files changed (238) hide show
  1. package/components/ghost-storage-base-3.0.0.tgz +0 -0
  2. package/components/tryghost-adapter-base-cache-0.2.0.tgz +0 -0
  3. package/components/tryghost-adapter-base-scheduling-0.2.0.tgz +0 -0
  4. package/components/tryghost-adapter-base-sso-0.1.1.tgz +0 -0
  5. package/components/tryghost-kg-default-nodes-2.1.3.tgz +0 -0
  6. package/core/built/admin/assets/Growth-BTP1LthE.js +3 -0
  7. package/core/built/admin/assets/Newsletters-CSEX0myc.js +1 -0
  8. package/core/built/admin/assets/Overview-BjACEsJF.js +1 -0
  9. package/core/built/admin/assets/Web-CxxxjCcv.js +2 -0
  10. package/core/built/admin/assets/{account-migration-DLvVA5yq.js → account-migration-BklF2I_I.js} +1 -1
  11. package/core/built/admin/assets/activitypub/{account-migration-DHbZcxGM.js → account-migration-CNovr6W-.js} +4 -4
  12. package/core/built/admin/assets/activitypub/activitypub.js +2 -2
  13. package/core/built/admin/assets/activitypub/{bluesky-sharing-zfxUey_L.js → bluesky-sharing-Ceslb1_M.js} +4 -4
  14. package/core/built/admin/assets/activitypub/{dist-BqkDUQxJ2.js → dist-ByFmDSNP2.js} +64 -63
  15. package/core/built/admin/assets/activitypub/{dist-Ba_QxHHl.js → dist-DHAjBcV3.js} +2 -2
  16. package/core/built/admin/assets/activitypub/{domain-CmwSZLHK.js → domain-C1qQvJ8C.js} +5 -5
  17. package/core/built/admin/assets/activitypub/{edit-profile-CXdwNmVG.js → edit-profile-BcH-jEfM.js} +3 -3
  18. package/core/built/admin/assets/activitypub/{explore-DfbjzGM1.js → explore-AxwBh9dI.js} +3 -3
  19. package/core/built/admin/assets/activitypub/{feed-DSEBJOqf.js → feed-BaOlbh7a.js} +3 -3
  20. package/core/built/admin/assets/activitypub/{field-CY7XVcx6.js → field-B7mYYQpN.js} +2 -2
  21. package/core/built/admin/assets/activitypub/{inbox-DtQ0kytT.js → inbox-vXez-5uJ.js} +4 -4
  22. package/core/built/admin/assets/activitypub/{label-Dw96GQ7u.js → label-Chv5T8bw.js} +2 -2
  23. package/core/built/admin/assets/activitypub/layout-CziktJXo.js +1 -0
  24. package/core/built/admin/assets/activitypub/{moderation-Dg_-I-8C.js → moderation-DtSM2i1I.js} +4 -4
  25. package/core/built/admin/assets/activitypub/{note-Cy1-uKMY.js → note-Dckp4S-2.js} +3 -3
  26. package/core/built/admin/assets/activitypub/{notifications-B_fvoLIM.js → notifications-CG5T22vg.js} +3 -3
  27. package/core/built/admin/assets/activitypub/{preferences-ByJseZp6.js → preferences-D_799jdl.js} +6 -6
  28. package/core/built/admin/assets/activitypub/{profile-BFtbnIjz.js → profile-D2JfXBQl.js} +6 -6
  29. package/core/built/admin/assets/activitypub/{routes-Dh3xXfce.js → routes-CCHe2dQp.js} +28 -25
  30. package/core/built/admin/assets/activitypub/{settings-DTgqJPxM.js → settings-kx-9Enkz.js} +3 -3
  31. package/core/built/admin/assets/activitypub/{site-CdL9ZLqm.js → site-x4_IjVJ0.js} +2 -2
  32. package/core/built/admin/assets/activitypub/{tabs-ogqwoBlb.js → tabs-CyPRQQ1N.js} +3 -3
  33. package/core/built/admin/assets/activitypub/{use-reply-chain-data-CxonteFD.js → use-reply-chain-data-BAB6fNGt.js} +2 -2
  34. package/core/built/admin/assets/{audience-BKRdsFmy.js → audience-X_XsZivI.js} +1 -1
  35. package/core/built/admin/assets/{automated-emails-Cn87L5Md.js → automated-emails-of1MvVsv.js} +1 -1
  36. package/core/built/admin/assets/{automations-BP_aHamJ.js → automations-DRFjW5fw.js} +1 -1
  37. package/core/built/admin/assets/{bluesky-sharing-Bh36XPh4.js → bluesky-sharing-BbbjWdzs.js} +1 -1
  38. package/core/built/admin/assets/{breadcrumb-D34YWnaw.js → breadcrumb-B-M9Ip_2.js} +1 -1
  39. package/core/built/admin/assets/{chart-helpers-DbklaJw9.js → chart-helpers-xiSsW8r0.js} +1 -1
  40. package/core/built/admin/assets/checkbox-EhJKHFdZ.js +1 -0
  41. package/core/built/admin/assets/{chunk.962.b73442b07be930bb5d85.js → chunk.211.6f7328d63c41536451b3.js} +737 -559
  42. package/core/built/admin/assets/{chunk.962.b73442b07be930bb5d85.js.LICENSE.txt → chunk.211.6f7328d63c41536451b3.js.LICENSE.txt} +2 -0
  43. package/core/built/admin/assets/{chunk.589.096f4d0bb775e5229aa9.js → chunk.361.3cdb584b973959e52ccc.js} +3 -3
  44. package/core/built/admin/assets/chunk.524.8365cd9280405c0f6216.js +25 -0
  45. package/core/built/admin/assets/chunk.582.61d078937e6102769f2c.js +27 -0
  46. package/core/built/admin/assets/code-editor-view-CZtJFGhR.js +1 -0
  47. package/core/built/admin/assets/comments-CvyXxOyN.js +1 -0
  48. package/core/built/admin/assets/{date-range-select-B5vSsrpx.js → date-range-select-CTlyo8zW.js} +1 -1
  49. package/core/built/admin/assets/{disabled-sources-indicator-NkOIoMue.js → disabled-sources-indicator-qs2GfLvl.js} +1 -1
  50. package/core/built/admin/assets/dist-2Tnz2Yfq.js +1 -0
  51. package/core/built/admin/assets/dist-CjY-f5Z1.js +1 -0
  52. package/core/built/admin/assets/dist-DIC8yz5C.js +1 -0
  53. package/core/built/admin/assets/{domain-B7_n5nfx.js → domain-CPNXKVOV.js} +1 -1
  54. package/core/built/admin/assets/dropzone-BmCXnw6J.js +5 -0
  55. package/core/built/admin/assets/{edit-profile-rvDGrS8J.js → edit-profile-SGrJve5M.js} +1 -1
  56. package/core/built/admin/assets/editor-BOG2YPUm.js +8 -0
  57. package/core/built/admin/assets/{explore-BZAZOg5X.js → explore-CUYLv9G9.js} +1 -1
  58. package/core/built/admin/assets/{feed-CFV8xDw9.js → feed-CPdJMttL.js} +1 -1
  59. package/core/built/admin/assets/{field-BW-M2Zwp.js → field-D0XISCK2.js} +1 -1
  60. package/core/built/admin/assets/{filter-sources-Fx8wiYYw.js → filter-sources-Bieps75g.js} +1 -1
  61. package/core/built/admin/assets/{filters-CGX_pf2N.js → filters-CuB-A7ma.js} +1 -1
  62. package/core/built/admin/assets/filters-bXkkWro8.js +1 -0
  63. package/core/built/admin/assets/{ghost-b1dce91ad3232cc259ee5fbbf9526243.js → ghost-05f9a7182ed56903dba05878a5b8422b.js} +290 -289
  64. package/core/built/admin/assets/{gift-link-modal-Bk8qpnMw.js → gift-link-modal-su8MsRdg.js} +1 -1
  65. package/core/built/admin/assets/{growth-Ck2s3JjR.js → growth-Ba6S5FQX.js} +1 -1
  66. package/core/built/admin/assets/{inbox-BRp1_aaC.js → inbox-Xx1CdnfO.js} +8 -8
  67. package/core/built/admin/assets/index-D8PbzuT9.css +1 -0
  68. package/core/built/admin/assets/index-ukW_0Uie.js +67 -0
  69. package/core/built/admin/assets/input-group-CGOXcy4l.js +1 -0
  70. package/core/built/admin/assets/input-surface-BpAjZXY5.js +1 -0
  71. package/core/built/admin/assets/{koenig-lexical-CM7UFNdL.js → koenig-lexical-B61IALf5.js} +1 -1
  72. package/core/built/admin/assets/{kpi-card-CIGyijjx.js → kpi-card-BlZaG1Lo.js} +1 -1
  73. package/core/built/admin/assets/{kpi-card-BrGH6FsT.js → kpi-card-DCdlyaSS.js} +1 -1
  74. package/core/built/admin/assets/{kpi-helpers-_HRRsklE.js → kpi-helpers-DpAuS1Nb.js} +1 -1
  75. package/core/built/admin/assets/{kpi-tabs-BSvh9eoq.js → kpi-tabs-Dme44bQS.js} +1 -1
  76. package/core/built/admin/assets/{label-BOR9XdKl.js → label-dSs5aepE.js} +1 -1
  77. package/core/built/admin/assets/{label-picker-BdNAWuUX.js → label-picker-Uq91atex.js} +1 -1
  78. package/core/built/admin/assets/layout-WUcz6tu1.js +1 -0
  79. package/core/built/admin/assets/{member-custom-fields-DvFyamLu.js → member-custom-fields-NCzrMuba.js} +1 -1
  80. package/core/built/admin/assets/member-detail-DPWDtYnN.js +1 -0
  81. package/core/built/admin/assets/{members-DsPxYAVm.js → members-B9rO7qr4.js} +2 -2
  82. package/core/built/admin/assets/modals-CxRaan1J.js +31 -0
  83. package/core/built/admin/assets/{moderation-Dlp7M5sa.js → moderation-D8zon60E.js} +1 -1
  84. package/core/built/admin/assets/{multi-select-combobox-CH1o8XXQ.js → multi-select-combobox-1wYm3nle.js} +1 -1
  85. package/core/built/admin/assets/newsletter-DbYMRxRS.js +1 -0
  86. package/core/built/admin/assets/{note-BrAZ7Z0o.js → note-PiWPnnrh.js} +1 -1
  87. package/core/built/admin/assets/{notifications-BnbHLOPC.js → notifications-DEgqqB9G.js} +1 -1
  88. package/core/built/admin/assets/{onboarding-route-eU146kuA.js → onboarding-route-BzF6HeVn.js} +1 -1
  89. package/core/built/admin/assets/{overview-BpbaK-xu.js → overview-DixLeIqY.js} +1 -1
  90. package/core/built/admin/assets/{page-header-C0jVxtS8.js → page-header-op0eN36I.js} +1 -1
  91. package/core/built/admin/assets/{post-analytics-Iw7dk1mT.js → post-analytics-WwRDEnrV.js} +1 -1
  92. package/core/built/admin/assets/post-analytics-header-DsMoLx0R.js +1 -0
  93. package/core/built/admin/assets/{post-analytics-provider-BDqcfBor.js → post-analytics-provider-BJCPLMIb.js} +1 -1
  94. package/core/built/admin/assets/{post-share-modal-DBfKl2GZ.js → post-share-modal-Dx-MEjhI.js} +1 -1
  95. package/core/built/admin/assets/preferences-D7-j6A4C.js +5 -0
  96. package/core/built/admin/assets/profile-Dl69QPp0.js +1 -0
  97. package/core/built/admin/assets/select-C1qDZnW4.js +1 -0
  98. package/core/built/admin/assets/settings-BbmC_Iqq.js +4 -0
  99. package/core/built/admin/assets/{settings-Bz3RZRmE.js → settings-H6HxFgfI.js} +1 -1
  100. package/core/built/admin/assets/shade-app-B3heOAfz-CGXQwWeO.js +3 -0
  101. package/core/built/admin/assets/{share-modal-CkdTTMJd.js → share-modal-BJCadJyp.js} +1 -1
  102. package/core/built/admin/assets/social-urls-BPL1nADp.js +8 -0
  103. package/core/built/admin/assets/{sort-button-DZp9Y2r1.js → sort-button-DyYlfYp7.js} +1 -1
  104. package/core/built/admin/assets/{source-icon-CHr9qJ2Y.js → source-icon-_ubRDbg_.js} +1 -1
  105. package/core/built/admin/assets/{stats-DGoXZxWE.js → stats-BQZMGMvn.js} +1 -1
  106. package/core/built/admin/assets/{stats-view-D2-1L0ms.js → stats-view-Doei-qCX.js} +1 -1
  107. package/core/built/admin/assets/{step-1-RdahbwyG.js → step-1-IKiLBPoC.js} +1 -1
  108. package/core/built/admin/assets/{step-2-D83PeRwd.js → step-2-jJ41PsDN.js} +1 -1
  109. package/core/built/admin/assets/{step-3-p_Op4wv4.js → step-3-CaqFAEqf.js} +2 -2
  110. package/core/built/admin/assets/switch-t-WyENU2.js +1 -0
  111. package/core/built/admin/assets/{table-B8LgTQJZ.js → table-DxUL78jU.js} +1 -1
  112. package/core/built/admin/assets/{tabs-D3ZOYkM7.js → tabs-BQcJQ1ke.js} +1 -1
  113. package/core/built/admin/assets/{tags-B4l891z2.js → tags-CNujGwkT.js} +1 -1
  114. package/core/built/admin/assets/textarea-Bjccvh_E.js +1 -0
  115. package/core/built/admin/assets/{toggle-group-DNXDkAwf.js → toggle-group-BeEpQ69p.js} +1 -1
  116. package/core/built/admin/assets/{topic-filter-DZGjEcz3.js → topic-filter-Bb5MOD-1.js} +1 -1
  117. package/core/built/admin/assets/{use-analytics-data-CaT8RirX.js → use-analytics-data-Cds6XDjg.js} +1 -1
  118. package/core/built/admin/assets/use-filter-params-DjXkhO54.js +1 -0
  119. package/core/built/admin/assets/{use-growth-stats-9Db2CuJe.js → use-growth-stats-D8uf98-G.js} +1 -1
  120. package/core/built/admin/assets/{use-post-referrers-BV54YcQS.js → use-post-referrers-CRtgyuXB.js} +1 -1
  121. package/core/built/admin/assets/{use-reply-chain-data-DuYZGDan.js → use-reply-chain-data-DbKYX50v.js} +1 -1
  122. package/core/built/admin/assets/{use-simple-pagination-8fTua9Nt.js → use-simple-pagination-DC01kpo0.js} +1 -1
  123. package/core/built/admin/assets/{use-tinybird-query-Dz156HvZ.js → use-tinybird-query-C7bQRJWw.js} +1 -1
  124. package/core/built/admin/assets/{use-tinybird-token-C9RUdL_n.js → use-tinybird-token-Az4tam7_.js} +1 -1
  125. package/core/built/admin/assets/{virtual-list-BCvP0YF-.js → virtual-list-Dhuh9fGx.js} +1 -1
  126. package/core/built/admin/assets/web-LtHNiNBV.js +1 -0
  127. package/core/built/admin/index.html +11 -7
  128. package/core/frontend/services/proxy.js +11 -1
  129. package/core/server/adapters/storage/LocalFilesStorage.js +13 -11
  130. package/core/server/adapters/storage/LocalFilesStorage.ts +24 -0
  131. package/core/server/adapters/storage/LocalImagesStorage.js +14 -12
  132. package/core/server/adapters/storage/LocalImagesStorage.ts +24 -0
  133. package/core/server/adapters/storage/LocalMediaStorage.js +13 -11
  134. package/core/server/adapters/storage/LocalMediaStorage.ts +24 -0
  135. package/core/server/adapters/storage/LocalStorageBase.js +107 -167
  136. package/core/server/adapters/storage/LocalStorageBase.ts +342 -0
  137. package/core/server/adapters/storage/utils.js +36 -50
  138. package/core/server/adapters/storage/utils.ts +60 -0
  139. package/core/server/api/endpoints/db.js +6 -0
  140. package/core/server/api/endpoints/members.js +25 -28
  141. package/core/server/api/endpoints/pages.js +6 -1
  142. package/core/server/api/endpoints/posts.js +11 -2
  143. package/core/server/api/endpoints/utils/serializers/output/mappers/comments.js +4 -1
  144. package/core/server/api/endpoints/utils/serializers/output/members.js +18 -87
  145. package/core/server/data/exporter/export-filename.js +2 -1
  146. package/core/server/data/importer/importers/data/revue-subscriber-importer.js +6 -10
  147. package/core/server/data/importer/importers/json-to-html.js +24 -4
  148. package/core/server/lib/image/image-size.js +3 -17
  149. package/core/server/lib/image/image-utils.js +15 -1
  150. package/core/server/lib/request-external.js +1 -1
  151. package/core/server/models/user.js +46 -70
  152. package/core/server/services/auth/session/session-service.js +28 -1
  153. package/core/server/services/automations/automations-api.js +6 -6
  154. package/core/server/services/automations/automations-api.ts +6 -6
  155. package/core/server/services/automations/automations-repository.ts +1 -0
  156. package/core/server/services/automations/database-automations-repository.js +7 -2
  157. package/core/server/services/automations/database-automations-repository.ts +8 -2
  158. package/core/server/services/comments/comments-service-emails.js +4 -1
  159. package/core/server/services/members/{importer → import-export}/config.js +4 -9
  160. package/core/server/services/members/{importer → import-export}/config.ts +4 -9
  161. package/core/server/services/members/{csv → import-export/csv}/index.js +3 -3
  162. package/core/server/services/members/import-export/csv/index.ts +2 -0
  163. package/core/server/services/members/import-export/csv/parse.js +64 -0
  164. package/core/server/services/members/import-export/csv/parse.ts +69 -0
  165. package/core/server/services/members/import-export/csv/serialize.js +13 -0
  166. package/core/server/services/members/import-export/csv/serialize.ts +18 -0
  167. package/core/server/services/members/import-export/export/exporter.js +202 -0
  168. package/core/server/services/members/import-export/export/exporter.ts +325 -0
  169. package/core/server/services/members/import-export/import/completion-email.js +65 -0
  170. package/core/server/services/members/import-export/import/completion-email.ts +119 -0
  171. package/core/server/services/members/import-export/import/importer.js +276 -0
  172. package/core/server/services/members/import-export/import/importer.ts +430 -0
  173. package/core/server/services/members/import-export/import/reader.js +32 -0
  174. package/core/server/services/members/import-export/import/reader.ts +33 -0
  175. package/core/server/services/members/import-export/import/row.js +33 -0
  176. package/core/server/services/members/import-export/import/row.ts +52 -0
  177. package/core/server/services/members/import-export/import/spool.js +30 -0
  178. package/core/server/services/members/import-export/import/spool.ts +38 -0
  179. package/core/server/services/members/import-export/index.js +90 -0
  180. package/core/server/services/members/import-export/index.ts +118 -0
  181. package/core/server/services/members/members-api/controllers/router-controller.js +7 -0
  182. package/core/server/services/members/service.js +17 -19
  183. package/core/server/services/posts/posts-exporter.js +6 -4
  184. package/core/server/services/posts/posts-service.js +2 -2
  185. package/core/server/services/themes/storage.js +9 -0
  186. package/core/server/services/themes/theme-storage.js +1 -1
  187. package/core/shared/config/defaults.json +2 -1
  188. package/core/shared/express.js +6 -1
  189. package/core/shared/labs.js +2 -1
  190. package/package.json +9 -9
  191. package/pnpm-lock.yaml +17 -17
  192. package/pnpm-workspace.yaml +7 -7
  193. package/components/tryghost-adapter-base-scheduling-0.1.1.tgz +0 -0
  194. package/core/built/admin/assets/Growth-CSaHoSVD.js +0 -3
  195. package/core/built/admin/assets/Newsletters-C5cs6FGo.js +0 -1
  196. package/core/built/admin/assets/Overview-j4NaeUNm.js +0 -1
  197. package/core/built/admin/assets/Web-_fD935OX.js +0 -2
  198. package/core/built/admin/assets/activitypub/layout-IdVq3lRy.js +0 -1
  199. package/core/built/admin/assets/checkbox-DA4-1UiI.js +0 -1
  200. package/core/built/admin/assets/chunk.524.45e0875f41b71e46e849.js +0 -25
  201. package/core/built/admin/assets/chunk.582.51defd4b009f0de8d4d5.js +0 -27
  202. package/core/built/admin/assets/code-editor-view-BKBrkffZ.js +0 -1
  203. package/core/built/admin/assets/comments-CzVNIT8b.js +0 -1
  204. package/core/built/admin/assets/dist-CT46_f_J.js +0 -1
  205. package/core/built/admin/assets/dropzone-DcuJEEUf.js +0 -5
  206. package/core/built/admin/assets/editor-BCiIyFjm.js +0 -8
  207. package/core/built/admin/assets/filters-DtcXMZDU.js +0 -1
  208. package/core/built/admin/assets/index-BBf502Wg.js +0 -69
  209. package/core/built/admin/assets/index-BCCZKxP2.css +0 -1
  210. package/core/built/admin/assets/input-group-DjuYnNCd.js +0 -1
  211. package/core/built/admin/assets/layout-Bw4GnxXp.js +0 -1
  212. package/core/built/admin/assets/member-detail-Cg2vTqvJ.js +0 -1
  213. package/core/built/admin/assets/modals-Bf_fyvHZ.js +0 -31
  214. package/core/built/admin/assets/newsletter-CQ5CAYUI.js +0 -1
  215. package/core/built/admin/assets/post-analytics-header-D9pJ4iNm.js +0 -1
  216. package/core/built/admin/assets/preferences-D4EANGoc.js +0 -5
  217. package/core/built/admin/assets/profile-dJ44Bn_W.js +0 -1
  218. package/core/built/admin/assets/select--2uVN9_-.js +0 -1
  219. package/core/built/admin/assets/settings-SD9bxIpt.js +0 -4
  220. package/core/built/admin/assets/social-urls-BvFZwYcE.js +0 -8
  221. package/core/built/admin/assets/switch-qp518gFQ.js +0 -1
  222. package/core/built/admin/assets/textarea-CTlb3Uu6.js +0 -1
  223. package/core/built/admin/assets/use-filter-params-DeQ8sX4N.js +0 -1
  224. package/core/built/admin/assets/web-DTZnnUGr.js +0 -1
  225. package/core/server/services/mail/templates/newsletter.html +0 -876
  226. package/core/server/services/members/csv/index.ts +0 -3
  227. package/core/server/services/members/csv/parse.js +0 -94
  228. package/core/server/services/members/csv/parse.ts +0 -111
  229. package/core/server/services/members/csv/types.js +0 -2
  230. package/core/server/services/members/csv/types.ts +0 -39
  231. package/core/server/services/members/csv/unparse.js +0 -69
  232. package/core/server/services/members/csv/unparse.ts +0 -79
  233. package/core/server/services/members/exporter/query.js +0 -233
  234. package/core/server/services/members/importer/index.js +0 -30
  235. package/core/server/services/members/importer/labels.js +0 -125
  236. package/core/server/services/members/importer/members-csv-importer.js +0 -520
  237. /package/core/server/services/members/{importer → import-export/import}/email-template.js +0 -0
  238. /package/core/server/services/members/{importer/members-csv-importer-stripe-utils.js → import-export/import/stripe-utils.js} +0 -0
@@ -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,65 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.default = buildCompletionEmail;
4
+ const csv_1 = require("../csv");
5
+ const emailTemplate = require('./email-template');
6
+ // Turn an ORM validation error into copy a member manager can act on. Presentation
7
+ // only: the API response keeps the raw messages, and just the emailed report is
8
+ // rewritten.
9
+ function humaniseError(message) {
10
+ return message
11
+ .replace('Value in [members.email] cannot be blank.', 'Missing email address')
12
+ .replace('Value in [members.note] exceeds maximum length of 2000 characters.', '"Note" exceeds maximum length of 2000 characters')
13
+ .replace('Value in [members.subscribed] must be one of true, false, 0 or 1.', 'Value in "Subscribed to emails" must be "true" or "false"')
14
+ .replace('Validation (isEmail) failed for email', 'Invalid email address')
15
+ .replace(/No such customer:[^,]*/, 'Could not find Stripe customer');
16
+ }
17
+ function stringifyLabels(labels) {
18
+ return labels.map(label => (typeof label === 'string' ? label : label.name)).join(',');
19
+ }
20
+ function toErrorReportRow(row) {
21
+ return {
22
+ id: row.id,
23
+ email: row.email,
24
+ name: row.name,
25
+ note: row.note,
26
+ subscribed_to_emails: row.subscribed,
27
+ complimentary_plan: row.complimentary_plan,
28
+ stripe_customer_id: row.stripe_customer_id,
29
+ created_at: row.created_at,
30
+ deleted_at: undefined,
31
+ labels: stringifyLabels(row.labels),
32
+ tiers: '',
33
+ gift_id: row.gift_id || null,
34
+ error: humaniseError(row.error)
35
+ };
36
+ }
37
+ // The error report attached to the completion email: the failed rows as CSV. No column
38
+ // list is passed -- the typed ErrorReportRow defines the columns.
39
+ function buildErrorReport(errors) {
40
+ return (0, csv_1.serialize)(errors.map(toErrorReportRow));
41
+ }
42
+ // Compose the completion email for a finished import: the summary and its links,
43
+ // plus the attached error report. Owns how the outcome is presented, so the
44
+ // importer yields only the result and never touches email or CSV formatting.
45
+ function buildCompletionEmail({ result, recipient, labelName, importLabel, urlFor }) {
46
+ const siteUrl = new URL(urlFor('home', null, true));
47
+ const membersUrl = new URL('members', urlFor('admin', null, true));
48
+ if (importLabel) {
49
+ membersUrl.searchParams.set('label', importLabel.slug);
50
+ }
51
+ const html = emailTemplate({ result, siteUrl, membersUrl, emailRecipient: recipient, importLabel });
52
+ const subject = result.imported > 0 ? 'Your member import is complete' : 'Your member import was unsuccessful';
53
+ return {
54
+ to: recipient,
55
+ subject,
56
+ html,
57
+ forceTextContent: true,
58
+ attachments: [{
59
+ filename: `${labelName} - Errors.csv`,
60
+ content: buildErrorReport(result.errors),
61
+ contentType: 'text/csv',
62
+ contentDisposition: 'attachment'
63
+ }]
64
+ };
65
+ }
@@ -0,0 +1,119 @@
1
+ import {serialize} from '../csv';
2
+ import type {MemberImportRow, ImportErrorRow, ImportLabel, Label} from './row';
3
+
4
+ const emailTemplate = require('./email-template');
5
+
6
+ // The finished import as the email reads it: how many imported and which rows
7
+ // failed. Structural, so the importer's richer result satisfies it directly.
8
+ interface ImportSummary {
9
+ imported: number;
10
+ errors: ImportErrorRow[];
11
+ }
12
+
13
+ interface CompletionEmailInput {
14
+ result: ImportSummary;
15
+ recipient: string;
16
+ labelName: string;
17
+ importLabel: ImportLabel | null;
18
+ urlFor: (type: string, data: unknown, absolute: boolean) => string;
19
+ }
20
+
21
+ interface EmailPayload {
22
+ to: string;
23
+ subject: string;
24
+ html: string;
25
+ forceTextContent: boolean;
26
+ attachments: Array<{
27
+ filename: string;
28
+ content: string;
29
+ contentType: string;
30
+ contentDisposition: string;
31
+ }>;
32
+ }
33
+
34
+ // Turn an ORM validation error into copy a member manager can act on. Presentation
35
+ // only: the API response keeps the raw messages, and just the emailed report is
36
+ // rewritten.
37
+ function humaniseError(message: string): string {
38
+ return message
39
+ .replace('Value in [members.email] cannot be blank.', 'Missing email address')
40
+ .replace('Value in [members.note] exceeds maximum length of 2000 characters.', '"Note" exceeds maximum length of 2000 characters')
41
+ .replace('Value in [members.subscribed] must be one of true, false, 0 or 1.', 'Value in "Subscribed to emails" must be "true" or "false"')
42
+ .replace('Validation (isEmail) failed for email', 'Invalid email address')
43
+ .replace(/No such customer:[^,]*/, 'Could not find Stripe customer');
44
+ }
45
+
46
+ // One row of the attached error report, in emit order. papaparse takes the columns from
47
+ // these keys, so the report cannot drift from the shaper -- a new column must be added
48
+ // here or the shaper stops compiling. tiers and deleted_at are export-vocabulary columns
49
+ // an import never fills; kept always-empty so the report matches the members export CSV.
50
+ type ErrorReportRow = {
51
+ id: MemberImportRow['id'];
52
+ email: MemberImportRow['email'];
53
+ name: MemberImportRow['name'];
54
+ note: MemberImportRow['note'];
55
+ subscribed_to_emails: MemberImportRow['subscribed'];
56
+ complimentary_plan: MemberImportRow['complimentary_plan'];
57
+ stripe_customer_id: MemberImportRow['stripe_customer_id'];
58
+ created_at: MemberImportRow['created_at'];
59
+ deleted_at: undefined;
60
+ labels: string;
61
+ tiers: '';
62
+ gift_id: string | null;
63
+ error: string;
64
+ };
65
+
66
+ function stringifyLabels(labels: Array<string | Label>): string {
67
+ return labels.map(label => (typeof label === 'string' ? label : label.name)).join(',');
68
+ }
69
+
70
+ function toErrorReportRow(row: ImportErrorRow): ErrorReportRow {
71
+ return {
72
+ id: row.id,
73
+ email: row.email,
74
+ name: row.name,
75
+ note: row.note,
76
+ subscribed_to_emails: row.subscribed,
77
+ complimentary_plan: row.complimentary_plan,
78
+ stripe_customer_id: row.stripe_customer_id,
79
+ created_at: row.created_at,
80
+ deleted_at: undefined,
81
+ labels: stringifyLabels(row.labels),
82
+ tiers: '',
83
+ gift_id: row.gift_id || null,
84
+ error: humaniseError(row.error)
85
+ };
86
+ }
87
+
88
+ // The error report attached to the completion email: the failed rows as CSV. No column
89
+ // list is passed -- the typed ErrorReportRow defines the columns.
90
+ function buildErrorReport(errors: ImportErrorRow[]): string {
91
+ return serialize(errors.map(toErrorReportRow));
92
+ }
93
+
94
+ // Compose the completion email for a finished import: the summary and its links,
95
+ // plus the attached error report. Owns how the outcome is presented, so the
96
+ // importer yields only the result and never touches email or CSV formatting.
97
+ export default function buildCompletionEmail({result, recipient, labelName, importLabel, urlFor}: CompletionEmailInput): EmailPayload {
98
+ const siteUrl = new URL(urlFor('home', null, true));
99
+ const membersUrl = new URL('members', urlFor('admin', null, true));
100
+ if (importLabel) {
101
+ membersUrl.searchParams.set('label', importLabel.slug);
102
+ }
103
+
104
+ const html = emailTemplate({result, siteUrl, membersUrl, emailRecipient: recipient, importLabel});
105
+ const subject = result.imported > 0 ? 'Your member import is complete' : 'Your member import was unsuccessful';
106
+
107
+ return {
108
+ to: recipient,
109
+ subject,
110
+ html,
111
+ forceTextContent: true,
112
+ attachments: [{
113
+ filename: `${labelName} - Errors.csv`,
114
+ content: buildErrorReport(result.errors),
115
+ contentType: 'text/csv',
116
+ contentDisposition: 'attachment'
117
+ }]
118
+ };
119
+ }