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
@@ -1,125 +0,0 @@
1
- const _ = require('lodash');
2
- const moment = require('moment-timezone');
3
- const models = require('../../../models');
4
-
5
- const findOrCreateLabels = async (labels, options) => {
6
- const existingLabels = [];
7
- const createdLabels = [];
8
-
9
- for (const label of labels) {
10
- const existingLabel = await models.Label.findOne({name: label.name});
11
-
12
- if (existingLabel) {
13
- existingLabels.push(existingLabel.toJSON());
14
- } else {
15
- try {
16
- const createdLabel = await models.Label.add(label, options);
17
- createdLabels.push(createdLabel.toJSON());
18
- } catch (error) {
19
- if (error.code && error.message.toLowerCase().indexOf('unique') !== -1) {
20
- // ignore if label already exists
21
- } else {
22
- throw error;
23
- }
24
- }
25
- }
26
- }
27
-
28
- return {existingLabels, createdLabels};
29
- };
30
-
31
- const getUniqueMemberLabels = (members) => {
32
- const allLabels = [];
33
-
34
- members.forEach((member) => {
35
- const labels = (member.labels && member.labels.split(',')) || [];
36
-
37
- if (labels.length) {
38
- allLabels.push(...labels);
39
- }
40
- });
41
-
42
- return _.uniq(allLabels);
43
- };
44
-
45
- function serializeMemberLabels(labels) {
46
- if (_.isString(labels)) {
47
- if (labels === '') {
48
- return [];
49
- }
50
-
51
- return [{
52
- name: labels.trim()
53
- }];
54
- } else if (labels) {
55
- return labels.filter((label) => {
56
- return !!label;
57
- }).map((label) => {
58
- if (_.isString(label)) {
59
- return {
60
- name: label.trim()
61
- };
62
- }
63
- return label;
64
- });
65
- }
66
- return [];
67
- }
68
-
69
- const handleImportSetLabels = async (labels, siteTimezone, options) => {
70
- const importSetLabels = serializeMemberLabels(labels);
71
- let importLabel;
72
-
73
- const {existingLabels, createdLabels} = await findOrCreateLabels(importSetLabels, options);
74
-
75
- // NOTE: an import label allows for imports to be "undone" via bulk delete
76
- if (createdLabels.length) {
77
- importLabel = createdLabels[0] && createdLabels[0];
78
- } else {
79
- const name = `Import ${moment().tz(siteTimezone).format('YYYY-MM-DD HH:mm')}`;
80
- const result = await findOrCreateLabels([{name}], options);
81
-
82
- const generatedLabel = result.createdLabels.length
83
- ? result.createdLabels[0]
84
- : result.existingLabels[0];
85
- importLabel = generatedLabel;
86
- importLabel.generated = true;
87
-
88
- createdLabels.push(generatedLabel);
89
- }
90
-
91
- return {
92
- importSetLabels: [...existingLabels, ...createdLabels],
93
- importLabel
94
- };
95
- };
96
-
97
- const handleMemberLabels = async (members, options) => {
98
- const memberLabels = serializeMemberLabels(getUniqueMemberLabels(members));
99
-
100
- return await findOrCreateLabels(memberLabels, options);
101
- };
102
-
103
- const handleAllLabels = async (labels, members, siteTimezone, options) => {
104
- let {
105
- importSetLabels,
106
- importLabel
107
- } = await handleImportSetLabels(labels, siteTimezone, options);
108
-
109
- const memberLabelsResult = await handleMemberLabels(members);
110
-
111
- const allLabels = [
112
- ...importSetLabels,
113
- ...memberLabelsResult.existingLabels,
114
- ...memberLabelsResult.createdLabels
115
- ];
116
-
117
- return {
118
- allLabels,
119
- importSetLabels,
120
- importLabel
121
- };
122
- };
123
-
124
- module.exports.handleAllLabels = handleAllLabels;
125
- module.exports.serializeMemberLabels = serializeMemberLabels;
@@ -1,520 +0,0 @@
1
- const moment = require('moment-timezone');
2
- const path = require('path');
3
- const fs = require('fs-extra');
4
- const metrics = require('@tryghost/metrics');
5
- const membersCSV = require('../csv');
6
- const errors = require('@tryghost/errors');
7
- const tpl = require('@tryghost/tpl');
8
- const emailTemplate = require('./email-template');
9
- const logging = require('@tryghost/logging');
10
-
11
- const messages = {
12
- filenameCollision: 'Filename already exists, please try again.',
13
- freeMemberNotAllowedImportTier: 'You cannot import a free member with a specified tier.',
14
- invalidImportTier: '"{tier}" is not a valid tier.',
15
- giftServiceUnavailable: 'Gift service is not available.',
16
- giftCannotCombineWithImportTier: 'Cannot specify both gift_id and import_tier.',
17
- giftCannotCombineWithComplimentary: 'Cannot specify both gift_id and complimentary_plan.',
18
- giftReassignFailed: 'Failed to reassign gift to member.'
19
- };
20
-
21
- function wrapGiftError(error) {
22
- const message = (error && typeof error.message === 'string' && error.message) || tpl(messages.giftReassignFailed);
23
- return new errors.DataImportError({
24
- message: `Member cannot be assigned to a gift: ${message}`
25
- });
26
- }
27
-
28
- // The key should correspond to a member model field (unless it's a special purpose field like 'complimentary_plan')
29
- // the value should represent an allowed field name coming from user input
30
- const DEFAULT_CSV_HEADER_MAPPING = {
31
- email: 'email',
32
- name: 'name',
33
- note: 'note',
34
- subscribed_to_emails: 'subscribed',
35
- created_at: 'created_at',
36
- complimentary_plan: 'complimentary_plan',
37
- stripe_customer_id: 'stripe_customer_id',
38
- labels: 'labels',
39
- import_tier: 'import_tier',
40
- gift_id: 'gift_id'
41
- };
42
-
43
- /**
44
- * @typedef {Object} MembersCSVImporterOptions
45
- * @property {string} storagePath - The path to store CSV's in before importing
46
- * @property {Function} getTimezone - function returning currently configured timezone
47
- * @property {() => Object} getMembersRepository - member model access instance for data access and manipulation
48
- * @property {() => Promise<import('../../tiers/tier')>} getDefaultTier - async function returning default Member Tier
49
- * @property {(string) => Promise<import('../../tiers/tier')>} getTierByName - async function returning Member Tier by name
50
- * @property {() => Promise<import('../../gifts/gift-service').GiftService>} getGiftService - async function returning the GiftService instance
51
- * @property {Function} sendEmail - function sending an email
52
- * @property {(string) => boolean} isSet - Method checking if specific feature is enabled
53
- * @property {({job, offloaded, name}) => void} addJob - Method registering an async job
54
- * @property {Object} knex - An instance of the Ghost Database connection
55
- * @property {Function} urlFor - function generating urls
56
- * @property {Object} context
57
- * @property {Object} stripeUtils - An instance of MembersCSVImporterStripeUtils
58
- */
59
-
60
- module.exports = class MembersCSVImporter {
61
- /**
62
- * @param {MembersCSVImporterOptions} options
63
- */
64
- constructor({storagePath, getTimezone, getInlineThreshold, getMembersRepository, getDefaultTier, getTierByName, getGiftService, sendEmail, isSet, addJob, knex, urlFor, context, stripeUtils}) {
65
- this._storagePath = storagePath;
66
- this._getTimezone = getTimezone;
67
- this._getInlineThreshold = getInlineThreshold;
68
- this._getMembersRepository = getMembersRepository;
69
- this._getDefaultTier = getDefaultTier;
70
- this._getTierByName = getTierByName;
71
- this._getGiftService = getGiftService;
72
- this._sendEmail = sendEmail;
73
- this._isSet = isSet;
74
- this._addJob = addJob;
75
- this._knex = knex;
76
- this._urlFor = urlFor;
77
- this._context = context;
78
- this._stripeUtils = stripeUtils;
79
- this._tierIdCache = new Map();
80
- }
81
-
82
- /**
83
- * Prepares a CSV file for import
84
- * - Maps headers based on headerMapping, this allows for a non standard CSV
85
- * to be imported, so long as a mapping exists between it and a standard CSV
86
- * - Stores the CSV to be imported in the storagePath
87
- * - Creates a MemberImport Job and associated MemberImportBatch's
88
- *
89
- * @param {string} inputFilePath - The path to the CSV to prepare
90
- * @param {Object.<string, string>} [headerMapping] - An object whose keys are headers in the input CSV and values are the header to replace it with
91
- *
92
- * @returns {Promise<{filePath: string, batches: number, metadata: Object.<string, any>}>} - A promise resolving to the data including filePath of "prepared" CSV
93
- */
94
- async prepare(inputFilePath, headerMapping) {
95
- headerMapping = headerMapping || DEFAULT_CSV_HEADER_MAPPING;
96
- // @NOTE: investigate why is it "1" and do we even need this concept anymore?
97
- const batchSize = 1;
98
-
99
- const siteTimezone = this._getTimezone();
100
- const currentTime = moment().tz(siteTimezone).format('YYYY-MM-DD HH:mm:ss.SSS');
101
- const outputFileName = `Members Import ${currentTime}.csv`;
102
- const outputFilePath = path.join(this._storagePath, '/', outputFileName);
103
-
104
- const pathExists = await fs.pathExists(outputFilePath);
105
-
106
- if (pathExists) {
107
- throw new errors.DataImportError({message: tpl(messages.filenameCollision)});
108
- }
109
-
110
- // completely rely on explicit user input for header mappings
111
- const rows = await membersCSV.parse(inputFilePath, headerMapping);
112
- // Columns come from every row, not the first. A row with fewer fields than
113
- // the header parses to fewer keys, so reading the schema off one row drops
114
- // the columns it happened to omit for the whole file. A file of headers and
115
- // no rows has no columns, and serialising nothing has nothing to say.
116
- const columns = [...new Set(rows.flatMap(row => Object.keys(row)))];
117
- const numberOfBatches = Math.ceil(rows.length / batchSize);
118
- // No formula escaping: this file is only ever read back by the import, not
119
- // opened by a person, and escaping would be stored on the member verbatim.
120
- const mappedCSV = columns.length ? membersCSV.unparse(rows, columns, {escapeFormulae: false}) : '';
121
-
122
- const hasStripeData = !!(rows.find(function rowHasStripeData(row) {
123
- return !!row.stripe_customer_id;
124
- }));
125
-
126
- await fs.writeFile(outputFilePath, mappedCSV);
127
-
128
- return {
129
- filePath: outputFilePath,
130
- batches: numberOfBatches,
131
- metadata: {
132
- hasStripeData
133
- }
134
- };
135
- }
136
-
137
- /**
138
- * Performs an import of a CSV file
139
- *
140
- * @param {string} filePath - the path to a "prepared" CSV file
141
- */
142
- async perform(filePath, globalLabels = []) {
143
- const performStart = Date.now();
144
- // Copied per row, not shared: the member model stamps ids and trims names onto
145
- // label objects in place, one of these is the caller's import label that
146
- // process() looks up again once the import finishes, and each row runs in its
147
- // own transaction that can roll back.
148
- const cloneGlobalLabels = () => (globalLabels || [])
149
- .map(label => (typeof label === 'string' ? {name: label} : {...label}))
150
- // A caller can hand in a nameless label; the model would drop it, but the
151
- // round-trip this replaced dropped it before the model ever saw it.
152
- .filter(label => label.name);
153
- const rows = await membersCSV.parse(filePath, DEFAULT_CSV_HEADER_MAPPING);
154
-
155
- const defaultTier = await this._getDefaultTier();
156
- const membersRepository = await this._getMembersRepository();
157
- const giftService = this._getGiftService ? await this._getGiftService() : null;
158
-
159
- // Clear tier ID cache before each import in-case tiers have been updated since last import
160
- this._tierIdCache.clear();
161
-
162
- // Keep track of any Stripe prices created as a result of an import tier being specified so that they
163
- // can be archived after the import has completed - This ensures the created Stripe prices cannot be re-used
164
- // for future subscriptions
165
- const archivableStripePriceIds = [];
166
-
167
- const result = await rows.reduce(async (resultPromise, row) => {
168
- const resultAccumulator = await resultPromise;
169
-
170
- // Use doNotReject config to reject `executionPromise` on rollback
171
- // https://github.com/knex/knex/blob/master/UPGRADING.md
172
- const trx = await this._knex.transaction(undefined, {doNotRejectOnRollback: false});
173
- const options = {
174
- transacting: trx,
175
- context: this._context
176
- };
177
-
178
- try {
179
- // Early validation of mutually exclusive columns so we fail the row before
180
- // any member/tier work.
181
- if (row.gift_id) {
182
- if (row.import_tier) {
183
- throw wrapGiftError(new errors.DataImportError({message: tpl(messages.giftCannotCombineWithImportTier)}));
184
- }
185
- if (row.complimentary_plan) {
186
- throw wrapGiftError(new errors.DataImportError({message: tpl(messages.giftCannotCombineWithComplimentary)}));
187
- }
188
- }
189
-
190
- // If the member is created in the future, set created_at to now
191
- // Members created in the future will not appear in admin members list
192
- // Refs https://github.com/TryGhost/Team/issues/2793
193
- const createdAt = moment(row.created_at).isAfter(moment()) ? moment().toDate() : row.created_at;
194
- const memberValues = {
195
- email: row.email,
196
- name: row.name,
197
- note: row.note,
198
- subscribed: row.subscribed,
199
- created_at: createdAt,
200
- // Form labels are applied here rather than folded into the parsed
201
- // rows, so a name that contains a comma does not go through the
202
- // prepared file's comma-separated labels column and split in two.
203
- labels: [...row.labels, ...cloneGlobalLabels()]
204
- };
205
- const existingMember = await membersRepository.get({email: memberValues.email}, {
206
- ...options,
207
- withRelated: ['labels', 'newsletters']
208
- });
209
- let member;
210
- if (existingMember) {
211
- const existingLabels = existingMember.related('labels') ? existingMember.related('labels').toJSON() : [];
212
- const existingNewsletters = existingMember.related('newsletters');
213
-
214
- // Preserve member's existing newsletter subscription preferences
215
- if (existingNewsletters.length > 0 && memberValues.subscribed) {
216
- memberValues.newsletters = existingNewsletters.toJSON();
217
- }
218
-
219
- // If member does not have any subscriptions, assume they have previously unsubscribed
220
- // and do not re-subscribe them
221
- if (!existingNewsletters.length && memberValues.subscribed) {
222
- memberValues.subscribed = false;
223
- }
224
-
225
- // Don't overwrite name or note if they are blank in the file
226
- if (!row.name) {
227
- memberValues.name = existingMember.name;
228
- }
229
- if (!row.note) {
230
- memberValues.note = existingMember.note;
231
- }
232
-
233
- member = await membersRepository.update({
234
- ...memberValues,
235
- labels: existingLabels.concat(memberValues.labels)
236
- }, {
237
- ...options,
238
- id: existingMember.id
239
- });
240
- } else {
241
- member = await membersRepository.create(memberValues, Object.assign({}, options, {
242
- context: {
243
- import: true
244
- }
245
- }));
246
- }
247
-
248
- let importTierId;
249
- if (row.import_tier) {
250
- importTierId = await this.#getTierIdByName(row.import_tier);
251
-
252
- if (!importTierId) {
253
- throw new errors.DataImportError({
254
- message: tpl(messages.invalidImportTier, {tier: row.import_tier})
255
- });
256
- }
257
- }
258
-
259
- if (row.stripe_customer_id && typeof row.stripe_customer_id === 'string') {
260
- let stripeCustomerId;
261
-
262
- // If 'auto' is passed, try to find the Stripe customer by email
263
- if (row.stripe_customer_id.toLowerCase() === 'auto') {
264
- stripeCustomerId = await membersRepository.getCustomerIdByEmail(row.email);
265
- } else {
266
- stripeCustomerId = row.stripe_customer_id;
267
- }
268
-
269
- if (stripeCustomerId) {
270
- if (row.import_tier) {
271
- const {isNewStripePrice, stripePriceId} = await this._stripeUtils.forceStripeSubscriptionToProduct({
272
- customer_id: stripeCustomerId,
273
- product_id: importTierId
274
- }, options);
275
-
276
- if (isNewStripePrice) {
277
- archivableStripePriceIds.push(stripePriceId);
278
- }
279
- }
280
-
281
- await membersRepository.linkStripeCustomer({
282
- customer_id: stripeCustomerId,
283
- member_id: member.id
284
- }, options);
285
- }
286
- } else if (row.complimentary_plan) {
287
- const products = [];
288
-
289
- if (row.import_tier) {
290
- products.push({id: importTierId});
291
- } else {
292
- products.push({id: defaultTier.id.toString()});
293
- }
294
-
295
- await membersRepository.update({products}, {
296
- ...options,
297
- id: member.id
298
- });
299
- } else if (row.import_tier) {
300
- throw new errors.DataImportError({message: tpl(messages.freeMemberNotAllowedImportTier)});
301
- }
302
-
303
- if (row.gift_id) {
304
- if (!giftService) {
305
- throw wrapGiftError(new errors.DataImportError({message: tpl(messages.giftServiceUnavailable)}));
306
- }
307
-
308
- try {
309
- await giftService.reassignRedeemer(row.gift_id, member.id, {transacting: trx});
310
- } catch (giftError) {
311
- throw wrapGiftError(giftError);
312
- }
313
- }
314
-
315
- await trx.commit();
316
- return {
317
- ...resultAccumulator,
318
- imported: resultAccumulator.imported + 1
319
- };
320
- } catch (error) {
321
- // The model layer can sometimes throw arrays of errors
322
- const errorList = [].concat(error);
323
- const errorMessage = errorList.map(({message}) => message).join(', ');
324
- await trx.rollback();
325
- return {
326
- ...resultAccumulator,
327
- errors: [...resultAccumulator.errors, {
328
- ...row,
329
- error: errorMessage
330
- }]
331
- };
332
- }
333
- }, Promise.resolve({
334
- imported: 0,
335
- errors: []
336
- }));
337
-
338
- await Promise.all(
339
- archivableStripePriceIds.map(stripePriceId => this._stripeUtils.archivePrice(stripePriceId))
340
- );
341
-
342
- metrics.metric({
343
- imported: result.imported,
344
- errors: result.errors.length,
345
- value: Date.now() - performStart
346
- });
347
-
348
- return {
349
- total: result.imported + result.errors.length,
350
- ...result
351
- };
352
- }
353
-
354
- generateCompletionEmail(result, data) {
355
- const siteUrl = new URL(this._urlFor('home', null, true));
356
- const membersUrl = new URL('members', this._urlFor('admin', null, true));
357
- if (data.importLabel) {
358
- membersUrl.searchParams.set('label', data.importLabel.slug);
359
- }
360
- return emailTemplate({result, siteUrl, membersUrl, ...data});
361
- }
362
-
363
- generateErrorCSV(result) {
364
- const errorsWithFormattedMessages = result.errors.map((row) => {
365
- const formattedError = row.error
366
- .replace(
367
- 'Value in [members.email] cannot be blank.',
368
- 'Missing email address'
369
- )
370
- .replace(
371
- 'Value in [members.note] exceeds maximum length of 2000 characters.',
372
- '"Note" exceeds maximum length of 2000 characters'
373
- )
374
- .replace(
375
- 'Value in [members.subscribed] must be one of true, false, 0 or 1.',
376
- 'Value in "Subscribed to emails" must be "true" or "false"'
377
- )
378
- .replace(
379
- 'Validation (isEmail) failed for email',
380
- 'Invalid email address'
381
- )
382
- .replace(
383
- /No such customer:[^,]*/,
384
- 'Could not find Stripe customer'
385
- );
386
-
387
- return {
388
- ...row,
389
- error: formattedError
390
- };
391
- });
392
- return membersCSV.unparse(errorsWithFormattedMessages);
393
- }
394
-
395
- /**
396
- * Send email with attached CSV containing error rows info
397
- *
398
- * @param {Object} config
399
- * @param {string} config.emailRecipient - email recipient for error file
400
- * @param {string} config.emailSubject - email subject
401
- * @param {string} config.emailContent - html content of email
402
- * @param {string} config.errorCSV - error CSV content
403
- * @param {Object} config.emailSubject - email subject
404
- * @param {Object} config.importLabel -
405
- * @param {string} config.importLabel.name - label name
406
- */
407
- async sendErrorEmail({emailRecipient, emailSubject, emailContent, errorCSV, importLabel}) {
408
- await this._sendEmail({
409
- to: emailRecipient,
410
- subject: emailSubject,
411
- html: emailContent,
412
- forceTextContent: true,
413
- attachments: [{
414
- filename: `${importLabel.name} - Errors.csv`,
415
- content: errorCSV,
416
- contentType: 'text/csv',
417
- contentDisposition: 'attachment'
418
- }]
419
- });
420
- return;
421
- }
422
-
423
- /**
424
- * Processes CSV file and imports member&label records depending on the size of the imported set
425
- *
426
- * @param {Object} config
427
- * @param {string} config.pathToCSV - path where imported csv with members records is stored
428
- * @param {Object} config.headerMapping - mapping of CSV headers to member record fields
429
- * @param {Object} [config.globalLabels] - labels to be applied to whole imported members set
430
- * @param {Object} config.importLabel -
431
- * @param {string} config.importLabel.name - label name
432
- * @param {Object} config.user
433
- * @param {string} config.user.email - calling user email
434
- * @param {Object} config.LabelModel - instance of Ghosts Label model
435
- * @param {boolean} config.forceInline - allows to force performing imports not in a job (used in test environment)
436
- * @param {{testImportThreshold: () => Promise<void>}} config.verificationTrigger
437
- */
438
- async process({pathToCSV, headerMapping, globalLabels, importLabel, user, LabelModel, forceInline, verificationTrigger}) {
439
- const meta = {};
440
- const job = await this.prepare(pathToCSV, headerMapping);
441
-
442
- meta.originalImportSize = job.batches;
443
-
444
- if ((job.batches <= this._getInlineThreshold() && !job.metadata.hasStripeData) || forceInline) {
445
- const result = await this.perform(job.filePath, globalLabels);
446
- const importLabelModel = result.imported ? await LabelModel.findOne(importLabel) : null;
447
- await verificationTrigger.testImportThreshold();
448
-
449
- return {
450
- meta: Object.assign(meta, {
451
- stats: {
452
- imported: result.imported,
453
- invalid: result.errors
454
- },
455
- import_label: importLabelModel
456
- })
457
- };
458
- } else {
459
- const emailRecipient = user.email;
460
- this._addJob({
461
- job: async () => {
462
- try {
463
- const result = await this.perform(job.filePath, globalLabels);
464
- const importLabelModel = result.imported ? await LabelModel.findOne(importLabel) : null;
465
- const emailContent = this.generateCompletionEmail(result, {
466
- emailRecipient,
467
- importLabel: importLabelModel ? importLabelModel.toJSON() : null
468
- });
469
- const errorCSV = this.generateErrorCSV(result);
470
- const emailSubject = result.imported > 0 ? 'Your member import is complete' : 'Your member import was unsuccessful';
471
- await this.sendErrorEmail({
472
- emailRecipient,
473
- emailSubject,
474
- emailContent,
475
- errorCSV,
476
- importLabel
477
- });
478
- } catch (e) {
479
- logging.error('Error in members import job');
480
- logging.error(e);
481
- }
482
-
483
- // Still check verification triggers in case of errors (e.g., email sending failed)
484
- try {
485
- await verificationTrigger.testImportThreshold();
486
- } catch (e) {
487
- logging.error('Error in members import job when testing import threshold');
488
- logging.error(e);
489
- }
490
- },
491
- offloaded: false,
492
- name: 'members-import'
493
- });
494
-
495
- return {
496
- meta
497
- };
498
- }
499
- }
500
-
501
- /**
502
- * Retrieve the ID of a tier, querying by its name, and cache the result
503
- *
504
- * @param {string} name
505
- * @returns {Promise<string|null>}
506
- */
507
- async #getTierIdByName(name) {
508
- if (!this._tierIdCache.has(name)) {
509
- const tier = await this._getTierByName(name);
510
-
511
- if (!tier) {
512
- return null;
513
- }
514
-
515
- this._tierIdCache.set(name, tier.id.toString());
516
- }
517
-
518
- return this._tierIdCache.get(name);
519
- }
520
- };