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,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
+ }