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,202 @@
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.toExportCsvRow = toExportCsvRow;
7
+ const moment_1 = __importDefault(require("moment"));
8
+ const node_stream_1 = require("node:stream");
9
+ const logging = require('@tryghost/logging');
10
+ const errors = require('@tryghost/errors');
11
+ const { csvCellsForFields } = require('@tryghost/custom-field-types/csv');
12
+ // The columns the export reads off the members table, checked against MemberDbRow so the
13
+ // query cannot select a column the row type does not declare.
14
+ const MEMBER_COLUMNS = ['id', 'email', 'name', 'note', 'status', 'created_at'];
15
+ function namesToCsv(items) {
16
+ return items.map(item => item.name).join(',');
17
+ }
18
+ // Encode a flattened export row into its CSV cells, type-checked against MemberExportRow:
19
+ // a change to that row must be handled here or this stops compiling. deleted_at is always
20
+ // null -- the export reads live members -- and complimentary_plan is written from comped.
21
+ function toExportCsvRow(row) {
22
+ return {
23
+ id: row.id,
24
+ email: row.email,
25
+ name: row.name,
26
+ note: row.note,
27
+ subscribed_to_emails: row.subscribed === true ? 'true' : 'false',
28
+ complimentary_plan: row.comped === true ? 'true' : '',
29
+ stripe_customer_id: row.stripe_customer_id,
30
+ created_at: row.created_at,
31
+ deleted_at: null,
32
+ labels: namesToCsv(row.labels),
33
+ tiers: namesToCsv(row.tiers),
34
+ gift_id: row.gift_id || '',
35
+ ...row.custom_field_cells
36
+ };
37
+ }
38
+ const BATCH_SIZE = 1000;
39
+ // Streams the matching members as flat rows, each carrying its related data (tiers,
40
+ // labels, subscription, gift, Stripe customer, custom fields) for the output serializer
41
+ // to turn into CSV lines. The members aggregate and custom fields are reached only
42
+ // through the ports above, so nothing Bookshelf- or flag-shaped leaks into the export.
43
+ class MembersCSVExporter {
44
+ _knex;
45
+ _members;
46
+ _customFields;
47
+ constructor({ knex, members, customFields }) {
48
+ this._knex = knex;
49
+ this._members = members;
50
+ this._customFields = customFields;
51
+ }
52
+ async export(options = {}) {
53
+ const start = Date.now();
54
+ const hasFilter = options.limit !== 'all' || options.filter || options.search;
55
+ const ids = hasFilter ? await this._members.findFilteredIds(options) : null;
56
+ if (ids) {
57
+ logging.info(`[MembersExporter] Found ${ids.length} members matching filter criteria`);
58
+ }
59
+ const reference = await this.fetchReferenceData();
60
+ const membersQuery = this._knex('members').select(...MEMBER_COLUMNS);
61
+ if (ids) {
62
+ membersQuery.whereIn('id', ids);
63
+ }
64
+ logging.info('[MembersExporter] Starting streaming export of members');
65
+ const batchingTransform = this.createBatchingTransform();
66
+ const processingTransform = this.createProcessingTransform(reference);
67
+ (0, node_stream_1.pipeline)(membersQuery.stream(), batchingTransform, processingTransform, (err) => {
68
+ if (err) {
69
+ logging.error({ event: { name: 'members-export.stream.error' }, err }, 'Members export stream failed');
70
+ }
71
+ else {
72
+ logging.info('[MembersExporter] Total time taken for member export: ' + (Date.now() - start) / 1000 + 's');
73
+ }
74
+ });
75
+ return processingTransform;
76
+ }
77
+ // products and labels are small, stable tables, read once up front as id->name
78
+ // maps. Custom field definitions are read once too, which fixes the column set for
79
+ // the whole file: archiving a field mid-export cannot leave the header ragged. It
80
+ // can still leave that field's cells empty from the batch after the archive
81
+ // onwards, because the per-batch value read applies its own active filter.
82
+ async fetchReferenceData() {
83
+ const start = Date.now();
84
+ const allProducts = await this._knex('products').select('id', 'name').then(rows => rows.reduce((acc, product) => {
85
+ acc[product.id] = product.name;
86
+ return acc;
87
+ }, {}));
88
+ const allLabels = await this._knex('labels').select('id', 'name').then(rows => rows.reduce((acc, label) => {
89
+ acc[label.id] = label.name;
90
+ return acc;
91
+ }, {}));
92
+ logging.info('[MembersExporter] Fetched products and labels in ' + (Date.now() - start) + 'ms');
93
+ const activeCustomFields = await this._customFields.activeDefinitions();
94
+ return { allProducts, allLabels, activeCustomFields };
95
+ }
96
+ // Group the member stream into batches so the related-data reads are one query per
97
+ // batch rather than one per member.
98
+ createBatchingTransform() {
99
+ let currentBatch = [];
100
+ return new node_stream_1.Transform({
101
+ objectMode: true,
102
+ transform(member, encoding, callback) {
103
+ currentBatch.push(member);
104
+ if (currentBatch.length >= BATCH_SIZE) {
105
+ this.push(currentBatch);
106
+ currentBatch = [];
107
+ }
108
+ callback();
109
+ },
110
+ flush(callback) {
111
+ if (currentBatch.length > 0) {
112
+ this.push(currentBatch);
113
+ }
114
+ callback();
115
+ }
116
+ });
117
+ }
118
+ // Each flattened row is pushed individually so a batch's rows do not pile up in memory.
119
+ createProcessingTransform(reference) {
120
+ const assembleRelatedData = (memberIds) => this.assembleRelatedData(memberIds, reference.activeCustomFields);
121
+ const flattenBatch = (members, related) => this.flattenBatch(members, related, reference);
122
+ return new node_stream_1.Transform({
123
+ objectMode: true,
124
+ highWaterMark: BATCH_SIZE,
125
+ async transform(batch, encoding, callback) {
126
+ try {
127
+ const memberIds = batch.map(member => member.id);
128
+ const related = await assembleRelatedData(memberIds);
129
+ const rows = flattenBatch(batch, related);
130
+ rows.forEach(row => this.push(row));
131
+ callback();
132
+ }
133
+ catch (err) {
134
+ callback(err instanceof Error ? err : new errors.InternalServerError({ message: String(err) }));
135
+ }
136
+ }
137
+ });
138
+ }
139
+ // One query per related table for the whole batch, not one per member. Custom field
140
+ // values are read only when there are columns to fill.
141
+ async assembleRelatedData(memberIds, activeCustomFields) {
142
+ const knex = this._knex;
143
+ const [tiers, labels, stripeCustomers, subscriptions, gifts, customFieldValuesMap] = await Promise.all([
144
+ knex('members_products')
145
+ .select('member_id', knex.raw('GROUP_CONCAT(product_id) as tiers'))
146
+ .whereIn('member_id', memberIds)
147
+ .groupBy('member_id'),
148
+ knex('members_labels')
149
+ .select('member_id', knex.raw('GROUP_CONCAT(label_id) as labels'))
150
+ .whereIn('member_id', memberIds)
151
+ .groupBy('member_id'),
152
+ knex('members_stripe_customers')
153
+ .select('member_id', knex.raw('MIN(customer_id) as stripe_customer_id'))
154
+ .whereIn('member_id', memberIds)
155
+ .groupBy('member_id'),
156
+ knex('members_newsletters')
157
+ .distinct('member_id')
158
+ .whereIn('member_id', memberIds),
159
+ knex('gifts')
160
+ .select('id', 'redeemer_member_id')
161
+ .where('status', 'redeemed')
162
+ .whereIn('redeemer_member_id', memberIds),
163
+ activeCustomFields.length > 0
164
+ ? this._customFields.valuesForMembers(memberIds)
165
+ : new Map()
166
+ ]);
167
+ return {
168
+ tiersMap: new Map(tiers.map((row) => [row.member_id, row.tiers])),
169
+ labelsMap: new Map(labels.map((row) => [row.member_id, row.labels])),
170
+ stripeCustomerMap: new Map(stripeCustomers.map((row) => [row.member_id, row.stripe_customer_id])),
171
+ subscribedSet: new Set(subscriptions.map((row) => row.member_id)),
172
+ giftIdMap: new Map(gifts.map((row) => [row.redeemer_member_id, row.id])),
173
+ customFieldValuesMap
174
+ };
175
+ }
176
+ flattenBatch(members, related, reference) {
177
+ const { tiersMap, labelsMap, stripeCustomerMap, subscribedSet, giftIdMap, customFieldValuesMap } = related;
178
+ const { allProducts, allLabels, activeCustomFields } = reference;
179
+ return members.map((row) => {
180
+ const tierConcat = tiersMap.get(row.id);
181
+ const tierIds = tierConcat ? tierConcat.split(',') : [];
182
+ const labelConcat = labelsMap.get(row.id);
183
+ const labelIds = labelConcat ? labelConcat.split(',') : [];
184
+ return {
185
+ ...row,
186
+ subscribed: subscribedSet.has(row.id),
187
+ comped: row.status === 'comped',
188
+ gift_id: giftIdMap.get(row.id) || null,
189
+ stripe_customer_id: stripeCustomerMap.get(row.id) || null,
190
+ created_at: (0, moment_1.default)(row.created_at).toISOString(),
191
+ tiers: tierIds.map(id => ({ name: allProducts[id] })),
192
+ labels: labelIds.map(id => ({ name: allLabels[id] })),
193
+ // Flattened here rather than in the serializer because the column set is
194
+ // only knowable from the database. Every member carries a cell for every
195
+ // active field's column, so a member with no values still contributes the
196
+ // full set -- the CSV header is taken from whichever row streams first.
197
+ custom_field_cells: csvCellsForFields(activeCustomFields, customFieldValuesMap.get(row.id) || {})
198
+ };
199
+ });
200
+ }
201
+ }
202
+ exports.default = MembersCSVExporter;
@@ -0,0 +1,325 @@
1
+ import moment from 'moment';
2
+ import {Transform, pipeline, type Readable} from 'node:stream';
3
+ import type {Knex} from 'knex';
4
+
5
+ const logging = require('@tryghost/logging');
6
+ const errors = require('@tryghost/errors');
7
+ const {csvCellsForFields} = require('@tryghost/custom-field-types/csv');
8
+
9
+ // Options accepted by the export, forwarded to the members query for filtering.
10
+ export interface ExportOptions {
11
+ limit?: number | 'all';
12
+ filter?: string;
13
+ search?: string;
14
+ [option: string]: unknown;
15
+ }
16
+
17
+ // A custom field definition, opaque to the exporter: it only counts the set and hands
18
+ // it to the values codec, which decides the columns. Named for intent; its shape is
19
+ // the custom fields service's concern, not this one's.
20
+ export type CustomFieldDefinition = Record<string, unknown>;
21
+
22
+ // The custom fields collaborator: the active column set for this export, and the
23
+ // per-member values that fill those columns. Both resolve empty when the feature is
24
+ // off, so the exporter carries no flag of its own.
25
+ export interface CustomFieldsService {
26
+ activeDefinitions(): Promise<CustomFieldDefinition[]>;
27
+ valuesForMembers(memberIds: string[]): Promise<Map<string, Record<string, unknown>>>;
28
+ }
29
+
30
+ // The members aggregate, narrowed to the one read the export makes: the ids matching
31
+ // a filter, resolved without any of the related data the stream fetches itself.
32
+ export interface MembersRepository {
33
+ findFilteredIds(options: ExportOptions): Promise<string[]>;
34
+ }
35
+
36
+ export interface ExporterDeps {
37
+ knex: Knex;
38
+ members: MembersRepository;
39
+ customFields: CustomFieldsService;
40
+ }
41
+
42
+ interface BatchRelatedData {
43
+ tiersMap: Map<string, string>;
44
+ labelsMap: Map<string, string>;
45
+ stripeCustomerMap: Map<string, string>;
46
+ subscribedSet: Set<string>;
47
+ giftIdMap: Map<string, string>;
48
+ customFieldValuesMap: Map<string, Record<string, unknown>>;
49
+ }
50
+
51
+ // The reference data read once up front and shared across every batch.
52
+ interface ReferenceData {
53
+ allProducts: Record<string, string>;
54
+ allLabels: Record<string, string>;
55
+ activeCustomFields: CustomFieldDefinition[];
56
+ }
57
+
58
+ // A member row as the export query selects it -- the shape the stream emits, and the
59
+ // contract flattenBatch is total over. Its value types are declared here (knex streams
60
+ // rows untyped); a change to the members table that this export should carry is made by
61
+ // changing this type, which then ripples into flattenBatch until every field is handled.
62
+ // The end-to-end e2e export test is what catches the table drifting from this shape.
63
+ interface MemberDbRow {
64
+ id: string;
65
+ email: string;
66
+ name: string | null;
67
+ note: string | null;
68
+ status: string;
69
+ created_at: Date | string;
70
+ }
71
+
72
+ // The columns the export reads off the members table, checked against MemberDbRow so the
73
+ // query cannot select a column the row type does not declare.
74
+ const MEMBER_COLUMNS = ['id', 'email', 'name', 'note', 'status', 'created_at'] satisfies Array<keyof MemberDbRow>;
75
+
76
+ // The db row with its related data resolved onto it. custom_field_cells later spreads
77
+ // into one column per active custom field.
78
+ interface MemberExportRow extends MemberDbRow {
79
+ created_at: string;
80
+ tiers: Array<{name: string}>;
81
+ labels: Array<{name: string}>;
82
+ subscribed: boolean;
83
+ comped: boolean;
84
+ gift_id: string | null;
85
+ stripe_customer_id: string | null;
86
+ custom_field_cells: Record<string, unknown>;
87
+ }
88
+
89
+ // One row of the members export CSV: a flattened export row encoded into cells. This
90
+ // type is the export's column contract -- the output serializer takes the columns from
91
+ // these keys. The fixed member columns are typed; custom_field_cells spreads in whatever
92
+ // per-site columns the database holds, so the row also carries an open string index.
93
+ type ExportCsvRow = {
94
+ id: string;
95
+ email: string;
96
+ name: string | null;
97
+ note: string | null;
98
+ subscribed_to_emails: string;
99
+ complimentary_plan: string;
100
+ stripe_customer_id: string | null;
101
+ created_at: string;
102
+ deleted_at: null;
103
+ labels: string;
104
+ tiers: string;
105
+ gift_id: string;
106
+ } & Record<string, unknown>;
107
+
108
+ function namesToCsv(items: Array<{name: string}>): string {
109
+ return items.map(item => item.name).join(',');
110
+ }
111
+
112
+ // Encode a flattened export row into its CSV cells, type-checked against MemberExportRow:
113
+ // a change to that row must be handled here or this stops compiling. deleted_at is always
114
+ // null -- the export reads live members -- and complimentary_plan is written from comped.
115
+ export function toExportCsvRow(row: MemberExportRow): ExportCsvRow {
116
+ return {
117
+ id: row.id,
118
+ email: row.email,
119
+ name: row.name,
120
+ note: row.note,
121
+ subscribed_to_emails: row.subscribed === true ? 'true' : 'false',
122
+ complimentary_plan: row.comped === true ? 'true' : '',
123
+ stripe_customer_id: row.stripe_customer_id,
124
+ created_at: row.created_at,
125
+ deleted_at: null,
126
+ labels: namesToCsv(row.labels),
127
+ tiers: namesToCsv(row.tiers),
128
+ gift_id: row.gift_id || '',
129
+ ...row.custom_field_cells
130
+ };
131
+ }
132
+
133
+ const BATCH_SIZE = 1000;
134
+
135
+ // Streams the matching members as flat rows, each carrying its related data (tiers,
136
+ // labels, subscription, gift, Stripe customer, custom fields) for the output serializer
137
+ // to turn into CSV lines. The members aggregate and custom fields are reached only
138
+ // through the ports above, so nothing Bookshelf- or flag-shaped leaks into the export.
139
+ export default class MembersCSVExporter {
140
+ private _knex: Knex;
141
+ private _members: MembersRepository;
142
+ private _customFields: CustomFieldsService;
143
+
144
+ constructor({knex, members, customFields}: ExporterDeps) {
145
+ this._knex = knex;
146
+ this._members = members;
147
+ this._customFields = customFields;
148
+ }
149
+
150
+ async export(options: ExportOptions = {}): Promise<Readable> {
151
+ const start = Date.now();
152
+ const hasFilter = options.limit !== 'all' || options.filter || options.search;
153
+ const ids = hasFilter ? await this._members.findFilteredIds(options) : null;
154
+ if (ids) {
155
+ logging.info(`[MembersExporter] Found ${ids.length} members matching filter criteria`);
156
+ }
157
+
158
+ const reference = await this.fetchReferenceData();
159
+
160
+ const membersQuery = this._knex('members').select(...MEMBER_COLUMNS);
161
+ if (ids) {
162
+ membersQuery.whereIn('id', ids);
163
+ }
164
+
165
+ logging.info('[MembersExporter] Starting streaming export of members');
166
+ const batchingTransform = this.createBatchingTransform();
167
+ const processingTransform = this.createProcessingTransform(reference);
168
+
169
+ pipeline(membersQuery.stream(), batchingTransform, processingTransform, (err) => {
170
+ if (err) {
171
+ logging.error({event: {name: 'members-export.stream.error'}, err}, 'Members export stream failed');
172
+ } else {
173
+ logging.info('[MembersExporter] Total time taken for member export: ' + (Date.now() - start) / 1000 + 's');
174
+ }
175
+ });
176
+
177
+ return processingTransform;
178
+ }
179
+
180
+ // products and labels are small, stable tables, read once up front as id->name
181
+ // maps. Custom field definitions are read once too, which fixes the column set for
182
+ // the whole file: archiving a field mid-export cannot leave the header ragged. It
183
+ // can still leave that field's cells empty from the batch after the archive
184
+ // onwards, because the per-batch value read applies its own active filter.
185
+ private async fetchReferenceData(): Promise<ReferenceData> {
186
+ const start = Date.now();
187
+
188
+ const allProducts = await this._knex('products').select('id', 'name').then(rows => rows.reduce((acc: Record<string, string>, product: {id: string; name: string}) => {
189
+ acc[product.id] = product.name;
190
+ return acc;
191
+ }, {}));
192
+
193
+ const allLabels = await this._knex('labels').select('id', 'name').then(rows => rows.reduce((acc: Record<string, string>, label: {id: string; name: string}) => {
194
+ acc[label.id] = label.name;
195
+ return acc;
196
+ }, {}));
197
+
198
+ logging.info('[MembersExporter] Fetched products and labels in ' + (Date.now() - start) + 'ms');
199
+
200
+ const activeCustomFields = await this._customFields.activeDefinitions();
201
+
202
+ return {allProducts, allLabels, activeCustomFields};
203
+ }
204
+
205
+ // Group the member stream into batches so the related-data reads are one query per
206
+ // batch rather than one per member.
207
+ private createBatchingTransform(): Transform {
208
+ let currentBatch: MemberDbRow[] = [];
209
+
210
+ return new Transform({
211
+ objectMode: true,
212
+ transform(member: MemberDbRow, encoding, callback) {
213
+ currentBatch.push(member);
214
+
215
+ if (currentBatch.length >= BATCH_SIZE) {
216
+ this.push(currentBatch);
217
+ currentBatch = [];
218
+ }
219
+
220
+ callback();
221
+ },
222
+ flush(callback) {
223
+ if (currentBatch.length > 0) {
224
+ this.push(currentBatch);
225
+ }
226
+ callback();
227
+ }
228
+ });
229
+ }
230
+
231
+ // Each flattened row is pushed individually so a batch's rows do not pile up in memory.
232
+ private createProcessingTransform(reference: ReferenceData): Transform {
233
+ const assembleRelatedData = (memberIds: string[]) => this.assembleRelatedData(memberIds, reference.activeCustomFields);
234
+ const flattenBatch = (members: MemberDbRow[], related: BatchRelatedData) => this.flattenBatch(members, related, reference);
235
+
236
+ return new Transform({
237
+ objectMode: true,
238
+ highWaterMark: BATCH_SIZE,
239
+ async transform(batch: MemberDbRow[], encoding, callback) {
240
+ try {
241
+ const memberIds = batch.map(member => member.id);
242
+ const related = await assembleRelatedData(memberIds);
243
+ const rows = flattenBatch(batch, related);
244
+ rows.forEach(row => this.push(row));
245
+ callback();
246
+ } catch (err) {
247
+ callback(err instanceof Error ? err : new errors.InternalServerError({message: String(err)}));
248
+ }
249
+ }
250
+ });
251
+ }
252
+
253
+ // One query per related table for the whole batch, not one per member. Custom field
254
+ // values are read only when there are columns to fill.
255
+ private async assembleRelatedData(memberIds: string[], activeCustomFields: CustomFieldDefinition[]): Promise<BatchRelatedData> {
256
+ const knex = this._knex;
257
+
258
+ const [tiers, labels, stripeCustomers, subscriptions, gifts, customFieldValuesMap] = await Promise.all([
259
+ knex('members_products')
260
+ .select('member_id', knex.raw('GROUP_CONCAT(product_id) as tiers'))
261
+ .whereIn('member_id', memberIds)
262
+ .groupBy('member_id'),
263
+
264
+ knex('members_labels')
265
+ .select('member_id', knex.raw('GROUP_CONCAT(label_id) as labels'))
266
+ .whereIn('member_id', memberIds)
267
+ .groupBy('member_id'),
268
+
269
+ knex('members_stripe_customers')
270
+ .select('member_id', knex.raw('MIN(customer_id) as stripe_customer_id'))
271
+ .whereIn('member_id', memberIds)
272
+ .groupBy('member_id'),
273
+
274
+ knex('members_newsletters')
275
+ .distinct('member_id')
276
+ .whereIn('member_id', memberIds),
277
+
278
+ knex('gifts')
279
+ .select('id', 'redeemer_member_id')
280
+ .where('status', 'redeemed')
281
+ .whereIn('redeemer_member_id', memberIds),
282
+
283
+ activeCustomFields.length > 0
284
+ ? this._customFields.valuesForMembers(memberIds)
285
+ : new Map<string, Record<string, unknown>>()
286
+ ]);
287
+
288
+ return {
289
+ tiersMap: new Map(tiers.map((row: {member_id: string; tiers: string}) => [row.member_id, row.tiers])),
290
+ labelsMap: new Map(labels.map((row: {member_id: string; labels: string}) => [row.member_id, row.labels])),
291
+ stripeCustomerMap: new Map(stripeCustomers.map((row: {member_id: string; stripe_customer_id: string}) => [row.member_id, row.stripe_customer_id])),
292
+ subscribedSet: new Set(subscriptions.map((row: {member_id: string}) => row.member_id)),
293
+ giftIdMap: new Map(gifts.map((row: {id: string; redeemer_member_id: string}) => [row.redeemer_member_id, row.id])),
294
+ customFieldValuesMap
295
+ };
296
+ }
297
+
298
+ private flattenBatch(members: MemberDbRow[], related: BatchRelatedData, reference: ReferenceData): MemberExportRow[] {
299
+ const {tiersMap, labelsMap, stripeCustomerMap, subscribedSet, giftIdMap, customFieldValuesMap} = related;
300
+ const {allProducts, allLabels, activeCustomFields} = reference;
301
+
302
+ return members.map((row) => {
303
+ const tierConcat = tiersMap.get(row.id);
304
+ const tierIds = tierConcat ? tierConcat.split(',') : [];
305
+ const labelConcat = labelsMap.get(row.id);
306
+ const labelIds = labelConcat ? labelConcat.split(',') : [];
307
+
308
+ return {
309
+ ...row,
310
+ subscribed: subscribedSet.has(row.id),
311
+ comped: row.status === 'comped',
312
+ gift_id: giftIdMap.get(row.id) || null,
313
+ stripe_customer_id: stripeCustomerMap.get(row.id) || null,
314
+ created_at: moment(row.created_at).toISOString(),
315
+ tiers: tierIds.map(id => ({name: allProducts[id]})),
316
+ labels: labelIds.map(id => ({name: allLabels[id]})),
317
+ // Flattened here rather than in the serializer because the column set is
318
+ // only knowable from the database. Every member carries a cell for every
319
+ // active field's column, so a member with no values still contributes the
320
+ // full set -- the CSV header is taken from whichever row streams first.
321
+ custom_field_cells: csvCellsForFields(activeCustomFields, customFieldValuesMap.get(row.id) || {})
322
+ };
323
+ });
324
+ }
325
+ }
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.default = buildCompletionEmail;
4
+ const csv_1 = require("../csv");
5
+ const csv_2 = require("@tryghost/custom-field-types/csv");
6
+ const emailTemplate = require('./email-template');
7
+ // Turn an ORM validation error into copy a member manager can act on. Presentation
8
+ // only: the API response keeps the raw messages, and just the emailed report is
9
+ // rewritten.
10
+ function humaniseError(message) {
11
+ return message
12
+ .replace('Value in [members.email] cannot be blank.', 'Missing email address')
13
+ .replace('Value in [members.note] exceeds maximum length of 2000 characters.', '"Note" exceeds maximum length of 2000 characters')
14
+ .replace('Value in [members.subscribed] must be one of true, false, 0 or 1.', 'Value in "Subscribed to emails" must be "true" or "false"')
15
+ .replace('Validation (isEmail) failed for email', 'Invalid email address')
16
+ .replace(/No such customer:[^,]*/, 'Could not find Stripe customer');
17
+ }
18
+ function stringifyLabels(labels) {
19
+ return labels.map(label => (typeof label === 'string' ? label : label.name)).join(',');
20
+ }
21
+ // The custom_fields.* cells a submitted row carried, echoed untouched so a manager can
22
+ // fix a failed row and re-upload the values they mapped.
23
+ function customFieldCells(row) {
24
+ return Object.fromEntries(Object.entries(row).filter(([column]) => (0, csv_2.isCustomFieldColumn)(column)));
25
+ }
26
+ // Shape a failed import row into its fixed error-report cells, with the raw ORM message
27
+ // rewritten into copy the member manager can act on. Custom field cells are merged on by
28
+ // buildErrorReport, which owns the dynamic column set.
29
+ function toErrorReportRow(row) {
30
+ return {
31
+ id: row.id,
32
+ email: row.email,
33
+ name: row.name,
34
+ note: row.note,
35
+ subscribed_to_emails: row.subscribed,
36
+ complimentary_plan: row.complimentary_plan,
37
+ stripe_customer_id: row.stripe_customer_id,
38
+ created_at: row.created_at,
39
+ deleted_at: undefined,
40
+ labels: stringifyLabels(row.labels),
41
+ tiers: '',
42
+ gift_id: row.gift_id || null,
43
+ error: humaniseError(row.error)
44
+ };
45
+ }
46
+ // The error report attached to the completion email: the failed rows as CSV. It shares
47
+ // the serialiser with the export but not the shaping -- the export writes db members,
48
+ // this echoes submitted rows. Member columns come from the shaper's keys (so the type
49
+ // stays the single source); the custom_fields.* columns across the rows are threaded in
50
+ // before the last error column, and each row's custom cells merged on.
51
+ function buildErrorReport(errors) {
52
+ if (errors.length === 0) {
53
+ return (0, csv_1.serialize)([]);
54
+ }
55
+ const memberColumns = Object.keys(toErrorReportRow(errors[0])).filter(column => column !== 'error');
56
+ const customColumns = [...new Set(errors.flatMap(row => Object.keys(customFieldCells(row))))];
57
+ const columns = [...memberColumns, ...customColumns, 'error'];
58
+ const rows = errors.map(row => ({ ...toErrorReportRow(row), ...customFieldCells(row) }));
59
+ return (0, csv_1.serialize)(rows, { columns });
60
+ }
61
+ // Compose the completion email for a finished import: the summary and its links,
62
+ // plus the attached error report. Owns how the outcome is presented, so the
63
+ // importer yields only the result and never touches email or CSV formatting.
64
+ function buildCompletionEmail({ result, recipient, labelName, importLabel, urlFor }) {
65
+ const siteUrl = new URL(urlFor('home', null, true));
66
+ const membersUrl = new URL('members', urlFor('admin', null, true));
67
+ if (importLabel) {
68
+ membersUrl.searchParams.set('label', importLabel.slug);
69
+ }
70
+ const html = emailTemplate({ result, siteUrl, membersUrl, emailRecipient: recipient, importLabel });
71
+ const subject = result.imported > 0 ? 'Your member import is complete' : 'Your member import was unsuccessful';
72
+ return {
73
+ to: recipient,
74
+ subject,
75
+ html,
76
+ forceTextContent: true,
77
+ attachments: [{
78
+ filename: `${labelName} - Errors.csv`,
79
+ content: buildErrorReport(result.errors),
80
+ contentType: 'text/csv',
81
+ contentDisposition: 'attachment'
82
+ }]
83
+ };
84
+ }