ghost 6.54.0 → 6.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (327) 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-custom-field-types-0.0.0.tgz +0 -0
  6. package/components/tryghost-kg-default-cards-10.3.3.tgz +0 -0
  7. package/core/boot.js +3 -6
  8. package/core/built/admin/assets/{account-migration-DLvVA5yq.js → account-migration-sjHLx5dy.js} +1 -1
  9. package/core/built/admin/assets/activitypub/{account-migration-DHbZcxGM.js → account-migration-DoYXtnZ8.js} +4 -4
  10. package/core/built/admin/assets/activitypub/activitypub.js +2 -2
  11. package/core/built/admin/assets/activitypub/{bluesky-sharing-zfxUey_L.js → bluesky-sharing-Bxeh6MKW.js} +4 -4
  12. package/core/built/admin/assets/activitypub/{dist-BqkDUQxJ2.js → dist-ByFmDSNP2.js} +64 -63
  13. package/core/built/admin/assets/activitypub/{dist-Ba_QxHHl.js → dist-D8sFfdpC.js} +2 -2
  14. package/core/built/admin/assets/activitypub/{domain-CmwSZLHK.js → domain-CWueFW6q.js} +5 -5
  15. package/core/built/admin/assets/activitypub/{edit-profile-CXdwNmVG.js → edit-profile-BGxPU7N5.js} +3 -3
  16. package/core/built/admin/assets/activitypub/{explore-DfbjzGM1.js → explore-DBK-u0Ch.js} +3 -3
  17. package/core/built/admin/assets/activitypub/{feed-DSEBJOqf.js → feed-Bcir5RQZ.js} +3 -3
  18. package/core/built/admin/assets/activitypub/{field-CY7XVcx6.js → field-BV-CIT3d.js} +2 -2
  19. package/core/built/admin/assets/activitypub/{inbox-DtQ0kytT.js → inbox-yC0nfvzl.js} +4 -4
  20. package/core/built/admin/assets/activitypub/{label-Dw96GQ7u.js → label-DTRS9TDk.js} +2 -2
  21. package/core/built/admin/assets/activitypub/layout-qx-bUL1k.js +1 -0
  22. package/core/built/admin/assets/activitypub/{moderation-Dg_-I-8C.js → moderation-Dwl9t0pb.js} +4 -4
  23. package/core/built/admin/assets/activitypub/{note-Cy1-uKMY.js → note-B-zKsZ7r.js} +3 -3
  24. package/core/built/admin/assets/activitypub/{notifications-B_fvoLIM.js → notifications-DjJZD54t.js} +3 -3
  25. package/core/built/admin/assets/activitypub/{preferences-ByJseZp6.js → preferences-CoU8vs6d.js} +6 -6
  26. package/core/built/admin/assets/activitypub/{profile-BFtbnIjz.js → profile-BAsaJekx.js} +6 -6
  27. package/core/built/admin/assets/activitypub/{routes-Dh3xXfce.js → routes-CLH3OVTP.js} +28 -25
  28. package/core/built/admin/assets/activitypub/{settings-DTgqJPxM.js → settings-DA6pNJ-Q.js} +3 -3
  29. package/core/built/admin/assets/activitypub/{site-CdL9ZLqm.js → site-CQD42Pw5.js} +2 -2
  30. package/core/built/admin/assets/activitypub/{tabs-ogqwoBlb.js → tabs-Cg0saow1.js} +7 -7
  31. package/core/built/admin/assets/activitypub/{use-reply-chain-data-CxonteFD.js → use-reply-chain-data-Br4aOWOh.js} +2 -2
  32. package/core/built/admin/assets/audience-DjEWNhp3.js +1 -0
  33. package/core/built/admin/assets/{automated-emails-Cn87L5Md.js → automated-emails-DRy7qY-J.js} +2 -2
  34. package/core/built/admin/assets/{automations-BP_aHamJ.js → automations-Bx3VcFIL.js} +1 -1
  35. package/core/built/admin/assets/{automations-B7-y3dXt.js → automations-DhKeS_U5.js} +1 -1
  36. package/core/built/admin/assets/{bluesky-sharing-Bh36XPh4.js → bluesky-sharing-ClMd0-Ft.js} +1 -1
  37. package/core/built/admin/assets/{breadcrumb-D34YWnaw.js → breadcrumb-BD0U2ygg.js} +1 -1
  38. package/core/built/admin/assets/{chart-helpers-DbklaJw9.js → chart-helpers-fjQKRVyY.js} +1 -1
  39. package/core/built/admin/assets/checkbox-CWa28lgO.js +1 -0
  40. package/core/built/admin/assets/{chunk.962.b73442b07be930bb5d85.js → chunk.211.6f7328d63c41536451b3.js} +737 -559
  41. package/core/built/admin/assets/{chunk.962.b73442b07be930bb5d85.js.LICENSE.txt → chunk.211.6f7328d63c41536451b3.js.LICENSE.txt} +2 -0
  42. package/core/built/admin/assets/{chunk.589.096f4d0bb775e5229aa9.js → chunk.481.6c7395a49484bb4583aa.js} +41 -38
  43. package/core/built/admin/assets/chunk.524.cffd399783a519e404a9.js +25 -0
  44. package/core/built/admin/assets/chunk.582.eebf0d0b2d202bb746a5.js +27 -0
  45. package/core/built/admin/assets/code-editor-view-CCr0rBWS.js +1 -0
  46. package/core/built/admin/assets/comments-DyUSMUdj.js +1 -0
  47. package/core/built/admin/assets/{date-range-select-B5vSsrpx.js → date-range-select-BFp58EMk.js} +1 -1
  48. package/core/built/admin/assets/{disabled-sources-indicator-NkOIoMue.js → disabled-sources-indicator-CUxMfiPa.js} +1 -1
  49. package/core/built/admin/assets/dist-2Tnz2Yfq.js +1 -0
  50. package/core/built/admin/assets/dist-CjY-f5Z1.js +1 -0
  51. package/core/built/admin/assets/dist-DIC8yz5C.js +1 -0
  52. package/core/built/admin/assets/domain-C0bV2UV8.js +1 -0
  53. package/core/built/admin/assets/dropzone-DlK-K2DQ.js +5 -0
  54. package/core/built/admin/assets/{edit-profile-rvDGrS8J.js → edit-profile-BKT-REJw.js} +1 -1
  55. package/core/built/admin/assets/editor-Dp865O-4.js +8 -0
  56. package/core/built/admin/assets/{explore-BZAZOg5X.js → explore-ClUpJZOp.js} +1 -1
  57. package/core/built/admin/assets/{feed-CFV8xDw9.js → feed-DF92dUMP.js} +1 -1
  58. package/core/built/admin/assets/field-Do7lieBA.js +1 -0
  59. package/core/built/admin/assets/{filter-sources-Fx8wiYYw.js → filter-sources-BV2rnpfA.js} +1 -1
  60. package/core/built/admin/assets/filters-CUXJ2Nm9.js +1 -0
  61. package/core/built/admin/assets/{filters-CGX_pf2N.js → filters-DfbIWc_l.js} +1 -1
  62. package/core/built/admin/assets/{ghost-3063eae29e6c9cc6d6e7131928a27da0.css → ghost-a9c788f6f0b1ea6cf0f14ca4b13f4496.css} +1 -1
  63. package/core/built/admin/assets/{ghost-b1dce91ad3232cc259ee5fbbf9526243.js → ghost-c4057474943effc568c5659ccc4909d7.js} +407 -406
  64. package/core/built/admin/assets/{ghost-dark-c8d0ab7f515b2010c173e23e9b32ea8a.css → ghost-dark-db36d795d21c61426837df40af270966.css} +1 -1
  65. package/core/built/admin/assets/gift-link-modal-f9VIMNDv.js +1 -0
  66. package/core/built/admin/assets/{gift-links-nBxJjZiQ.js → gift-links-CsUhefWG.js} +1 -1
  67. package/core/built/admin/assets/growth-B3I6gNLm.js +1 -0
  68. package/core/built/admin/assets/{Growth-CSaHoSVD.js → growth-CVw1My4S.js} +2 -2
  69. package/core/built/admin/assets/{hooks-BApwwBE0.js → hooks-CixO01lL.js} +1 -1
  70. package/core/built/admin/assets/{inbox-BRp1_aaC.js → inbox-CFBSu4Ed.js} +12 -12
  71. package/core/built/admin/assets/index-9Cw8z3rA.css +1 -0
  72. package/core/built/admin/assets/index-BGG7NckO.js +67 -0
  73. package/core/built/admin/assets/input-group-BSgG_lVw.js +1 -0
  74. package/core/built/admin/assets/input-surface-BpAjZXY5.js +1 -0
  75. package/core/built/admin/assets/koenig-lexical/koenig-lexical.js +974 -937
  76. package/core/built/admin/assets/koenig-lexical/koenig-lexical.umd.js +28 -28
  77. package/core/built/admin/assets/koenig-lexical/style.css +1 -1
  78. package/core/built/admin/assets/{koenig-lexical-CM7UFNdL.js → koenig-lexical-f8vHRYvz.js} +28 -28
  79. package/core/built/admin/assets/{kpi-card-BrGH6FsT.js → kpi-card-DSap9rAy.js} +1 -1
  80. package/core/built/admin/assets/{kpi-card-CIGyijjx.js → kpi-card-DU0rn9Cj.js} +1 -1
  81. package/core/built/admin/assets/{kpi-helpers-_HRRsklE.js → kpi-helpers-Dz33AV6h.js} +1 -1
  82. package/core/built/admin/assets/{kpi-tabs-BSvh9eoq.js → kpi-tabs-BtTTrKvJ.js} +1 -1
  83. package/core/built/admin/assets/{label-BOR9XdKl.js → label-DS8Mi6G5.js} +1 -1
  84. package/core/built/admin/assets/label-picker-1DvSuLA1.js +1 -0
  85. package/core/built/admin/assets/labels-CDSUt4J_.js +1 -0
  86. package/core/built/admin/assets/layout-yQEi2qDM.js +1 -0
  87. package/core/built/admin/assets/{link-helpers-tOlxmuH5.js → link-helpers-7EYqvvHs.js} +1 -1
  88. package/core/built/admin/assets/member-custom-fields-yOMHyAg9.js +1 -0
  89. package/core/built/admin/assets/member-detail-BAq7gDaa.js +1 -0
  90. package/core/built/admin/assets/members-CQpVFJu4.js +10 -0
  91. package/core/built/admin/assets/modals-ReORvWnu.js +31 -0
  92. package/core/built/admin/assets/moderation-DySbgyUS.js +1 -0
  93. package/core/built/admin/assets/newsletter-CJQvtHqN.js +1 -0
  94. package/core/built/admin/assets/{Newsletters-C5cs6FGo.js → newsletters-B3QQXfGT.js} +1 -1
  95. package/core/built/admin/assets/{newsletters-D1jbtV-h.js → newsletters-BXJbJgA-.js} +1 -1
  96. package/core/built/admin/assets/note-C8kxrz6w.js +1 -0
  97. package/core/built/admin/assets/notifications-CgtSfX7t.js +1 -0
  98. package/core/built/admin/assets/{offers-CrY6nSRR.js → offers-DEniVRD5.js} +1 -1
  99. package/core/built/admin/assets/onboarding-route-4qtziA9d.js +1 -0
  100. package/core/built/admin/assets/overview-Bf2sbznq.js +1 -0
  101. package/core/built/admin/assets/overview-CBfePxcr.js +1 -0
  102. package/core/built/admin/assets/{page-header-C0jVxtS8.js → page-header-B3Wb9Jqv.js} +1 -1
  103. package/core/built/admin/assets/{pages-BbNWFcGj.js → pages-B4goRPUn.js} +1 -1
  104. package/core/built/admin/assets/{post-analytics-Iw7dk1mT.js → post-analytics-We-ko5Cb.js} +1 -1
  105. package/core/built/admin/assets/post-analytics-header-Bmc9su6q.js +1 -0
  106. package/core/built/admin/assets/post-analytics-provider-B6UynTGb.js +1 -0
  107. package/core/built/admin/assets/{post-share-modal-DBfKl2GZ.js → post-share-modal-DFfO93_0.js} +1 -1
  108. package/core/built/admin/assets/{posts-EWpZIjkr.js → posts-DhXE7b6r.js} +1 -1
  109. package/core/built/admin/assets/{preferences-D4EANGoc.js → preferences-DJPfHEGu.js} +2 -2
  110. package/core/built/admin/assets/profile-DqBQp-gT.js +1 -0
  111. package/core/built/admin/assets/{referrers-BCfFYSHc.js → referrers-pmJT4k3-.js} +1 -1
  112. package/core/built/admin/assets/select-C5_xEt5b.js +1 -0
  113. package/core/built/admin/assets/settings-BwiVIXau.js +4 -0
  114. package/core/built/admin/assets/{settings-Bz3RZRmE.js → settings-WXNeWyU1.js} +1 -1
  115. package/core/built/admin/assets/shade-app-B3heOAfz-CGXQwWeO.js +3 -0
  116. package/core/built/admin/assets/{share-modal-CkdTTMJd.js → share-modal-DsgW3wbg.js} +1 -1
  117. package/core/built/admin/assets/social-urls-BQPFEk1_.js +8 -0
  118. package/core/built/admin/assets/{sort-button-DZp9Y2r1.js → sort-button-Bb_FCl7e.js} +1 -1
  119. package/core/built/admin/assets/{source-icon-CHr9qJ2Y.js → source-icon-BeaRxUn9.js} +1 -1
  120. package/core/built/admin/assets/{stats-DGoXZxWE.js → stats-Bjj_kmJ1.js} +1 -1
  121. package/core/built/admin/assets/{stats-view-D2-1L0ms.js → stats-view-BxPbW6K1.js} +1 -1
  122. package/core/built/admin/assets/{step-1-RdahbwyG.js → step-1-B1e3-1F8.js} +1 -1
  123. package/core/built/admin/assets/{step-2-D83PeRwd.js → step-2-C5WaeBJo.js} +1 -1
  124. package/core/built/admin/assets/{step-3-p_Op4wv4.js → step-3-B46iVPwu.js} +2 -2
  125. package/core/built/admin/assets/switch-z5Y6Uzt6.js +1 -0
  126. package/core/built/admin/assets/{table-B8LgTQJZ.js → table-CB-4_HBJ.js} +1 -1
  127. package/core/built/admin/assets/tabs-CCEgKYzq.js +1 -0
  128. package/core/built/admin/assets/{tags-B4l891z2.js → tags-Cg2VJGmG.js} +1 -1
  129. package/core/built/admin/assets/textarea-Bjccvh_E.js +1 -0
  130. package/core/built/admin/assets/{tiers-BgYLWRJv.js → tiers-DYsa4DCo.js} +1 -1
  131. package/core/built/admin/assets/{toggle-group-DNXDkAwf.js → toggle-group-b3IlxvhV.js} +1 -1
  132. package/core/built/admin/assets/{topic-filter-DZGjEcz3.js → topic-filter-DAFT87tQ.js} +1 -1
  133. package/core/built/admin/assets/{use-analytics-data-CaT8RirX.js → use-analytics-data-S0FTd43s.js} +1 -1
  134. package/core/built/admin/assets/use-filter-params-Gpoq2E1L.js +1 -0
  135. package/core/built/admin/assets/{use-growth-stats-9Db2CuJe.js → use-growth-stats-CIaiHOVb.js} +1 -1
  136. package/core/built/admin/assets/{use-post-referrers-BV54YcQS.js → use-post-referrers-Cq2vBPrA.js} +1 -1
  137. package/core/built/admin/assets/{use-reply-chain-data-DuYZGDan.js → use-reply-chain-data-DBDRtGu-.js} +1 -1
  138. package/core/built/admin/assets/{use-simple-pagination-8fTua9Nt.js → use-simple-pagination-CjYNxdwe.js} +1 -1
  139. package/core/built/admin/assets/{use-tinybird-query-Dz156HvZ.js → use-tinybird-query-falHAtuK.js} +1 -1
  140. package/core/built/admin/assets/{use-tinybird-token-C9RUdL_n.js → use-tinybird-token-D7-8ND1y.js} +1 -1
  141. package/core/built/admin/assets/{users-CcSCQFcQ.js → users-Dkv8NQZe.js} +1 -1
  142. package/core/built/admin/assets/{virtual-list-BCvP0YF-.js → virtual-list-CvnC9zF2.js} +1 -1
  143. package/core/built/admin/assets/web-B_5GZRvH.js +1 -0
  144. package/core/built/admin/assets/web-jFYWCKDn.js +2 -0
  145. package/core/built/admin/index.html +14 -10
  146. package/core/frontend/services/proxy.js +10 -1
  147. package/core/frontend/services/routing/router-manager.js +14 -14
  148. package/core/frontend/services/routing/static-routes-router.js +8 -10
  149. package/core/server/adapters/storage/LocalFilesStorage.js +13 -11
  150. package/core/server/adapters/storage/LocalFilesStorage.ts +24 -0
  151. package/core/server/adapters/storage/LocalImagesStorage.js +14 -12
  152. package/core/server/adapters/storage/LocalImagesStorage.ts +24 -0
  153. package/core/server/adapters/storage/LocalMediaStorage.js +13 -11
  154. package/core/server/adapters/storage/LocalMediaStorage.ts +24 -0
  155. package/core/server/adapters/storage/LocalStorageBase.js +107 -167
  156. package/core/server/adapters/storage/LocalStorageBase.ts +342 -0
  157. package/core/server/adapters/storage/utils.js +36 -50
  158. package/core/server/adapters/storage/utils.ts +60 -0
  159. package/core/server/api/endpoints/automation-action-links.js +68 -0
  160. package/core/server/api/endpoints/automation-action-links.ts +46 -0
  161. package/core/server/api/endpoints/db.js +6 -0
  162. package/core/server/api/endpoints/index.js +4 -0
  163. package/core/server/api/endpoints/members.js +25 -28
  164. package/core/server/api/endpoints/pages.js +6 -1
  165. package/core/server/api/endpoints/posts.js +11 -2
  166. package/core/server/api/endpoints/settings.js +0 -2
  167. package/core/server/api/endpoints/utils/serializers/output/mappers/comments.js +4 -1
  168. package/core/server/api/endpoints/utils/serializers/output/members.js +18 -87
  169. package/core/server/api/endpoints/utils/serializers/output/utils/url.js +11 -1
  170. package/core/server/data/exporter/export-filename.js +2 -1
  171. package/core/server/data/importer/importers/data/revue-subscriber-importer.js +6 -10
  172. package/core/server/data/importer/importers/json-to-html.js +24 -4
  173. package/core/server/data/migrations/versions/6.55/2026-07-27-11-25-44-add-to-hash-to-redirects.js +51 -0
  174. package/core/server/data/migrations/versions/6.55/2026-07-28-12-08-00-remove-routes-hash-from-settings.js +5 -0
  175. package/core/server/data/schema/commands.js +5 -0
  176. package/core/server/data/schema/default-settings/default-settings.json +0 -4
  177. package/core/server/data/schema/schema.js +5 -1
  178. package/core/server/lib/image/image-size.js +3 -17
  179. package/core/server/lib/image/image-utils.js +15 -1
  180. package/core/server/lib/request-external.js +1 -1
  181. package/core/server/models/user.js +46 -70
  182. package/core/server/services/auth/session/session-service.js +28 -1
  183. package/core/server/services/automations/automations-api.js +21 -6
  184. package/core/server/services/automations/automations-api.ts +25 -6
  185. package/core/server/services/automations/automations-repository.ts +19 -0
  186. package/core/server/services/automations/database-automations-repository.js +116 -28
  187. package/core/server/services/automations/database-automations-repository.ts +118 -9
  188. package/core/server/services/automations/database-date.js +10 -0
  189. package/core/server/services/automations/database-date.ts +5 -0
  190. package/core/server/services/automations/poll.js +5 -2
  191. package/core/server/services/automations/poll.ts +7 -2
  192. package/core/server/services/automations/scheduler-poll-time.js +40 -0
  193. package/core/server/services/automations/scheduler-poll-time.ts +38 -0
  194. package/core/server/services/automations/service.js +15 -3
  195. package/core/server/services/automations/service.ts +16 -3
  196. package/core/server/services/comments/comments-service-emails.js +14 -8
  197. package/core/server/services/comments/comments-service.js +9 -4
  198. package/core/server/services/email-rendering/partials/email-wrapper.hbs +3 -1
  199. package/core/server/services/email-service/email-service-wrapper.js +6 -1
  200. package/core/server/services/email-service/email-templates/partials/styles.hbs +8 -0
  201. package/core/server/services/email-service/newsletter-email-event-storage.js +41 -10
  202. package/core/server/services/lib/dynamic-redirect-manager.js +12 -3
  203. package/core/server/services/link-redirection/link-redirect-repository.js +41 -4
  204. package/core/server/services/link-redirection/link-redirect.js +2 -0
  205. package/core/server/services/link-redirection/link-redirect.ts +3 -1
  206. package/core/server/services/link-redirection/link-redirects-service.js +38 -2
  207. package/core/server/services/link-tracking/click-event.js +3 -0
  208. package/core/server/services/link-tracking/index.js +38 -6
  209. package/core/server/services/link-tracking/link-click-repository.js +30 -9
  210. package/core/server/services/link-tracking/link-click-tracking-service.js +44 -3
  211. package/core/server/services/member-attribution/outbound-link-tagger.js +3 -1
  212. package/core/server/services/member-welcome-emails/member-welcome-email-renderer.js +19 -4
  213. package/core/server/services/member-welcome-emails/service.js +11 -3
  214. package/core/server/services/members/{importer → import-export}/config.js +4 -9
  215. package/core/server/services/members/{importer → import-export}/config.ts +4 -9
  216. package/core/server/services/members/import-export/csv/formula.js +16 -0
  217. package/core/server/services/members/import-export/csv/formula.ts +14 -0
  218. package/core/server/services/members/import-export/csv/index.js +12 -0
  219. package/core/server/services/members/import-export/csv/index.ts +3 -0
  220. package/core/server/services/members/import-export/csv/parse.js +64 -0
  221. package/core/server/services/members/import-export/csv/parse.ts +69 -0
  222. package/core/server/services/members/import-export/csv/serialize.js +13 -0
  223. package/core/server/services/members/import-export/csv/serialize.ts +18 -0
  224. package/core/server/services/members/import-export/export/exporter.js +202 -0
  225. package/core/server/services/members/import-export/export/exporter.ts +325 -0
  226. package/core/server/services/members/import-export/import/completion-email.js +84 -0
  227. package/core/server/services/members/import-export/import/completion-email.ts +141 -0
  228. package/core/server/services/members/import-export/import/importer.js +297 -0
  229. package/core/server/services/members/import-export/import/importer.ts +468 -0
  230. package/core/server/services/members/import-export/import/reader.js +32 -0
  231. package/core/server/services/members/import-export/import/reader.ts +33 -0
  232. package/core/server/services/members/import-export/import/row.js +33 -0
  233. package/core/server/services/members/import-export/import/row.ts +52 -0
  234. package/core/server/services/members/import-export/import/spool.js +30 -0
  235. package/core/server/services/members/import-export/import/spool.ts +38 -0
  236. package/core/server/services/members/import-export/index.js +99 -0
  237. package/core/server/services/members/import-export/index.ts +137 -0
  238. package/core/server/services/members/members-api/controllers/router-controller.js +7 -0
  239. package/core/server/services/members/service.js +25 -20
  240. package/core/server/services/members-custom-fields/values-service.js +19 -7
  241. package/core/server/services/members-custom-fields/values-service.ts +19 -7
  242. package/core/server/services/mentions/service.js +5 -1
  243. package/core/server/services/posts/posts-exporter.js +6 -4
  244. package/core/server/services/posts/posts-service.js +2 -2
  245. package/core/server/services/route-settings/activation-bridge.js +59 -49
  246. package/core/server/services/route-settings/activation-bridge.ts +91 -51
  247. package/core/server/services/route-settings/dynamic-routing-service.js +22 -51
  248. package/core/server/services/route-settings/index.js +1 -16
  249. package/core/server/services/settings/settings-service.js +0 -18
  250. package/core/server/services/themes/storage.js +9 -0
  251. package/core/server/services/themes/theme-storage.js +1 -1
  252. package/core/server/services/url/url-service-facade.js +1 -1
  253. package/core/server/services/url/url-service-facade.ts +8 -1
  254. package/core/server/web/api/endpoints/admin/routes.js +1 -0
  255. package/core/shared/config/defaults.json +2 -1
  256. package/core/shared/express.js +6 -1
  257. package/core/shared/labs.js +2 -1
  258. package/package.json +11 -11
  259. package/pnpm-lock.yaml +18 -18
  260. package/pnpm-workspace.yaml +8 -7
  261. package/components/tryghost-adapter-base-scheduling-0.1.1.tgz +0 -0
  262. package/core/built/admin/assets/Overview-j4NaeUNm.js +0 -1
  263. package/core/built/admin/assets/Web-_fD935OX.js +0 -2
  264. package/core/built/admin/assets/activitypub/layout-IdVq3lRy.js +0 -1
  265. package/core/built/admin/assets/audience-BKRdsFmy.js +0 -1
  266. package/core/built/admin/assets/checkbox-DA4-1UiI.js +0 -1
  267. package/core/built/admin/assets/chunk.524.45e0875f41b71e46e849.js +0 -25
  268. package/core/built/admin/assets/chunk.582.51defd4b009f0de8d4d5.js +0 -27
  269. package/core/built/admin/assets/code-editor-view-BKBrkffZ.js +0 -1
  270. package/core/built/admin/assets/comments-CzVNIT8b.js +0 -1
  271. package/core/built/admin/assets/constants-c-JNF9c2.js +0 -1
  272. package/core/built/admin/assets/dist-CT46_f_J.js +0 -1
  273. package/core/built/admin/assets/domain-B7_n5nfx.js +0 -1
  274. package/core/built/admin/assets/dropzone-DcuJEEUf.js +0 -5
  275. package/core/built/admin/assets/editor-BCiIyFjm.js +0 -8
  276. package/core/built/admin/assets/field-BW-M2Zwp.js +0 -1
  277. package/core/built/admin/assets/filters-DtcXMZDU.js +0 -1
  278. package/core/built/admin/assets/gift-link-modal-Bk8qpnMw.js +0 -1
  279. package/core/built/admin/assets/growth-Ck2s3JjR.js +0 -1
  280. package/core/built/admin/assets/index-BBf502Wg.js +0 -69
  281. package/core/built/admin/assets/index-BCCZKxP2.css +0 -1
  282. package/core/built/admin/assets/input-group-DjuYnNCd.js +0 -1
  283. package/core/built/admin/assets/label-picker-BdNAWuUX.js +0 -1
  284. package/core/built/admin/assets/labels-Cyayfd-e.js +0 -1
  285. package/core/built/admin/assets/layout-Bw4GnxXp.js +0 -1
  286. package/core/built/admin/assets/member-custom-fields-DvFyamLu.js +0 -1
  287. package/core/built/admin/assets/member-detail-Cg2vTqvJ.js +0 -1
  288. package/core/built/admin/assets/members-DsPxYAVm.js +0 -10
  289. package/core/built/admin/assets/modals-Bf_fyvHZ.js +0 -31
  290. package/core/built/admin/assets/moderation-Dlp7M5sa.js +0 -1
  291. package/core/built/admin/assets/multi-select-combobox-CH1o8XXQ.js +0 -1
  292. package/core/built/admin/assets/newsletter-CQ5CAYUI.js +0 -1
  293. package/core/built/admin/assets/note-BrAZ7Z0o.js +0 -1
  294. package/core/built/admin/assets/notifications-BnbHLOPC.js +0 -1
  295. package/core/built/admin/assets/onboarding-route-eU146kuA.js +0 -1
  296. package/core/built/admin/assets/overview-BpbaK-xu.js +0 -1
  297. package/core/built/admin/assets/post-analytics-header-D9pJ4iNm.js +0 -1
  298. package/core/built/admin/assets/post-analytics-provider-BDqcfBor.js +0 -1
  299. package/core/built/admin/assets/profile-dJ44Bn_W.js +0 -1
  300. package/core/built/admin/assets/select--2uVN9_-.js +0 -1
  301. package/core/built/admin/assets/settings-SD9bxIpt.js +0 -4
  302. package/core/built/admin/assets/social-urls-BvFZwYcE.js +0 -8
  303. package/core/built/admin/assets/switch-qp518gFQ.js +0 -1
  304. package/core/built/admin/assets/tabs-D3ZOYkM7.js +0 -1
  305. package/core/built/admin/assets/textarea-CTlb3Uu6.js +0 -1
  306. package/core/built/admin/assets/use-filter-params-DeQ8sX4N.js +0 -1
  307. package/core/built/admin/assets/web-DTZnnUGr.js +0 -1
  308. package/core/server/services/mail/templates/newsletter.html +0 -876
  309. package/core/server/services/members/csv/index.js +0 -10
  310. package/core/server/services/members/csv/index.ts +0 -3
  311. package/core/server/services/members/csv/parse.js +0 -94
  312. package/core/server/services/members/csv/parse.ts +0 -111
  313. package/core/server/services/members/csv/types.js +0 -2
  314. package/core/server/services/members/csv/types.ts +0 -39
  315. package/core/server/services/members/csv/unparse.js +0 -69
  316. package/core/server/services/members/csv/unparse.ts +0 -79
  317. package/core/server/services/members/exporter/query.js +0 -233
  318. package/core/server/services/members/importer/index.js +0 -30
  319. package/core/server/services/members/importer/labels.js +0 -125
  320. package/core/server/services/members/importer/members-csv-importer.js +0 -520
  321. package/core/server/services/route-settings/default-settings-manager.js +0 -63
  322. package/core/server/services/route-settings/route-settings.js +0 -170
  323. package/core/server/services/route-settings/settings-loader.js +0 -60
  324. package/core/server/services/route-settings/settings-path-manager.js +0 -47
  325. package/core/server/services/route-settings/validate.js +0 -440
  326. /package/core/server/services/members/{importer → import-export/import}/email-template.js +0 -0
  327. /package/core/server/services/members/{importer/members-csv-importer-stripe-utils.js → import-export/import/stripe-utils.js} +0 -0
@@ -0,0 +1,468 @@
1
+ import moment from 'moment-timezone';
2
+ import buildCompletionEmail from './completion-email';
3
+ import {stripFormulaGuard} from '../csv';
4
+ import {fieldValuesFromCsvRow, type CsvField} from '@tryghost/custom-field-types/csv';
5
+ import type {Knex} from 'knex';
6
+ import type {MemberImportRow, ImportErrorRow, ImportLabel, Label} from './row';
7
+ import type {RowSpool, SpooledRows} from './spool';
8
+
9
+ const metrics = require('@tryghost/metrics');
10
+ const errors = require('@tryghost/errors');
11
+ const tpl = require('@tryghost/tpl');
12
+ const logging = require('@tryghost/logging');
13
+
14
+ // The members CSV importer, sliced into one concern per method. Two entry points by
15
+ // design: importCSV decides inline-vs-deferred by load, since a large import must not
16
+ // hold a request open; importInline always runs now, for callers with no request to
17
+ // protect (the Revue data import). The kernel takes a plain MemberImportRow array -- the
18
+ // CSV reader and the deferred spool both produce one -- so a test can drive it from a
19
+ // literal array. Collaborators are injected one per concern; knex is first-class, and no
20
+ // Bookshelf model is referenced below the boundary that owns it.
21
+
22
+ // The import service's arguments, built by the endpoint from the request frame.
23
+ // requestUserEmail is null when the request carried no user, so the deferred path falls
24
+ // back to the site owner.
25
+ export interface ImportRequest {
26
+ filePath: string;
27
+ mapping?: Record<string, string>;
28
+ extraLabels?: Label[];
29
+ requestUserEmail: string | null;
30
+ }
31
+
32
+ interface VerificationTrigger {
33
+ testImportThreshold(): Promise<void>;
34
+ }
35
+
36
+ // A tier, narrowed to the id the import reads off it.
37
+ export interface Tier {
38
+ id: {toString(): string};
39
+ }
40
+
41
+ // A member model, narrowed to what the import loop touches. Relations are loaded
42
+ // via withRelated before the loop reads them, so related() is treated as present.
43
+ interface MemberModel {
44
+ id: string;
45
+ name: string;
46
+ note: string;
47
+ related(relation: string): {toJSON(): Array<{name: string}>; length: number};
48
+ }
49
+
50
+ // Reads a source into import rows. The CSV reader is the only implementation today;
51
+ // a test can inject one that returns a fixed rows array with no file involved.
52
+ type ReadRows = (path: string, mapping?: Record<string, string>) => Promise<MemberImportRow[]>;
53
+
54
+ // The members aggregate: the one thing the import writes. Its methods are the
55
+ // member operations a row performs, plus the import label lookup, which is a
56
+ // member-tagging concern rather than a store of its own. Hides the Member and
57
+ // Label models behind the shape the import needs.
58
+ export interface MembersRepository {
59
+ get(data: {email?: string}, options: object): Promise<MemberModel | null>;
60
+ create(values: object, options: object): Promise<MemberModel>;
61
+ update(values: object, options: object): Promise<MemberModel>;
62
+ getCustomerIdByEmail(email: string): Promise<string | undefined>;
63
+ linkStripeCustomer(data: {customer_id: string; member_id: string}, options: object): Promise<void>;
64
+ getImportLabel(name: string): Promise<{toJSON(): ImportLabel} | null>;
65
+ }
66
+
67
+ // Tiers: the tier catalog, an aggregate of its own, consulted to resolve the
68
+ // default tier and a tier a row names.
69
+ interface TiersRepository {
70
+ getDefault(): Promise<Tier>;
71
+ getByName(name: string): Promise<Tier | null>;
72
+ }
73
+
74
+ // Stripe: the external subscription work a row with Stripe data triggers.
75
+ interface StripeSubscriptions {
76
+ forceStripeSubscriptionToProduct(
77
+ args: {customer_id: string; product_id?: string},
78
+ options: object
79
+ ): Promise<{isNewStripePrice: boolean; stripePriceId: string}>;
80
+ archivePrice(stripePriceId: string): Promise<void>;
81
+ }
82
+
83
+ // Gifts: reassigning a redeemed gift to the imported member.
84
+ export interface GiftService {
85
+ reassignRedeemer(giftId: string, memberId: string, options: object): Promise<void>;
86
+ }
87
+
88
+ // The completion email concern: who it goes to, the links it carries, and sending
89
+ // it. The default recipient is the site owner, used when the request had no user.
90
+ export interface EmailNotifications {
91
+ send(options: object): Promise<unknown>;
92
+ getDefaultRecipient(): Promise<string>;
93
+ urlFor(type: string, data: unknown, absolute: boolean): string;
94
+ }
95
+
96
+ // Opaque to the import: planWrite produces it, applyWrite consumes it.
97
+ type CustomFieldPlan = unknown;
98
+
99
+ // The custom fields collaborator as the import needs it. activeFields is the field set a
100
+ // custom_fields.* column is read against, empty when the feature is off; planWrite
101
+ // validates a row's values (throwing so the row fails whole) and applyWrite persists
102
+ // them, both on the row's transaction.
103
+ export interface CustomFieldsImport {
104
+ activeFields(): Promise<CsvField[]>;
105
+ planWrite(values: Record<string, unknown>): Promise<CustomFieldPlan[]>;
106
+ applyWrite(memberId: string, plan: CustomFieldPlan[], executor: Knex): Promise<void>;
107
+ }
108
+
109
+ // The collaborators the import depends on, one per concern.
110
+ interface ImporterDeps {
111
+ knex: Knex;
112
+ readRows: ReadRows;
113
+ spool: RowSpool;
114
+ members: MembersRepository;
115
+ tiers: TiersRepository;
116
+ stripe: StripeSubscriptions;
117
+ gifts: GiftService;
118
+ customFields: CustomFieldsImport;
119
+ email: EmailNotifications;
120
+ addJob: (job: {job: () => Promise<void>; offloaded: boolean; name: string}) => void;
121
+ getTimezone: () => string;
122
+ getInlineThreshold: () => number;
123
+ }
124
+
125
+ // The values built for one row and handed to the members repository. Indexed off
126
+ // MemberImportRow so a change to the import schema surfaces here: rename or retype a
127
+ // field the kernel writes and this stops compiling until the mapping is updated. name
128
+ // and note may be replaced with the existing member's values (both strings, already in
129
+ // range); created_at may be clamped to now when the row dates a member in the future;
130
+ // newsletters is opaque ORM relation JSON, an honest unknown at the model boundary.
131
+ interface MemberImportValues {
132
+ email: MemberImportRow['email'];
133
+ name: MemberImportRow['name'];
134
+ note: MemberImportRow['note'];
135
+ subscribed: MemberImportRow['subscribed'];
136
+ created_at: MemberImportRow['created_at'] | Date;
137
+ labels: Label[];
138
+ newsletters?: unknown;
139
+ }
140
+
141
+ // The kernel does the importing, so it is what knows whether anything imported and,
142
+ // if so, which label the members were tagged with (a label is only persisted once
143
+ // attached to a member). The label is absent when nothing imported; the orchestrator
144
+ // reads it as null and never fetches it itself.
145
+ interface ImportResult {
146
+ imported: number;
147
+ errors: ImportErrorRow[];
148
+ importLabel?: ImportLabel;
149
+ }
150
+
151
+ // What the domain service returns: an import that ran inline carries its stats and
152
+ // label for the response; a deferred one carries only the size accepted, because
153
+ // the work -- and its email -- finishes after the request is answered.
154
+ export type ImportOutcome =
155
+ | {deferred: false; originalImportSize: number; result: ImportResult}
156
+ | {deferred: true; originalImportSize: number};
157
+
158
+ const messages = {
159
+ freeMemberNotAllowedImportTier: 'You cannot import a free member with a specified tier.',
160
+ invalidImportTier: '"{tier}" is not a valid tier.',
161
+ giftCannotCombineWithImportTier: 'Cannot specify both gift_id and import_tier.',
162
+ giftCannotCombineWithComplimentary: 'Cannot specify both gift_id and complimentary_plan.',
163
+ giftReassignFailed: 'Failed to reassign gift to member.'
164
+ };
165
+
166
+ // Columns whose presence makes a row slow to import (they reach out to Stripe), so
167
+ // a file carrying any of them is deferred regardless of size. The routing decision
168
+ // only ever sees the boolean this produces, never the column names themselves.
169
+ const EXPENSIVE_COLUMNS = ['stripe_customer_id'];
170
+
171
+ // The context each repository call runs under: the import identifies itself, and a
172
+ // created member is additionally flagged as imported so downstream hooks can tell.
173
+ const IMPORT_CONTEXT = {importer: true};
174
+ const CREATE_CONTEXT = {import: true};
175
+
176
+ // The import label members are tagged with, named for the request time. Shared by
177
+ // the kernel (the label) and the deferred email (the error CSV filename).
178
+ function buildImportLabelName(timezone: string): string {
179
+ return `Import ${moment().tz(timezone).format('YYYY-MM-DD HH:mm')}`;
180
+ }
181
+
182
+ function hasExpensiveColumns(rows: MemberImportRow[]): boolean {
183
+ return rows.some(row => EXPENSIVE_COLUMNS.some(column => !!row[column]));
184
+ }
185
+
186
+ // The routing decision, in terms it can reason about without knowing what a member
187
+ // or a Stripe customer is: an empty or small, cheap file imports inline (an empty
188
+ // one simply imports nothing); a large one, or one carrying expensive columns, is
189
+ // too slow to hold a request open, so it is deferred to a background job.
190
+ function canImportInline(rowCount: number, expensive: boolean, inlineThreshold: number): boolean {
191
+ return rowCount <= inlineThreshold && !expensive;
192
+ }
193
+
194
+ class MembersCSVImporter {
195
+ private _knex: Knex;
196
+ private _readRows: ReadRows;
197
+ private _spool: RowSpool;
198
+ private _members: MembersRepository;
199
+ private _tiers: TiersRepository;
200
+ private _stripe: StripeSubscriptions;
201
+ private _gifts: GiftService;
202
+ private _customFields: CustomFieldsImport;
203
+ private _email: EmailNotifications;
204
+ private _addJob: (job: {job: () => Promise<void>; offloaded: boolean; name: string}) => void;
205
+ private _getTimezone: () => string;
206
+ private _getInlineThreshold: () => number;
207
+
208
+ constructor({knex, readRows, spool, members, tiers, stripe, gifts, customFields, email, addJob, getTimezone, getInlineThreshold}: ImporterDeps) {
209
+ this._knex = knex;
210
+ this._readRows = readRows;
211
+ this._spool = spool;
212
+ this._members = members;
213
+ this._tiers = tiers;
214
+ this._stripe = stripe;
215
+ this._gifts = gifts;
216
+ this._customFields = customFields;
217
+ this._email = email;
218
+ this._addJob = addJob;
219
+ this._getTimezone = getTimezone;
220
+ this._getInlineThreshold = getInlineThreshold;
221
+ }
222
+
223
+ async importCSV(request: ImportRequest, verificationTrigger: VerificationTrigger): Promise<ImportOutcome> {
224
+ const rows = await this._readRows(request.filePath, request.mapping);
225
+ const labelName = buildImportLabelName(this._getTimezone());
226
+ const extraLabels = request.extraLabels ?? [];
227
+
228
+ if (canImportInline(rows.length, hasExpensiveColumns(rows), this._getInlineThreshold())) {
229
+ const result = await this.importRows(rows, labelName, extraLabels);
230
+ await verificationTrigger.testImportThreshold();
231
+ return {deferred: false, originalImportSize: rows.length, result};
232
+ }
233
+
234
+ await this.deferImport(rows, {labelName, extraLabels, requestUserEmail: request.requestUserEmail}, verificationTrigger);
235
+ return {deferred: true, originalImportSize: rows.length};
236
+ }
237
+
238
+ async importInline(request: ImportRequest, verificationTrigger: VerificationTrigger): Promise<ImportResult> {
239
+ const rows = await this._readRows(request.filePath, request.mapping);
240
+ const result = await this.importRows(rows, buildImportLabelName(this._getTimezone()), request.extraLabels ?? []);
241
+ await verificationTrigger.testImportThreshold();
242
+ return result;
243
+ }
244
+
245
+ private async deferImport(
246
+ rows: MemberImportRow[],
247
+ {labelName, extraLabels, requestUserEmail}: {labelName: string; extraLabels: Label[]; requestUserEmail: string | null},
248
+ verificationTrigger: VerificationTrigger
249
+ ): Promise<void> {
250
+ // Resolved here, not at the API boundary, so the owner lookup only runs when
251
+ // a request without a user actually reaches the deferred path.
252
+ const emailRecipient: string = requestUserEmail ?? await this._email.getDefaultRecipient();
253
+ const spooled = await this._spool.write(rows);
254
+
255
+ this._addJob({
256
+ job: () => this.runImportJob(spooled, {labelName, extraLabels, emailRecipient}, verificationTrigger),
257
+ offloaded: false,
258
+ name: 'members-import'
259
+ });
260
+ }
261
+
262
+ // Swallows its own errors: a failed import or email must not leave the queued job
263
+ // rejected, and the verification trigger must still run afterwards either way.
264
+ private async runImportJob(
265
+ spooled: SpooledRows,
266
+ {labelName, extraLabels, emailRecipient}: {labelName: string; extraLabels: Label[]; emailRecipient: string},
267
+ verificationTrigger: VerificationTrigger
268
+ ): Promise<void> {
269
+ try {
270
+ const spooledRows = await spooled.read();
271
+ const result = await this.importRows(spooledRows, labelName, extraLabels);
272
+ const importLabel = result.importLabel ?? null;
273
+ await this._email.send(buildCompletionEmail({
274
+ result,
275
+ recipient: emailRecipient,
276
+ labelName,
277
+ importLabel,
278
+ urlFor: this._email.urlFor
279
+ }));
280
+ } catch (e) {
281
+ logging.error('Error in members import job');
282
+ logging.error(e);
283
+ } finally {
284
+ await spooled.remove();
285
+ }
286
+
287
+ try {
288
+ await verificationTrigger.testImportThreshold();
289
+ } catch (e) {
290
+ logging.error('Error in members import job when testing import threshold');
291
+ logging.error(e);
292
+ }
293
+ }
294
+
295
+ // The members import kernel, shared by the inline and deferred paths. Each row is
296
+ // created or updated in its own transaction, so one row's failure rolls back only
297
+ // itself; failures are collected, and any Stripe prices an import tier created are
298
+ // archived at the end.
299
+ private async importRows(rows: MemberImportRow[], labelName: string, extraLabels: Label[]): Promise<ImportResult> {
300
+ const importLabel: Label = {name: labelName};
301
+ const globalLabels: Label[] = [importLabel, ...extraLabels];
302
+ const performStart = Date.now();
303
+ const defaultTier = await this._tiers.getDefault();
304
+ // The field set a custom_fields.* column is read against; empty when the feature
305
+ // is off, so a carried-through column is dropped and the write boundary stays shut.
306
+ const activeCustomFields = await this._customFields.activeFields();
307
+ const tierIdCache = new Map();
308
+ const archivableStripePriceIds: string[] = [];
309
+ // Copied per row: the member model stamps ids and trims names onto these in
310
+ // place, and each row runs in its own transaction that can roll back. A
311
+ // caller can hand in a nameless label, which the model would drop anyway.
312
+ const cloneGlobalLabels = (): Label[] => globalLabels
313
+ .map(label => ({...label}))
314
+ .filter(label => label.name);
315
+
316
+ let imported = 0;
317
+ const importErrors: ImportErrorRow[] = [];
318
+ for (const row of rows) {
319
+ let trx: Knex.Transaction | undefined;
320
+ try {
321
+ if (row.gift_id) {
322
+ if (row.import_tier) {
323
+ throw wrapGiftError(new errors.DataImportError({message: tpl(messages.giftCannotCombineWithImportTier)}));
324
+ }
325
+ if (row.complimentary_plan) {
326
+ throw wrapGiftError(new errors.DataImportError({message: tpl(messages.giftCannotCombineWithComplimentary)}));
327
+ }
328
+ }
329
+
330
+ // Validate the row's custom field values before the transaction opens.
331
+ // planWrite only reads, and it throws on an invalid value to fail the row
332
+ // before any member write -- so there is no reason to hold a transaction
333
+ // across it, and doing so would deadlock the single-connection SQLite pool.
334
+ const customFieldPlan = activeCustomFields.length > 0
335
+ ? await this._customFields.planWrite(fieldValuesFromCsvRow(activeCustomFields, row, stripFormulaGuard))
336
+ : [];
337
+
338
+ trx = await this._knex.transaction(undefined, {doNotRejectOnRollback: false});
339
+ const options = {transacting: trx, context: IMPORT_CONTEXT};
340
+
341
+ const createdAt = (row.created_at && moment(row.created_at).isAfter(moment())) ? moment().toDate() : row.created_at;
342
+ const memberValues: MemberImportValues = {
343
+ email: row.email,
344
+ name: row.name,
345
+ note: row.note,
346
+ subscribed: row.subscribed,
347
+ created_at: createdAt,
348
+ labels: [...row.labels, ...cloneGlobalLabels()]
349
+ };
350
+ const existingMember = row.email
351
+ ? await this._members.get({email: row.email}, {...options, withRelated: ['labels', 'newsletters']})
352
+ : null;
353
+ let member;
354
+ if (existingMember) {
355
+ const existingLabels = existingMember.related('labels').toJSON();
356
+ const existingNewsletters = existingMember.related('newsletters');
357
+ if (existingNewsletters.length > 0 && memberValues.subscribed) {
358
+ memberValues.newsletters = existingNewsletters.toJSON();
359
+ }
360
+ if (!existingNewsletters.length && memberValues.subscribed) {
361
+ memberValues.subscribed = false;
362
+ }
363
+ if (!row.name) {
364
+ memberValues.name = existingMember.name;
365
+ }
366
+ if (!row.note) {
367
+ memberValues.note = existingMember.note;
368
+ }
369
+ member = await this._members.update({
370
+ ...memberValues,
371
+ labels: existingLabels.concat(memberValues.labels)
372
+ }, {...options, id: existingMember.id});
373
+ } else {
374
+ member = await this._members.create(memberValues, Object.assign({}, options, {context: CREATE_CONTEXT}));
375
+ }
376
+
377
+ let importTierId;
378
+ if (row.import_tier) {
379
+ if (!tierIdCache.has(row.import_tier)) {
380
+ const tier = await this._tiers.getByName(row.import_tier);
381
+ tierIdCache.set(row.import_tier, tier ? tier.id.toString() : null);
382
+ }
383
+ importTierId = tierIdCache.get(row.import_tier);
384
+ if (!importTierId) {
385
+ throw new errors.DataImportError({message: tpl(messages.invalidImportTier, {tier: row.import_tier})});
386
+ }
387
+ }
388
+
389
+ if (row.stripe_customer_id) {
390
+ let stripeCustomerId;
391
+ if (row.stripe_customer_id.toLowerCase() === 'auto') {
392
+ stripeCustomerId = row.email ? await this._members.getCustomerIdByEmail(row.email) : undefined;
393
+ } else {
394
+ stripeCustomerId = row.stripe_customer_id;
395
+ }
396
+ if (stripeCustomerId) {
397
+ if (row.import_tier) {
398
+ const {isNewStripePrice, stripePriceId} = await this._stripe.forceStripeSubscriptionToProduct({
399
+ customer_id: stripeCustomerId,
400
+ product_id: importTierId
401
+ }, options);
402
+ if (isNewStripePrice) {
403
+ archivableStripePriceIds.push(stripePriceId);
404
+ }
405
+ }
406
+ await this._members.linkStripeCustomer({customer_id: stripeCustomerId, member_id: member.id}, options);
407
+ }
408
+ } else if (row.complimentary_plan) {
409
+ const products = [];
410
+ if (row.import_tier) {
411
+ products.push({id: importTierId});
412
+ } else {
413
+ products.push({id: defaultTier.id.toString()});
414
+ }
415
+ await this._members.update({products}, {...options, id: member.id});
416
+ } else if (row.import_tier) {
417
+ throw new errors.DataImportError({message: tpl(messages.freeMemberNotAllowedImportTier)});
418
+ }
419
+
420
+ if (row.gift_id) {
421
+ try {
422
+ await this._gifts.reassignRedeemer(row.gift_id, member.id, {transacting: trx});
423
+ } catch (giftError) {
424
+ throw wrapGiftError(giftError);
425
+ }
426
+ }
427
+
428
+ // On the row's transaction, so the values commit or roll back with the member.
429
+ await this._customFields.applyWrite(member.id, customFieldPlan, trx);
430
+
431
+ await trx.commit();
432
+ imported += 1;
433
+ } catch (error) {
434
+ const errorList: unknown[] = Array.isArray(error) ? error : [error];
435
+ const errorMessage = errorList
436
+ .map(e => (typeof e === 'object' && e !== null && 'message' in e ? e.message : undefined))
437
+ .join(', ');
438
+ // trx is unset if the row failed before the transaction opened (a bad
439
+ // custom field value or gift combination).
440
+ if (trx) {
441
+ await trx.rollback();
442
+ }
443
+ importErrors.push({...row, error: errorMessage});
444
+ }
445
+ }
446
+
447
+ await Promise.all(archivableStripePriceIds.map(id => this._stripe.archivePrice(id)));
448
+ metrics.metric('members-import', {imported, errors: importErrors.length, value: Date.now() - performStart});
449
+
450
+ if (imported > 0) {
451
+ // The import label exists now that members carry it, so fetch it to report.
452
+ // Guarded against a null lookup in case a row is ever counted without it.
453
+ const importLabelModel = await this._members.getImportLabel(labelName);
454
+ return {imported, errors: importErrors, importLabel: importLabelModel?.toJSON()};
455
+ }
456
+ return {imported: 0, errors: importErrors};
457
+ }
458
+ }
459
+
460
+ function wrapGiftError(error: unknown) {
461
+ const err = error as {message?: unknown};
462
+ const message = (err && typeof err.message === 'string' && err.message) || tpl(messages.giftReassignFailed);
463
+ return new errors.DataImportError({
464
+ message: `Member cannot be assigned to a gift: ${message}`
465
+ });
466
+ }
467
+
468
+ 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
+ }