ghost 6.54.1 → 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 (247) hide show
  1. package/components/tryghost-adapter-base-scheduling-0.2.0.tgz +0 -0
  2. package/components/tryghost-custom-field-types-0.0.0.tgz +0 -0
  3. package/components/tryghost-kg-default-cards-10.3.3.tgz +0 -0
  4. package/components/tryghost-kg-default-nodes-2.1.3.tgz +0 -0
  5. package/core/boot.js +3 -6
  6. package/core/built/admin/assets/{account-migration-BklF2I_I.js → account-migration-sjHLx5dy.js} +1 -1
  7. package/core/built/admin/assets/activitypub/{account-migration-CNovr6W-.js → account-migration-DoYXtnZ8.js} +4 -4
  8. package/core/built/admin/assets/activitypub/activitypub.js +1 -1
  9. package/core/built/admin/assets/activitypub/{bluesky-sharing-Ceslb1_M.js → bluesky-sharing-Bxeh6MKW.js} +4 -4
  10. package/core/built/admin/assets/activitypub/{dist-DHAjBcV3.js → dist-D8sFfdpC.js} +2 -2
  11. package/core/built/admin/assets/activitypub/{domain-C1qQvJ8C.js → domain-CWueFW6q.js} +5 -5
  12. package/core/built/admin/assets/activitypub/{edit-profile-BcH-jEfM.js → edit-profile-BGxPU7N5.js} +3 -3
  13. package/core/built/admin/assets/activitypub/{explore-AxwBh9dI.js → explore-DBK-u0Ch.js} +3 -3
  14. package/core/built/admin/assets/activitypub/{feed-BaOlbh7a.js → feed-Bcir5RQZ.js} +3 -3
  15. package/core/built/admin/assets/activitypub/{field-B7mYYQpN.js → field-BV-CIT3d.js} +2 -2
  16. package/core/built/admin/assets/activitypub/{inbox-vXez-5uJ.js → inbox-yC0nfvzl.js} +4 -4
  17. package/core/built/admin/assets/activitypub/{label-Chv5T8bw.js → label-DTRS9TDk.js} +2 -2
  18. package/core/built/admin/assets/activitypub/layout-qx-bUL1k.js +1 -0
  19. package/core/built/admin/assets/activitypub/{moderation-DtSM2i1I.js → moderation-Dwl9t0pb.js} +4 -4
  20. package/core/built/admin/assets/activitypub/{note-Dckp4S-2.js → note-B-zKsZ7r.js} +3 -3
  21. package/core/built/admin/assets/activitypub/{notifications-CG5T22vg.js → notifications-DjJZD54t.js} +3 -3
  22. package/core/built/admin/assets/activitypub/{preferences-D_799jdl.js → preferences-CoU8vs6d.js} +6 -6
  23. package/core/built/admin/assets/activitypub/{profile-D2JfXBQl.js → profile-BAsaJekx.js} +6 -6
  24. package/core/built/admin/assets/activitypub/{routes-CCHe2dQp.js → routes-CLH3OVTP.js} +18 -18
  25. package/core/built/admin/assets/activitypub/{settings-kx-9Enkz.js → settings-DA6pNJ-Q.js} +3 -3
  26. package/core/built/admin/assets/activitypub/{site-x4_IjVJ0.js → site-CQD42Pw5.js} +2 -2
  27. package/core/built/admin/assets/activitypub/{tabs-CyPRQQ1N.js → tabs-Cg0saow1.js} +7 -7
  28. package/core/built/admin/assets/activitypub/{use-reply-chain-data-BAB6fNGt.js → use-reply-chain-data-Br4aOWOh.js} +2 -2
  29. package/core/built/admin/assets/audience-DjEWNhp3.js +1 -0
  30. package/core/built/admin/assets/{automated-emails-of1MvVsv.js → automated-emails-DRy7qY-J.js} +2 -2
  31. package/core/built/admin/assets/{automations-DRFjW5fw.js → automations-Bx3VcFIL.js} +1 -1
  32. package/core/built/admin/assets/{automations-B7-y3dXt.js → automations-DhKeS_U5.js} +1 -1
  33. package/core/built/admin/assets/{bluesky-sharing-BbbjWdzs.js → bluesky-sharing-ClMd0-Ft.js} +1 -1
  34. package/core/built/admin/assets/{breadcrumb-B-M9Ip_2.js → breadcrumb-BD0U2ygg.js} +1 -1
  35. package/core/built/admin/assets/{chart-helpers-xiSsW8r0.js → chart-helpers-fjQKRVyY.js} +1 -1
  36. package/core/built/admin/assets/{checkbox-EhJKHFdZ.js → checkbox-CWa28lgO.js} +1 -1
  37. package/core/built/admin/assets/{chunk.361.3cdb584b973959e52ccc.js → chunk.481.6c7395a49484bb4583aa.js} +41 -38
  38. package/core/built/admin/assets/{chunk.524.8365cd9280405c0f6216.js → chunk.524.cffd399783a519e404a9.js} +4 -4
  39. package/core/built/admin/assets/{chunk.582.61d078937e6102769f2c.js → chunk.582.eebf0d0b2d202bb746a5.js} +6 -6
  40. package/core/built/admin/assets/{code-editor-view-CZtJFGhR.js → code-editor-view-CCr0rBWS.js} +1 -1
  41. package/core/built/admin/assets/comments-DyUSMUdj.js +1 -0
  42. package/core/built/admin/assets/{date-range-select-CTlyo8zW.js → date-range-select-BFp58EMk.js} +1 -1
  43. package/core/built/admin/assets/{disabled-sources-indicator-qs2GfLvl.js → disabled-sources-indicator-CUxMfiPa.js} +1 -1
  44. package/core/built/admin/assets/domain-C0bV2UV8.js +1 -0
  45. package/core/built/admin/assets/{dropzone-BmCXnw6J.js → dropzone-DlK-K2DQ.js} +1 -1
  46. package/core/built/admin/assets/{edit-profile-SGrJve5M.js → edit-profile-BKT-REJw.js} +1 -1
  47. package/core/built/admin/assets/editor-Dp865O-4.js +8 -0
  48. package/core/built/admin/assets/{explore-CUYLv9G9.js → explore-ClUpJZOp.js} +1 -1
  49. package/core/built/admin/assets/{feed-CPdJMttL.js → feed-DF92dUMP.js} +1 -1
  50. package/core/built/admin/assets/field-Do7lieBA.js +1 -0
  51. package/core/built/admin/assets/{filter-sources-Bieps75g.js → filter-sources-BV2rnpfA.js} +1 -1
  52. package/core/built/admin/assets/filters-CUXJ2Nm9.js +1 -0
  53. package/core/built/admin/assets/{filters-CuB-A7ma.js → filters-DfbIWc_l.js} +1 -1
  54. package/core/built/admin/assets/{ghost-3063eae29e6c9cc6d6e7131928a27da0.css → ghost-a9c788f6f0b1ea6cf0f14ca4b13f4496.css} +1 -1
  55. package/core/built/admin/assets/{ghost-05f9a7182ed56903dba05878a5b8422b.js → ghost-c4057474943effc568c5659ccc4909d7.js} +190 -190
  56. package/core/built/admin/assets/{ghost-dark-c8d0ab7f515b2010c173e23e9b32ea8a.css → ghost-dark-db36d795d21c61426837df40af270966.css} +1 -1
  57. package/core/built/admin/assets/gift-link-modal-f9VIMNDv.js +1 -0
  58. package/core/built/admin/assets/{gift-links-nBxJjZiQ.js → gift-links-CsUhefWG.js} +1 -1
  59. package/core/built/admin/assets/growth-B3I6gNLm.js +1 -0
  60. package/core/built/admin/assets/growth-CVw1My4S.js +3 -0
  61. package/core/built/admin/assets/{hooks-BApwwBE0.js → hooks-CixO01lL.js} +1 -1
  62. package/core/built/admin/assets/{inbox-Xx1CdnfO.js → inbox-CFBSu4Ed.js} +13 -13
  63. package/core/built/admin/assets/index-9Cw8z3rA.css +1 -0
  64. package/core/built/admin/assets/index-BGG7NckO.js +67 -0
  65. package/core/built/admin/assets/{input-group-CGOXcy4l.js → input-group-BSgG_lVw.js} +1 -1
  66. package/core/built/admin/assets/koenig-lexical/koenig-lexical.js +974 -937
  67. package/core/built/admin/assets/koenig-lexical/koenig-lexical.umd.js +28 -28
  68. package/core/built/admin/assets/koenig-lexical/style.css +1 -1
  69. package/core/built/admin/assets/{koenig-lexical-B61IALf5.js → koenig-lexical-f8vHRYvz.js} +28 -28
  70. package/core/built/admin/assets/{kpi-card-DCdlyaSS.js → kpi-card-DSap9rAy.js} +1 -1
  71. package/core/built/admin/assets/{kpi-card-BlZaG1Lo.js → kpi-card-DU0rn9Cj.js} +1 -1
  72. package/core/built/admin/assets/{kpi-helpers-DpAuS1Nb.js → kpi-helpers-Dz33AV6h.js} +1 -1
  73. package/core/built/admin/assets/{kpi-tabs-Dme44bQS.js → kpi-tabs-BtTTrKvJ.js} +1 -1
  74. package/core/built/admin/assets/{label-dSs5aepE.js → label-DS8Mi6G5.js} +1 -1
  75. package/core/built/admin/assets/label-picker-1DvSuLA1.js +1 -0
  76. package/core/built/admin/assets/labels-CDSUt4J_.js +1 -0
  77. package/core/built/admin/assets/layout-yQEi2qDM.js +1 -0
  78. package/core/built/admin/assets/{link-helpers-tOlxmuH5.js → link-helpers-7EYqvvHs.js} +1 -1
  79. package/core/built/admin/assets/member-custom-fields-yOMHyAg9.js +1 -0
  80. package/core/built/admin/assets/member-detail-BAq7gDaa.js +1 -0
  81. package/core/built/admin/assets/members-CQpVFJu4.js +10 -0
  82. package/core/built/admin/assets/modals-ReORvWnu.js +31 -0
  83. package/core/built/admin/assets/moderation-DySbgyUS.js +1 -0
  84. package/core/built/admin/assets/newsletter-CJQvtHqN.js +1 -0
  85. package/core/built/admin/assets/newsletters-B3QQXfGT.js +1 -0
  86. package/core/built/admin/assets/{newsletters-D1jbtV-h.js → newsletters-BXJbJgA-.js} +1 -1
  87. package/core/built/admin/assets/note-C8kxrz6w.js +1 -0
  88. package/core/built/admin/assets/notifications-CgtSfX7t.js +1 -0
  89. package/core/built/admin/assets/{offers-CrY6nSRR.js → offers-DEniVRD5.js} +1 -1
  90. package/core/built/admin/assets/onboarding-route-4qtziA9d.js +1 -0
  91. package/core/built/admin/assets/overview-Bf2sbznq.js +1 -0
  92. package/core/built/admin/assets/overview-CBfePxcr.js +1 -0
  93. package/core/built/admin/assets/{page-header-op0eN36I.js → page-header-B3Wb9Jqv.js} +1 -1
  94. package/core/built/admin/assets/{pages-BbNWFcGj.js → pages-B4goRPUn.js} +1 -1
  95. package/core/built/admin/assets/{post-analytics-WwRDEnrV.js → post-analytics-We-ko5Cb.js} +1 -1
  96. package/core/built/admin/assets/post-analytics-header-Bmc9su6q.js +1 -0
  97. package/core/built/admin/assets/post-analytics-provider-B6UynTGb.js +1 -0
  98. package/core/built/admin/assets/{post-share-modal-Dx-MEjhI.js → post-share-modal-DFfO93_0.js} +1 -1
  99. package/core/built/admin/assets/{posts-EWpZIjkr.js → posts-DhXE7b6r.js} +1 -1
  100. package/core/built/admin/assets/preferences-DJPfHEGu.js +5 -0
  101. package/core/built/admin/assets/profile-DqBQp-gT.js +1 -0
  102. package/core/built/admin/assets/{referrers-BCfFYSHc.js → referrers-pmJT4k3-.js} +1 -1
  103. package/core/built/admin/assets/{select-C1qDZnW4.js → select-C5_xEt5b.js} +1 -1
  104. package/core/built/admin/assets/settings-BwiVIXau.js +4 -0
  105. package/core/built/admin/assets/{settings-H6HxFgfI.js → settings-WXNeWyU1.js} +1 -1
  106. package/core/built/admin/assets/{share-modal-BJCadJyp.js → share-modal-DsgW3wbg.js} +1 -1
  107. package/core/built/admin/assets/social-urls-BQPFEk1_.js +8 -0
  108. package/core/built/admin/assets/{sort-button-DyYlfYp7.js → sort-button-Bb_FCl7e.js} +1 -1
  109. package/core/built/admin/assets/{source-icon-_ubRDbg_.js → source-icon-BeaRxUn9.js} +1 -1
  110. package/core/built/admin/assets/{stats-BQZMGMvn.js → stats-Bjj_kmJ1.js} +1 -1
  111. package/core/built/admin/assets/{stats-view-Doei-qCX.js → stats-view-BxPbW6K1.js} +1 -1
  112. package/core/built/admin/assets/{step-1-IKiLBPoC.js → step-1-B1e3-1F8.js} +1 -1
  113. package/core/built/admin/assets/{step-2-jJ41PsDN.js → step-2-C5WaeBJo.js} +1 -1
  114. package/core/built/admin/assets/{step-3-CaqFAEqf.js → step-3-B46iVPwu.js} +2 -2
  115. package/core/built/admin/assets/{switch-t-WyENU2.js → switch-z5Y6Uzt6.js} +1 -1
  116. package/core/built/admin/assets/{table-DxUL78jU.js → table-CB-4_HBJ.js} +1 -1
  117. package/core/built/admin/assets/tabs-CCEgKYzq.js +1 -0
  118. package/core/built/admin/assets/{tags-CNujGwkT.js → tags-Cg2VJGmG.js} +1 -1
  119. package/core/built/admin/assets/{tiers-BgYLWRJv.js → tiers-DYsa4DCo.js} +1 -1
  120. package/core/built/admin/assets/{toggle-group-BeEpQ69p.js → toggle-group-b3IlxvhV.js} +1 -1
  121. package/core/built/admin/assets/{topic-filter-Bb5MOD-1.js → topic-filter-DAFT87tQ.js} +1 -1
  122. package/core/built/admin/assets/{use-analytics-data-Cds6XDjg.js → use-analytics-data-S0FTd43s.js} +1 -1
  123. package/core/built/admin/assets/use-filter-params-Gpoq2E1L.js +1 -0
  124. package/core/built/admin/assets/{use-growth-stats-D8uf98-G.js → use-growth-stats-CIaiHOVb.js} +1 -1
  125. package/core/built/admin/assets/{use-post-referrers-CRtgyuXB.js → use-post-referrers-Cq2vBPrA.js} +1 -1
  126. package/core/built/admin/assets/{use-reply-chain-data-DbKYX50v.js → use-reply-chain-data-DBDRtGu-.js} +1 -1
  127. package/core/built/admin/assets/{use-simple-pagination-DC01kpo0.js → use-simple-pagination-CjYNxdwe.js} +1 -1
  128. package/core/built/admin/assets/{use-tinybird-query-C7bQRJWw.js → use-tinybird-query-falHAtuK.js} +1 -1
  129. package/core/built/admin/assets/{use-tinybird-token-Az4tam7_.js → use-tinybird-token-D7-8ND1y.js} +1 -1
  130. package/core/built/admin/assets/{users-CcSCQFcQ.js → users-Dkv8NQZe.js} +1 -1
  131. package/core/built/admin/assets/{virtual-list-Dhuh9fGx.js → virtual-list-CvnC9zF2.js} +1 -1
  132. package/core/built/admin/assets/web-B_5GZRvH.js +1 -0
  133. package/core/built/admin/assets/web-jFYWCKDn.js +2 -0
  134. package/core/built/admin/index.html +9 -9
  135. package/core/frontend/services/proxy.js +7 -8
  136. package/core/frontend/services/routing/router-manager.js +14 -14
  137. package/core/frontend/services/routing/static-routes-router.js +8 -10
  138. package/core/server/api/endpoints/automation-action-links.js +68 -0
  139. package/core/server/api/endpoints/automation-action-links.ts +46 -0
  140. package/core/server/api/endpoints/index.js +4 -0
  141. package/core/server/api/endpoints/settings.js +0 -2
  142. package/core/server/api/endpoints/utils/serializers/output/utils/url.js +11 -1
  143. package/core/server/data/migrations/versions/6.55/2026-07-27-11-25-44-add-to-hash-to-redirects.js +51 -0
  144. package/core/server/data/migrations/versions/6.55/2026-07-28-12-08-00-remove-routes-hash-from-settings.js +5 -0
  145. package/core/server/data/schema/commands.js +5 -0
  146. package/core/server/data/schema/default-settings/default-settings.json +0 -4
  147. package/core/server/data/schema/schema.js +5 -1
  148. package/core/server/services/automations/automations-api.js +15 -0
  149. package/core/server/services/automations/automations-api.ts +19 -0
  150. package/core/server/services/automations/automations-repository.ts +18 -0
  151. package/core/server/services/automations/database-automations-repository.js +109 -26
  152. package/core/server/services/automations/database-automations-repository.ts +110 -7
  153. package/core/server/services/automations/database-date.js +10 -0
  154. package/core/server/services/automations/database-date.ts +5 -0
  155. package/core/server/services/automations/poll.js +5 -2
  156. package/core/server/services/automations/poll.ts +7 -2
  157. package/core/server/services/automations/scheduler-poll-time.js +40 -0
  158. package/core/server/services/automations/scheduler-poll-time.ts +38 -0
  159. package/core/server/services/automations/service.js +15 -3
  160. package/core/server/services/automations/service.ts +16 -3
  161. package/core/server/services/comments/comments-service-emails.js +10 -7
  162. package/core/server/services/comments/comments-service.js +9 -4
  163. package/core/server/services/email-rendering/partials/email-wrapper.hbs +3 -1
  164. package/core/server/services/email-service/email-service-wrapper.js +6 -1
  165. package/core/server/services/email-service/email-templates/partials/styles.hbs +8 -0
  166. package/core/server/services/email-service/newsletter-email-event-storage.js +41 -10
  167. package/core/server/services/lib/dynamic-redirect-manager.js +12 -3
  168. package/core/server/services/link-redirection/link-redirect-repository.js +41 -4
  169. package/core/server/services/link-redirection/link-redirect.js +2 -0
  170. package/core/server/services/link-redirection/link-redirect.ts +3 -1
  171. package/core/server/services/link-redirection/link-redirects-service.js +38 -2
  172. package/core/server/services/link-tracking/click-event.js +3 -0
  173. package/core/server/services/link-tracking/index.js +38 -6
  174. package/core/server/services/link-tracking/link-click-repository.js +30 -9
  175. package/core/server/services/link-tracking/link-click-tracking-service.js +44 -3
  176. package/core/server/services/member-attribution/outbound-link-tagger.js +3 -1
  177. package/core/server/services/member-welcome-emails/member-welcome-email-renderer.js +19 -4
  178. package/core/server/services/member-welcome-emails/service.js +11 -3
  179. package/core/server/services/members/import-export/csv/formula.js +16 -0
  180. package/core/server/services/members/import-export/csv/formula.ts +14 -0
  181. package/core/server/services/members/import-export/csv/index.js +3 -1
  182. package/core/server/services/members/import-export/csv/index.ts +1 -0
  183. package/core/server/services/members/import-export/import/completion-email.js +22 -3
  184. package/core/server/services/members/import-export/import/completion-email.ts +29 -7
  185. package/core/server/services/members/import-export/import/importer.js +25 -4
  186. package/core/server/services/members/import-export/import/importer.ts +42 -4
  187. package/core/server/services/members/import-export/index.js +9 -0
  188. package/core/server/services/members/import-export/index.ts +20 -1
  189. package/core/server/services/members/service.js +8 -1
  190. package/core/server/services/members-custom-fields/values-service.js +19 -7
  191. package/core/server/services/members-custom-fields/values-service.ts +19 -7
  192. package/core/server/services/mentions/service.js +5 -1
  193. package/core/server/services/route-settings/activation-bridge.js +59 -49
  194. package/core/server/services/route-settings/activation-bridge.ts +91 -51
  195. package/core/server/services/route-settings/dynamic-routing-service.js +22 -51
  196. package/core/server/services/route-settings/index.js +1 -16
  197. package/core/server/services/settings/settings-service.js +0 -18
  198. package/core/server/services/url/url-service-facade.js +1 -1
  199. package/core/server/services/url/url-service-facade.ts +8 -1
  200. package/core/server/web/api/endpoints/admin/routes.js +1 -0
  201. package/package.json +7 -7
  202. package/pnpm-lock.yaml +8 -8
  203. package/pnpm-workspace.yaml +5 -4
  204. package/core/built/admin/assets/Growth-BTP1LthE.js +0 -3
  205. package/core/built/admin/assets/Newsletters-CSEX0myc.js +0 -1
  206. package/core/built/admin/assets/Overview-BjACEsJF.js +0 -1
  207. package/core/built/admin/assets/Web-CxxxjCcv.js +0 -2
  208. package/core/built/admin/assets/activitypub/layout-CziktJXo.js +0 -1
  209. package/core/built/admin/assets/audience-X_XsZivI.js +0 -1
  210. package/core/built/admin/assets/comments-CvyXxOyN.js +0 -1
  211. package/core/built/admin/assets/constants-c-JNF9c2.js +0 -1
  212. package/core/built/admin/assets/domain-CPNXKVOV.js +0 -1
  213. package/core/built/admin/assets/editor-BOG2YPUm.js +0 -8
  214. package/core/built/admin/assets/field-D0XISCK2.js +0 -1
  215. package/core/built/admin/assets/filters-bXkkWro8.js +0 -1
  216. package/core/built/admin/assets/gift-link-modal-su8MsRdg.js +0 -1
  217. package/core/built/admin/assets/growth-Ba6S5FQX.js +0 -1
  218. package/core/built/admin/assets/index-D8PbzuT9.css +0 -1
  219. package/core/built/admin/assets/index-ukW_0Uie.js +0 -67
  220. package/core/built/admin/assets/label-picker-Uq91atex.js +0 -1
  221. package/core/built/admin/assets/labels-Cyayfd-e.js +0 -1
  222. package/core/built/admin/assets/layout-WUcz6tu1.js +0 -1
  223. package/core/built/admin/assets/member-custom-fields-NCzrMuba.js +0 -1
  224. package/core/built/admin/assets/member-detail-DPWDtYnN.js +0 -1
  225. package/core/built/admin/assets/members-B9rO7qr4.js +0 -10
  226. package/core/built/admin/assets/modals-CxRaan1J.js +0 -31
  227. package/core/built/admin/assets/moderation-D8zon60E.js +0 -1
  228. package/core/built/admin/assets/multi-select-combobox-1wYm3nle.js +0 -1
  229. package/core/built/admin/assets/newsletter-DbYMRxRS.js +0 -1
  230. package/core/built/admin/assets/note-PiWPnnrh.js +0 -1
  231. package/core/built/admin/assets/notifications-DEgqqB9G.js +0 -1
  232. package/core/built/admin/assets/onboarding-route-BzF6HeVn.js +0 -1
  233. package/core/built/admin/assets/overview-DixLeIqY.js +0 -1
  234. package/core/built/admin/assets/post-analytics-header-DsMoLx0R.js +0 -1
  235. package/core/built/admin/assets/post-analytics-provider-BJCPLMIb.js +0 -1
  236. package/core/built/admin/assets/preferences-D7-j6A4C.js +0 -5
  237. package/core/built/admin/assets/profile-Dl69QPp0.js +0 -1
  238. package/core/built/admin/assets/settings-BbmC_Iqq.js +0 -4
  239. package/core/built/admin/assets/social-urls-BPL1nADp.js +0 -8
  240. package/core/built/admin/assets/tabs-BQcJQ1ke.js +0 -1
  241. package/core/built/admin/assets/use-filter-params-DjXkhO54.js +0 -1
  242. package/core/built/admin/assets/web-LtHNiNBV.js +0 -1
  243. package/core/server/services/route-settings/default-settings-manager.js +0 -63
  244. package/core/server/services/route-settings/route-settings.js +0 -170
  245. package/core/server/services/route-settings/settings-loader.js +0 -60
  246. package/core/server/services/route-settings/settings-path-manager.js +0 -47
  247. package/core/server/services/route-settings/validate.js +0 -440
@@ -10,7 +10,7 @@ const moment = require('moment');
10
10
 
11
11
  /**
12
12
  * @typedef {object} ILinkClickRepository
13
- * @prop {(event: LinkClick) => Promise<void>} save
13
+ * @prop {(event: LinkClick, options?: {transacting?: object}) => Promise<string | undefined>} save
14
14
  * @prop {({filter: string}) => Promise<LinkClick[]>} getAll
15
15
  */
16
16
 
@@ -61,6 +61,10 @@ class LinkClickTrackingService {
61
61
  #LinkRedirect;
62
62
  /** @type {Object} */
63
63
  #urlUtils;
64
+ /** @type {object} */
65
+ #automationsApi;
66
+ /** @type {(callback: (transacting: object) => Promise<void>) => Promise<void>} */
67
+ #runInTransaction;
64
68
 
65
69
  /**
66
70
  * @param {object} deps
@@ -69,6 +73,8 @@ class LinkClickTrackingService {
69
73
  * @param {IPostLinkRepository} deps.postLinkRepository
70
74
  * @param {DomainEvents} deps.DomainEvents
71
75
  * @param {urlUtils} deps.urlUtils
76
+ * @param {object} deps.automationsApi
77
+ * @param {(callback: (transacting: object) => Promise<void>) => Promise<void>} deps.runInTransaction
72
78
  */
73
79
  constructor(deps) {
74
80
  this.#linkClickRepository = deps.linkClickRepository;
@@ -76,6 +82,8 @@ class LinkClickTrackingService {
76
82
  this.#postLinkRepository = deps.postLinkRepository;
77
83
  this.#DomainEvents = deps.DomainEvents;
78
84
  this.#urlUtils = deps.urlUtils;
85
+ this.#automationsApi = deps.automationsApi;
86
+ this.#runInTransaction = deps.runInTransaction;
79
87
  }
80
88
 
81
89
  async init() {
@@ -233,6 +241,20 @@ class LinkClickTrackingService {
233
241
  return url;
234
242
  }
235
243
 
244
+ /**
245
+ * Add first-party tracking to a URL in an automation email
246
+ * @param {Readonly<URL>} url
247
+ * @param {string} automationActionRevisionId
248
+ * @param {string} memberUuid
249
+ * @return {Promise<URL>}
250
+ */
251
+ async addAutomationTrackingToUrl(url, automationActionRevisionId, memberUuid) {
252
+ const redirect = await this.#linkRedirectService.getOrAddAutomationRedirect(automationActionRevisionId, url);
253
+ const trackedUrl = new URL(redirect.from.href);
254
+ trackedUrl.searchParams.set('m', memberUuid);
255
+ return trackedUrl;
256
+ }
257
+
236
258
  subscribe() {
237
259
  this.#DomainEvents.subscribe(RedirectEvent, async (event) => {
238
260
  const uuid = event.data.url.searchParams.get('m');
@@ -242,9 +264,28 @@ class LinkClickTrackingService {
242
264
 
243
265
  const click = new LinkClick({
244
266
  member_uuid: uuid,
245
- link_id: event.data.link.link_id
267
+ link_id: event.data.link.link_id,
268
+ timestamp: event.timestamp
269
+ });
270
+
271
+ const automationActionRevisionId = event.data.link.automationActionRevisionId;
272
+ if (!automationActionRevisionId) {
273
+ await this.#linkClickRepository.save(click);
274
+ return;
275
+ }
276
+
277
+ await this.#runInTransaction(async (transacting) => {
278
+ const memberId = await this.#linkClickRepository.save(click, {transacting});
279
+ if (!memberId) {
280
+ return;
281
+ }
282
+
283
+ await this.#automationsApi.trackEmailClicked({
284
+ automationActionRevisionId,
285
+ memberId,
286
+ clickedAt: event.timestamp
287
+ }, {transacting});
246
288
  });
247
- await this.#linkClickRepository.save(click);
248
289
  });
249
290
  }
250
291
  }
@@ -11,7 +11,9 @@ const blockedReferrerDomains = [
11
11
  'www.chicagomag.com',
12
12
  'bailii.org',
13
13
  'www.bailii.org',
14
- '*.doubleclick.net'
14
+ '*.doubleclick.net',
15
+ // Substack's client app throws on any non-empty ?ref= value and swaps the post for an error screen
16
+ '*.substack.com'
15
17
  ];
16
18
 
17
19
  const isBlockedReferrerDomain = (domain) => {
@@ -2,11 +2,13 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const lexicalLib = require('../../lib/lexical');
4
4
  const labs = require('../../../shared/labs');
5
+ const urlUtils = require('../../../shared/url-utils').default;
5
6
  const {finalize} = require('../email-rendering/finalize');
6
7
  const errors = require('@tryghost/errors');
7
8
  const {MESSAGES} = require('./constants');
8
9
  const {wrapReplacementStrings} = require('@tryghost/kg-default-nodes').utils.replacementStrings;
9
10
  const linkReplacer = require('../lib/link-replacer');
11
+ const linkTracking = require('../link-tracking');
10
12
  const emailDesign = require('../email-rendering/email-design');
11
13
  const {registerHelpers} = require('../email-service/helpers/register-helpers');
12
14
 
@@ -115,9 +117,11 @@ class MemberWelcomeEmailRenderer {
115
117
  * @param {Object} options.member - Member data (name, email)
116
118
  * @param {Object} options.siteSettings - Site settings (title, url, accentColor)
117
119
  * @param {string} [options.unsubscribeUrl] - When set, the footer shows an "Unsubscribe from these emails" link instead of "Manage your preferences"
120
+ * @param {boolean} [options.trackClicks]
121
+ * @param {string | null} [options.automationActionRevisionId]
118
122
  * @returns {Promise<{html: string, text: string, subject: string}>}
119
123
  */
120
- async render({lexical, subject, designSettings, member, siteSettings, unsubscribeUrl}) {
124
+ async render({lexical, subject, designSettings, member, siteSettings, unsubscribeUrl, trackClicks = false, automationActionRevisionId = null}) {
121
125
  designSettings = designSettings || {};
122
126
 
123
127
  const design = emailDesign.getEmailDesign({
@@ -138,7 +142,8 @@ class MemberWelcomeEmailRenderer {
138
142
 
139
143
  let content;
140
144
  try {
141
- content = await lexicalLib.render(lexical, {target: 'email', design});
145
+ const absoluteLexical = urlUtils.transformReadyToAbsolute(lexical);
146
+ content = await lexicalLib.render(absoluteLexical, {target: 'email', design});
142
147
  } catch (err) {
143
148
  throw new errors.IncorrectUsageError({
144
149
  message: MESSAGES.INVALID_LEXICAL_STRUCTURE,
@@ -158,8 +163,18 @@ class MemberWelcomeEmailRenderer {
158
163
  const contentWithReplacements = this.#applyReplacements({definitions, text: content, escapeHtml: true});
159
164
  const subjectWithReplacements = this.#applyReplacements({definitions, text: subject, escapeHtml: false});
160
165
 
161
- // Resolve relative links (e.g. #/portal/signup) to absolute URLs using the site URL
162
- const contentWithAbsoluteLinks = await linkReplacer.replace(contentWithReplacements, (url) => {
166
+ // Resolve relative links, including Ghost Portal routes such as #/portal/signup,
167
+ // while preserving document-local anchors such as #section.
168
+ const contentWithAbsoluteLinks = await linkReplacer.replace(contentWithReplacements, async (url, originalPath) => {
169
+ const isDocumentAnchor = originalPath.startsWith('#') && !originalPath.startsWith('#/');
170
+ if (isDocumentAnchor) {
171
+ return originalPath;
172
+ }
173
+ const isTrackable = ['http:', 'https:'].includes(url.protocol);
174
+ if (trackClicks && automationActionRevisionId && member.uuid && isTrackable) {
175
+ await linkTracking.init();
176
+ return await linkTracking.service.addAutomationTrackingToUrl(url, automationActionRevisionId, member.uuid);
177
+ }
163
178
  return url;
164
179
  }, {base: siteSettings.url});
165
180
 
@@ -368,10 +368,12 @@ class MemberWelcomeEmailService {
368
368
  * @param {null | object} options.email.designSettings
369
369
  * @param {'welcome' | 'automation'} options.emailType
370
370
  * @param {boolean} [options.trackOpens]
371
+ * @param {boolean} [options.trackClicks]
372
+ * @param {string | null} [options.automationActionRevisionId]
371
373
  * @param {null | {url: string, oneClickUrl: string}} [options.unsubscribe] - When set, the footer links to an unsubscribe URL and the email carries one-click List-Unsubscribe headers
372
374
  * @returns {Promise<unknown>}
373
375
  */
374
- async #sendEmail({member, memberStatus, email, emailType, trackOpens, unsubscribe = null}) {
376
+ async #sendEmail({member, memberStatus, email, emailType, trackOpens, trackClicks = false, automationActionRevisionId = null, unsubscribe = null}) {
375
377
  if (!member.email) {
376
378
  throw new errors.IncorrectUsageError({
377
379
  message: MESSAGES.MISSING_RECIPIENT_EMAIL
@@ -396,7 +398,9 @@ class MemberWelcomeEmailService {
396
398
  uuid: member.uuid
397
399
  },
398
400
  siteSettings: this.#getSiteSettings(),
399
- unsubscribeUrl: unsubscribe?.url
401
+ unsubscribeUrl: unsubscribe?.url,
402
+ trackClicks,
403
+ automationActionRevisionId
400
404
  });
401
405
 
402
406
  const senderOptions = await this.#getEffectiveSenderOptions(
@@ -474,9 +478,11 @@ class MemberWelcomeEmailService {
474
478
  * @param {string} options.member.uuid
475
479
  * @param {'free' | 'paid'} options.memberStatus
476
480
  * @param {boolean} options.trackOpens
481
+ * @param {boolean} options.trackClicks
482
+ * @param {string} options.automationActionRevisionId
477
483
  * @returns {Promise<unknown>}
478
484
  */
479
- async sendAutomationEmail({email, member, memberStatus, trackOpens}) {
485
+ async sendAutomationEmail({email, member, memberStatus, trackOpens, trackClicks, automationActionRevisionId}) {
480
486
  const designSettings = email.designSettingId ?
481
487
  await EmailDesignSetting.findOne({id: email.designSettingId}) :
482
488
  null;
@@ -502,6 +508,8 @@ class MemberWelcomeEmailService {
502
508
  designSettings: designSettingsJson
503
509
  },
504
510
  trackOpens,
511
+ trackClicks,
512
+ automationActionRevisionId,
505
513
  unsubscribe
506
514
  });
507
515
  }
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.stripFormulaGuard = stripFormulaGuard;
4
+ // The export writes with papaparse's escapeFormulae on (see serialize), which prefixes a
5
+ // cell starting with one of these with an apostrophe so a spreadsheet won't read it as a
6
+ // formula: `=SUM(A1)` is written as `'=SUM(A1)`.
7
+ const FORMULA_TRIGGERS = ['=', '+', '-', '@', '\t', '\r'];
8
+ // Strip that guard back off, so a re-imported value doesn't gain an apostrophe on every
9
+ // round trip. Only a lone apostrophe before a trigger is the guard; one before anything
10
+ // else is a character the value actually starts with, and is left alone.
11
+ function stripFormulaGuard(cell) {
12
+ if (cell.charAt(0) === '\'' && FORMULA_TRIGGERS.includes(cell.charAt(1))) {
13
+ return cell.slice(1);
14
+ }
15
+ return cell;
16
+ }
@@ -0,0 +1,14 @@
1
+ // The export writes with papaparse's escapeFormulae on (see serialize), which prefixes a
2
+ // cell starting with one of these with an apostrophe so a spreadsheet won't read it as a
3
+ // formula: `=SUM(A1)` is written as `'=SUM(A1)`.
4
+ const FORMULA_TRIGGERS = ['=', '+', '-', '@', '\t', '\r'];
5
+
6
+ // Strip that guard back off, so a re-imported value doesn't gain an apostrophe on every
7
+ // round trip. Only a lone apostrophe before a trigger is the guard; one before anything
8
+ // else is a character the value actually starts with, and is left alone.
9
+ export function stripFormulaGuard(cell: string): string {
10
+ if (cell.charAt(0) === '\'' && FORMULA_TRIGGERS.includes(cell.charAt(1))) {
11
+ return cell.slice(1);
12
+ }
13
+ return cell;
14
+ }
@@ -3,8 +3,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.serialize = exports.parse = void 0;
6
+ exports.stripFormulaGuard = exports.serialize = exports.parse = void 0;
7
7
  var parse_1 = require("./parse");
8
8
  Object.defineProperty(exports, "parse", { enumerable: true, get: function () { return __importDefault(parse_1).default; } });
9
9
  var serialize_1 = require("./serialize");
10
10
  Object.defineProperty(exports, "serialize", { enumerable: true, get: function () { return __importDefault(serialize_1).default; } });
11
+ var formula_1 = require("./formula");
12
+ Object.defineProperty(exports, "stripFormulaGuard", { enumerable: true, get: function () { return formula_1.stripFormulaGuard; } });
@@ -1,2 +1,3 @@
1
1
  export {default as parse, type Row} from './parse';
2
2
  export {default as serialize} from './serialize';
3
+ export {stripFormulaGuard} from './formula';
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.default = buildCompletionEmail;
4
4
  const csv_1 = require("../csv");
5
+ const csv_2 = require("@tryghost/custom-field-types/csv");
5
6
  const emailTemplate = require('./email-template');
6
7
  // Turn an ORM validation error into copy a member manager can act on. Presentation
7
8
  // only: the API response keeps the raw messages, and just the emailed report is
@@ -17,6 +18,14 @@ function humaniseError(message) {
17
18
  function stringifyLabels(labels) {
18
19
  return labels.map(label => (typeof label === 'string' ? label : label.name)).join(',');
19
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.
20
29
  function toErrorReportRow(row) {
21
30
  return {
22
31
  id: row.id,
@@ -34,10 +43,20 @@ function toErrorReportRow(row) {
34
43
  error: humaniseError(row.error)
35
44
  };
36
45
  }
37
- // The error report attached to the completion email: the failed rows as CSV. No column
38
- // list is passed -- the typed ErrorReportRow defines the columns.
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.
39
51
  function buildErrorReport(errors) {
40
- return (0, csv_1.serialize)(errors.map(toErrorReportRow));
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 });
41
60
  }
42
61
  // Compose the completion email for a finished import: the summary and its links,
43
62
  // plus the attached error report. Owns how the outcome is presented, so the
@@ -1,4 +1,5 @@
1
1
  import {serialize} from '../csv';
2
+ import {isCustomFieldColumn} from '@tryghost/custom-field-types/csv';
2
3
  import type {MemberImportRow, ImportErrorRow, ImportLabel, Label} from './row';
3
4
 
4
5
  const emailTemplate = require('./email-template');
@@ -43,10 +44,12 @@ function humaniseError(message: string): string {
43
44
  .replace(/No such customer:[^,]*/, 'Could not find Stripe customer');
44
45
  }
45
46
 
46
- // One row of the attached error report, in emit order. papaparse takes the columns from
47
- // these keys, so the report cannot drift from the shaper -- a new column must be added
48
- // here or the shaper stops compiling. tiers and deleted_at are export-vocabulary columns
49
- // an import never fills; kept always-empty so the report matches the members export CSV.
47
+ // One row of the fixed, member-vocabulary part of the error report, in emit order.
48
+ // papaparse takes the fixed columns from these keys, so the report cannot drift from the
49
+ // shaper -- a new member column must be added here or the shaper stops compiling. tiers
50
+ // and deleted_at are export-vocabulary columns an import never fills; kept always-empty
51
+ // so the report matches the members export CSV. Any custom_fields.* columns a submitted
52
+ // row carried are dynamic, so they are threaded in separately by buildErrorReport.
50
53
  type ErrorReportRow = {
51
54
  id: MemberImportRow['id'];
52
55
  email: MemberImportRow['email'];
@@ -67,6 +70,15 @@ function stringifyLabels(labels: Array<string | Label>): string {
67
70
  return labels.map(label => (typeof label === 'string' ? label : label.name)).join(',');
68
71
  }
69
72
 
73
+ // The custom_fields.* cells a submitted row carried, echoed untouched so a manager can
74
+ // fix a failed row and re-upload the values they mapped.
75
+ function customFieldCells(row: ImportErrorRow): Record<string, unknown> {
76
+ return Object.fromEntries(Object.entries(row).filter(([column]) => isCustomFieldColumn(column)));
77
+ }
78
+
79
+ // Shape a failed import row into its fixed error-report cells, with the raw ORM message
80
+ // rewritten into copy the member manager can act on. Custom field cells are merged on by
81
+ // buildErrorReport, which owns the dynamic column set.
70
82
  function toErrorReportRow(row: ImportErrorRow): ErrorReportRow {
71
83
  return {
72
84
  id: row.id,
@@ -85,10 +97,20 @@ function toErrorReportRow(row: ImportErrorRow): ErrorReportRow {
85
97
  };
86
98
  }
87
99
 
88
- // The error report attached to the completion email: the failed rows as CSV. No column
89
- // list is passed -- the typed ErrorReportRow defines the columns.
100
+ // The error report attached to the completion email: the failed rows as CSV. It shares
101
+ // the serialiser with the export but not the shaping -- the export writes db members,
102
+ // this echoes submitted rows. Member columns come from the shaper's keys (so the type
103
+ // stays the single source); the custom_fields.* columns across the rows are threaded in
104
+ // before the last error column, and each row's custom cells merged on.
90
105
  function buildErrorReport(errors: ImportErrorRow[]): string {
91
- return serialize(errors.map(toErrorReportRow));
106
+ if (errors.length === 0) {
107
+ return serialize([]);
108
+ }
109
+ const memberColumns = Object.keys(toErrorReportRow(errors[0])).filter(column => column !== 'error');
110
+ const customColumns = [...new Set(errors.flatMap(row => Object.keys(customFieldCells(row))))];
111
+ const columns = [...memberColumns, ...customColumns, 'error'];
112
+ const rows = errors.map(row => ({...toErrorReportRow(row), ...customFieldCells(row)}));
113
+ return serialize(rows, {columns});
92
114
  }
93
115
 
94
116
  // Compose the completion email for a finished import: the summary and its links,
@@ -5,6 +5,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const moment_timezone_1 = __importDefault(require("moment-timezone"));
7
7
  const completion_email_1 = __importDefault(require("./completion-email"));
8
+ const csv_1 = require("../csv");
9
+ const csv_2 = require("@tryghost/custom-field-types/csv");
8
10
  const metrics = require('@tryghost/metrics');
9
11
  const errors = require('@tryghost/errors');
10
12
  const tpl = require('@tryghost/tpl');
@@ -47,11 +49,12 @@ class MembersCSVImporter {
47
49
  _tiers;
48
50
  _stripe;
49
51
  _gifts;
52
+ _customFields;
50
53
  _email;
51
54
  _addJob;
52
55
  _getTimezone;
53
56
  _getInlineThreshold;
54
- constructor({ knex, readRows, spool, members, tiers, stripe, gifts, email, addJob, getTimezone, getInlineThreshold }) {
57
+ constructor({ knex, readRows, spool, members, tiers, stripe, gifts, customFields, email, addJob, getTimezone, getInlineThreshold }) {
55
58
  this._knex = knex;
56
59
  this._readRows = readRows;
57
60
  this._spool = spool;
@@ -59,6 +62,7 @@ class MembersCSVImporter {
59
62
  this._tiers = tiers;
60
63
  this._stripe = stripe;
61
64
  this._gifts = gifts;
65
+ this._customFields = customFields;
62
66
  this._email = email;
63
67
  this._addJob = addJob;
64
68
  this._getTimezone = getTimezone;
@@ -132,6 +136,9 @@ class MembersCSVImporter {
132
136
  const globalLabels = [importLabel, ...extraLabels];
133
137
  const performStart = Date.now();
134
138
  const defaultTier = await this._tiers.getDefault();
139
+ // The field set a custom_fields.* column is read against; empty when the feature
140
+ // is off, so a carried-through column is dropped and the write boundary stays shut.
141
+ const activeCustomFields = await this._customFields.activeFields();
135
142
  const tierIdCache = new Map();
136
143
  const archivableStripePriceIds = [];
137
144
  // Copied per row: the member model stamps ids and trims names onto these in
@@ -143,8 +150,7 @@ class MembersCSVImporter {
143
150
  let imported = 0;
144
151
  const importErrors = [];
145
152
  for (const row of rows) {
146
- const trx = await this._knex.transaction(undefined, { doNotRejectOnRollback: false });
147
- const options = { transacting: trx, context: IMPORT_CONTEXT };
153
+ let trx;
148
154
  try {
149
155
  if (row.gift_id) {
150
156
  if (row.import_tier) {
@@ -154,6 +160,15 @@ class MembersCSVImporter {
154
160
  throw wrapGiftError(new errors.DataImportError({ message: tpl(messages.giftCannotCombineWithComplimentary) }));
155
161
  }
156
162
  }
163
+ // Validate the row's custom field values before the transaction opens.
164
+ // planWrite only reads, and it throws on an invalid value to fail the row
165
+ // before any member write -- so there is no reason to hold a transaction
166
+ // across it, and doing so would deadlock the single-connection SQLite pool.
167
+ const customFieldPlan = activeCustomFields.length > 0
168
+ ? await this._customFields.planWrite((0, csv_2.fieldValuesFromCsvRow)(activeCustomFields, row, csv_1.stripFormulaGuard))
169
+ : [];
170
+ trx = await this._knex.transaction(undefined, { doNotRejectOnRollback: false });
171
+ const options = { transacting: trx, context: IMPORT_CONTEXT };
157
172
  const createdAt = (row.created_at && (0, moment_timezone_1.default)(row.created_at).isAfter((0, moment_timezone_1.default)())) ? (0, moment_timezone_1.default)().toDate() : row.created_at;
158
173
  const memberValues = {
159
174
  email: row.email,
@@ -243,6 +258,8 @@ class MembersCSVImporter {
243
258
  throw wrapGiftError(giftError);
244
259
  }
245
260
  }
261
+ // On the row's transaction, so the values commit or roll back with the member.
262
+ await this._customFields.applyWrite(member.id, customFieldPlan, trx);
246
263
  await trx.commit();
247
264
  imported += 1;
248
265
  }
@@ -251,7 +268,11 @@ class MembersCSVImporter {
251
268
  const errorMessage = errorList
252
269
  .map(e => (typeof e === 'object' && e !== null && 'message' in e ? e.message : undefined))
253
270
  .join(', ');
254
- await trx.rollback();
271
+ // trx is unset if the row failed before the transaction opened (a bad
272
+ // custom field value or gift combination).
273
+ if (trx) {
274
+ await trx.rollback();
275
+ }
255
276
  importErrors.push({ ...row, error: errorMessage });
256
277
  }
257
278
  }
@@ -1,5 +1,7 @@
1
1
  import moment from 'moment-timezone';
2
2
  import buildCompletionEmail from './completion-email';
3
+ import {stripFormulaGuard} from '../csv';
4
+ import {fieldValuesFromCsvRow, type CsvField} from '@tryghost/custom-field-types/csv';
3
5
  import type {Knex} from 'knex';
4
6
  import type {MemberImportRow, ImportErrorRow, ImportLabel, Label} from './row';
5
7
  import type {RowSpool, SpooledRows} from './spool';
@@ -91,6 +93,19 @@ export interface EmailNotifications {
91
93
  urlFor(type: string, data: unknown, absolute: boolean): string;
92
94
  }
93
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
+
94
109
  // The collaborators the import depends on, one per concern.
95
110
  interface ImporterDeps {
96
111
  knex: Knex;
@@ -100,6 +115,7 @@ interface ImporterDeps {
100
115
  tiers: TiersRepository;
101
116
  stripe: StripeSubscriptions;
102
117
  gifts: GiftService;
118
+ customFields: CustomFieldsImport;
103
119
  email: EmailNotifications;
104
120
  addJob: (job: {job: () => Promise<void>; offloaded: boolean; name: string}) => void;
105
121
  getTimezone: () => string;
@@ -183,12 +199,13 @@ class MembersCSVImporter {
183
199
  private _tiers: TiersRepository;
184
200
  private _stripe: StripeSubscriptions;
185
201
  private _gifts: GiftService;
202
+ private _customFields: CustomFieldsImport;
186
203
  private _email: EmailNotifications;
187
204
  private _addJob: (job: {job: () => Promise<void>; offloaded: boolean; name: string}) => void;
188
205
  private _getTimezone: () => string;
189
206
  private _getInlineThreshold: () => number;
190
207
 
191
- constructor({knex, readRows, spool, members, tiers, stripe, gifts, email, addJob, getTimezone, getInlineThreshold}: ImporterDeps) {
208
+ constructor({knex, readRows, spool, members, tiers, stripe, gifts, customFields, email, addJob, getTimezone, getInlineThreshold}: ImporterDeps) {
192
209
  this._knex = knex;
193
210
  this._readRows = readRows;
194
211
  this._spool = spool;
@@ -196,6 +213,7 @@ class MembersCSVImporter {
196
213
  this._tiers = tiers;
197
214
  this._stripe = stripe;
198
215
  this._gifts = gifts;
216
+ this._customFields = customFields;
199
217
  this._email = email;
200
218
  this._addJob = addJob;
201
219
  this._getTimezone = getTimezone;
@@ -283,6 +301,9 @@ class MembersCSVImporter {
283
301
  const globalLabels: Label[] = [importLabel, ...extraLabels];
284
302
  const performStart = Date.now();
285
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();
286
307
  const tierIdCache = new Map();
287
308
  const archivableStripePriceIds: string[] = [];
288
309
  // Copied per row: the member model stamps ids and trims names onto these in
@@ -295,8 +316,7 @@ class MembersCSVImporter {
295
316
  let imported = 0;
296
317
  const importErrors: ImportErrorRow[] = [];
297
318
  for (const row of rows) {
298
- const trx = await this._knex.transaction(undefined, {doNotRejectOnRollback: false});
299
- const options = {transacting: trx, context: IMPORT_CONTEXT};
319
+ let trx: Knex.Transaction | undefined;
300
320
  try {
301
321
  if (row.gift_id) {
302
322
  if (row.import_tier) {
@@ -307,6 +327,17 @@ class MembersCSVImporter {
307
327
  }
308
328
  }
309
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
+
310
341
  const createdAt = (row.created_at && moment(row.created_at).isAfter(moment())) ? moment().toDate() : row.created_at;
311
342
  const memberValues: MemberImportValues = {
312
343
  email: row.email,
@@ -394,6 +425,9 @@ class MembersCSVImporter {
394
425
  }
395
426
  }
396
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
+
397
431
  await trx.commit();
398
432
  imported += 1;
399
433
  } catch (error) {
@@ -401,7 +435,11 @@ class MembersCSVImporter {
401
435
  const errorMessage = errorList
402
436
  .map(e => (typeof e === 'object' && e !== null && 'message' in e ? e.message : undefined))
403
437
  .join(', ');
404
- await trx.rollback();
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
+ }
405
443
  importErrors.push({...row, error: errorMessage});
406
444
  }
407
445
  }
@@ -42,6 +42,14 @@ function makeImporter(deps) {
42
42
  const gifts = {
43
43
  reassignRedeemer: (giftId, memberId, options) => deps.getGiftService().reassignRedeemer(giftId, memberId, options)
44
44
  };
45
+ // Gated by the same labs flag as the export, so the two halves round-trip or stay
46
+ // silent together: off, activeFields resolves empty and every custom_fields.* column
47
+ // is dropped.
48
+ const customFields = {
49
+ activeFields: async () => (labs.isSet('membersCustomFields') ? deps.customFields.definitions.browse() : []),
50
+ planWrite: values => deps.customFields.values.planWrite(values),
51
+ applyWrite: (memberId, plan, executor) => deps.customFields.values.applyWrite(memberId, plan, executor)
52
+ };
45
53
  return new importer_1.default({
46
54
  knex: deps.knex,
47
55
  readRows: reader_1.default,
@@ -56,6 +64,7 @@ function makeImporter(deps) {
56
64
  productRepository: deps.productRepository
57
65
  }),
58
66
  gifts,
67
+ customFields,
59
68
  email,
60
69
  addJob: deps.addJob,
61
70
  getTimezone: deps.getTimezone,
@@ -1,5 +1,6 @@
1
1
  import type {Knex} from 'knex';
2
- import MembersCSVImporter, {type MembersRepository, type GiftService, type EmailNotifications, type Tier} from './import/importer';
2
+ import type {CsvField} from '@tryghost/custom-field-types/csv';
3
+ import MembersCSVImporter, {type MembersRepository, type GiftService, type EmailNotifications, type Tier, type CustomFieldsImport} from './import/importer';
3
4
  import readMemberRows from './import/reader';
4
5
  import {createRowSpool} from './import/spool';
5
6
  import MembersCSVExporter, {type ExportOptions, type CustomFieldDefinition} from './export/exporter';
@@ -25,6 +26,14 @@ interface ImporterServices {
25
26
  getInlineThreshold(): number;
26
27
  stripeAPIService: unknown;
27
28
  productRepository: unknown;
29
+ // The custom fields services the members service hands the import composition root.
30
+ customFields: {
31
+ definitions: {browse(): Promise<CsvField[]>};
32
+ values: {
33
+ planWrite(values: Record<string, unknown>): Promise<unknown[]>;
34
+ applyWrite(memberId: string, plan: unknown[], executor: Knex): Promise<void>;
35
+ };
36
+ };
28
37
  }
29
38
 
30
39
  // The custom fields services the members service hands the export composition root.
@@ -66,6 +75,15 @@ export function makeImporter(deps: ImporterServices) {
66
75
  reassignRedeemer: (giftId, memberId, options) => deps.getGiftService().reassignRedeemer(giftId, memberId, options)
67
76
  };
68
77
 
78
+ // Gated by the same labs flag as the export, so the two halves round-trip or stay
79
+ // silent together: off, activeFields resolves empty and every custom_fields.* column
80
+ // is dropped.
81
+ const customFields: CustomFieldsImport = {
82
+ activeFields: async () => (labs.isSet('membersCustomFields') ? deps.customFields.definitions.browse() : []),
83
+ planWrite: values => deps.customFields.values.planWrite(values),
84
+ applyWrite: (memberId, plan, executor) => deps.customFields.values.applyWrite(memberId, plan, executor)
85
+ };
86
+
69
87
  return new MembersCSVImporter({
70
88
  knex: deps.knex,
71
89
  readRows: readMemberRows,
@@ -80,6 +98,7 @@ export function makeImporter(deps: ImporterServices) {
80
98
  productRepository: deps.productRepository
81
99
  }),
82
100
  gifts,
101
+ customFields,
83
102
  email,
84
103
  addJob: deps.addJob,
85
104
  getTimezone: deps.getTimezone,