@endora-commerce/mod-newsletter 0.100.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 (239) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +66 -0
  3. package/dist/admin/api/newsletter-client.d.ts +64 -0
  4. package/dist/admin/api/newsletter-client.d.ts.map +1 -0
  5. package/dist/admin/api/newsletter-client.js +71 -0
  6. package/dist/admin/api/newsletter-client.js.map +1 -0
  7. package/dist/admin/email-variables.d.ts +21 -0
  8. package/dist/admin/email-variables.d.ts.map +1 -0
  9. package/dist/admin/email-variables.js +48 -0
  10. package/dist/admin/email-variables.js.map +1 -0
  11. package/dist/admin/index.d.ts +44 -0
  12. package/dist/admin/index.d.ts.map +1 -0
  13. package/dist/admin/index.js +149 -0
  14. package/dist/admin/index.js.map +1 -0
  15. package/dist/admin/pages/AutomationBuilder.d.ts +8 -0
  16. package/dist/admin/pages/AutomationBuilder.d.ts.map +1 -0
  17. package/dist/admin/pages/AutomationBuilder.js +131 -0
  18. package/dist/admin/pages/AutomationBuilder.js.map +1 -0
  19. package/dist/admin/pages/AutomationsPage.d.ts +8 -0
  20. package/dist/admin/pages/AutomationsPage.d.ts.map +1 -0
  21. package/dist/admin/pages/AutomationsPage.js +43 -0
  22. package/dist/admin/pages/AutomationsPage.js.map +1 -0
  23. package/dist/admin/pages/BlocksPage.d.ts +8 -0
  24. package/dist/admin/pages/BlocksPage.d.ts.map +1 -0
  25. package/dist/admin/pages/BlocksPage.js +110 -0
  26. package/dist/admin/pages/BlocksPage.js.map +1 -0
  27. package/dist/admin/pages/CampaignEditor.d.ts +8 -0
  28. package/dist/admin/pages/CampaignEditor.d.ts.map +1 -0
  29. package/dist/admin/pages/CampaignEditor.js +144 -0
  30. package/dist/admin/pages/CampaignEditor.js.map +1 -0
  31. package/dist/admin/pages/CampaignStats.d.ts +8 -0
  32. package/dist/admin/pages/CampaignStats.d.ts.map +1 -0
  33. package/dist/admin/pages/CampaignStats.js +45 -0
  34. package/dist/admin/pages/CampaignStats.js.map +1 -0
  35. package/dist/admin/pages/CampaignsPage.d.ts +8 -0
  36. package/dist/admin/pages/CampaignsPage.d.ts.map +1 -0
  37. package/dist/admin/pages/CampaignsPage.js +28 -0
  38. package/dist/admin/pages/CampaignsPage.js.map +1 -0
  39. package/dist/admin/pages/ProviderSettingsPage.d.ts +8 -0
  40. package/dist/admin/pages/ProviderSettingsPage.d.ts.map +1 -0
  41. package/dist/admin/pages/ProviderSettingsPage.js +64 -0
  42. package/dist/admin/pages/ProviderSettingsPage.js.map +1 -0
  43. package/dist/admin/pages/SubscribersPage.d.ts +8 -0
  44. package/dist/admin/pages/SubscribersPage.d.ts.map +1 -0
  45. package/dist/admin/pages/SubscribersPage.js +55 -0
  46. package/dist/admin/pages/SubscribersPage.js.map +1 -0
  47. package/dist/admin/pages/TagsPage.d.ts +8 -0
  48. package/dist/admin/pages/TagsPage.d.ts.map +1 -0
  49. package/dist/admin/pages/TagsPage.js +114 -0
  50. package/dist/admin/pages/TagsPage.js.map +1 -0
  51. package/dist/backend/entities/newsletter-automation-run.entity.d.ts +19 -0
  52. package/dist/backend/entities/newsletter-automation-run.entity.d.ts.map +1 -0
  53. package/dist/backend/entities/newsletter-automation-run.entity.js +72 -0
  54. package/dist/backend/entities/newsletter-automation-run.entity.js.map +1 -0
  55. package/dist/backend/entities/newsletter-automation.entity.d.ts +22 -0
  56. package/dist/backend/entities/newsletter-automation.entity.d.ts.map +1 -0
  57. package/dist/backend/entities/newsletter-automation.entity.js +86 -0
  58. package/dist/backend/entities/newsletter-automation.entity.js.map +1 -0
  59. package/dist/backend/entities/newsletter-campaign-subscriber.entity.d.ts +6 -0
  60. package/dist/backend/entities/newsletter-campaign-subscriber.entity.d.ts.map +1 -0
  61. package/dist/backend/entities/newsletter-campaign-subscriber.entity.js +30 -0
  62. package/dist/backend/entities/newsletter-campaign-subscriber.entity.js.map +1 -0
  63. package/dist/backend/entities/newsletter-campaign.entity.d.ts +22 -0
  64. package/dist/backend/entities/newsletter-campaign.entity.d.ts.map +1 -0
  65. package/dist/backend/entities/newsletter-campaign.entity.js +102 -0
  66. package/dist/backend/entities/newsletter-campaign.entity.js.map +1 -0
  67. package/dist/backend/entities/newsletter-custom-field.entity.d.ts +12 -0
  68. package/dist/backend/entities/newsletter-custom-field.entity.d.ts.map +1 -0
  69. package/dist/backend/entities/newsletter-custom-field.entity.js +53 -0
  70. package/dist/backend/entities/newsletter-custom-field.entity.js.map +1 -0
  71. package/dist/backend/entities/newsletter-email-block-sales-channel.entity.d.ts +7 -0
  72. package/dist/backend/entities/newsletter-email-block-sales-channel.entity.d.ts.map +1 -0
  73. package/dist/backend/entities/newsletter-email-block-sales-channel.entity.js +35 -0
  74. package/dist/backend/entities/newsletter-email-block-sales-channel.entity.js.map +1 -0
  75. package/dist/backend/entities/newsletter-email-block.entity.d.ts +21 -0
  76. package/dist/backend/entities/newsletter-email-block.entity.d.ts.map +1 -0
  77. package/dist/backend/entities/newsletter-email-block.entity.js +78 -0
  78. package/dist/backend/entities/newsletter-email-block.entity.js.map +1 -0
  79. package/dist/backend/entities/newsletter-engagement-event.entity.d.ts +12 -0
  80. package/dist/backend/entities/newsletter-engagement-event.entity.d.ts.map +1 -0
  81. package/dist/backend/entities/newsletter-engagement-event.entity.js +54 -0
  82. package/dist/backend/entities/newsletter-engagement-event.entity.js.map +1 -0
  83. package/dist/backend/entities/newsletter-send-record.entity.d.ts +23 -0
  84. package/dist/backend/entities/newsletter-send-record.entity.d.ts.map +1 -0
  85. package/dist/backend/entities/newsletter-send-record.entity.js +89 -0
  86. package/dist/backend/entities/newsletter-send-record.entity.js.map +1 -0
  87. package/dist/backend/entities/newsletter-subscriber-tag.entity.d.ts +6 -0
  88. package/dist/backend/entities/newsletter-subscriber-tag.entity.d.ts.map +1 -0
  89. package/dist/backend/entities/newsletter-subscriber-tag.entity.js +31 -0
  90. package/dist/backend/entities/newsletter-subscriber-tag.entity.js.map +1 -0
  91. package/dist/backend/entities/newsletter-subscriber.entity.d.ts +63 -0
  92. package/dist/backend/entities/newsletter-subscriber.entity.d.ts.map +1 -0
  93. package/dist/backend/entities/newsletter-subscriber.entity.js +147 -0
  94. package/dist/backend/entities/newsletter-subscriber.entity.js.map +1 -0
  95. package/dist/backend/entities/newsletter-suppression.entity.d.ts +14 -0
  96. package/dist/backend/entities/newsletter-suppression.entity.d.ts.map +1 -0
  97. package/dist/backend/entities/newsletter-suppression.entity.js +51 -0
  98. package/dist/backend/entities/newsletter-suppression.entity.js.map +1 -0
  99. package/dist/backend/entities/newsletter-tag.entity.d.ts +12 -0
  100. package/dist/backend/entities/newsletter-tag.entity.d.ts.map +1 -0
  101. package/dist/backend/entities/newsletter-tag.entity.js +53 -0
  102. package/dist/backend/entities/newsletter-tag.entity.js.map +1 -0
  103. package/dist/backend/index.d.ts +120 -0
  104. package/dist/backend/index.d.ts.map +1 -0
  105. package/dist/backend/index.js +139 -0
  106. package/dist/backend/index.js.map +1 -0
  107. package/dist/backend/plugin.d.ts +114 -0
  108. package/dist/backend/plugin.d.ts.map +1 -0
  109. package/dist/backend/plugin.js +171 -0
  110. package/dist/backend/plugin.js.map +1 -0
  111. package/dist/backend/routes.admin.d.ts +27 -0
  112. package/dist/backend/routes.admin.d.ts.map +1 -0
  113. package/dist/backend/routes.admin.js +118 -0
  114. package/dist/backend/routes.admin.js.map +1 -0
  115. package/dist/backend/routes.self.d.ts +15 -0
  116. package/dist/backend/routes.self.d.ts.map +1 -0
  117. package/dist/backend/routes.self.js +28 -0
  118. package/dist/backend/routes.self.js.map +1 -0
  119. package/dist/backend/routes.storefront.d.ts +24 -0
  120. package/dist/backend/routes.storefront.d.ts.map +1 -0
  121. package/dist/backend/routes.storefront.js +79 -0
  122. package/dist/backend/routes.storefront.js.map +1 -0
  123. package/dist/backend/services/audience-resolver.d.ts +20 -0
  124. package/dist/backend/services/audience-resolver.d.ts.map +1 -0
  125. package/dist/backend/services/audience-resolver.js +41 -0
  126. package/dist/backend/services/audience-resolver.js.map +1 -0
  127. package/dist/backend/services/automation.service.d.ts +58 -0
  128. package/dist/backend/services/automation.service.d.ts.map +1 -0
  129. package/dist/backend/services/automation.service.js +301 -0
  130. package/dist/backend/services/automation.service.js.map +1 -0
  131. package/dist/backend/services/campaign-dispatch.service.d.ts +50 -0
  132. package/dist/backend/services/campaign-dispatch.service.d.ts.map +1 -0
  133. package/dist/backend/services/campaign-dispatch.service.js +130 -0
  134. package/dist/backend/services/campaign-dispatch.service.js.map +1 -0
  135. package/dist/backend/services/campaign.service.d.ts +40 -0
  136. package/dist/backend/services/campaign.service.d.ts.map +1 -0
  137. package/dist/backend/services/campaign.service.js +195 -0
  138. package/dist/backend/services/campaign.service.js.map +1 -0
  139. package/dist/backend/services/consent-block-seeder.d.ts +30 -0
  140. package/dist/backend/services/consent-block-seeder.d.ts.map +1 -0
  141. package/dist/backend/services/consent-block-seeder.js +52 -0
  142. package/dist/backend/services/consent-block-seeder.js.map +1 -0
  143. package/dist/backend/services/content.service.d.ts +52 -0
  144. package/dist/backend/services/content.service.d.ts.map +1 -0
  145. package/dist/backend/services/content.service.js +48 -0
  146. package/dist/backend/services/content.service.js.map +1 -0
  147. package/dist/backend/services/custom-field.service.d.ts +16 -0
  148. package/dist/backend/services/custom-field.service.d.ts.map +1 -0
  149. package/dist/backend/services/custom-field.service.js +67 -0
  150. package/dist/backend/services/custom-field.service.js.map +1 -0
  151. package/dist/backend/services/email-block.service.d.ts +26 -0
  152. package/dist/backend/services/email-block.service.d.ts.map +1 -0
  153. package/dist/backend/services/email-block.service.js +120 -0
  154. package/dist/backend/services/email-block.service.js.map +1 -0
  155. package/dist/backend/services/opt-in.service.d.ts +25 -0
  156. package/dist/backend/services/opt-in.service.d.ts.map +1 -0
  157. package/dist/backend/services/opt-in.service.js +46 -0
  158. package/dist/backend/services/opt-in.service.js.map +1 -0
  159. package/dist/backend/services/provider/console-provider.d.ts +32 -0
  160. package/dist/backend/services/provider/console-provider.d.ts.map +1 -0
  161. package/dist/backend/services/provider/console-provider.js +31 -0
  162. package/dist/backend/services/provider/console-provider.js.map +1 -0
  163. package/dist/backend/services/provider/provider-registry.d.ts +51 -0
  164. package/dist/backend/services/provider/provider-registry.d.ts.map +1 -0
  165. package/dist/backend/services/provider/provider-registry.js +84 -0
  166. package/dist/backend/services/provider/provider-registry.js.map +1 -0
  167. package/dist/backend/services/provider/smtp-provider.d.ts +30 -0
  168. package/dist/backend/services/provider/smtp-provider.d.ts.map +1 -0
  169. package/dist/backend/services/provider/smtp-provider.js +42 -0
  170. package/dist/backend/services/provider/smtp-provider.js.map +1 -0
  171. package/dist/backend/services/provider-admin.service.d.ts +39 -0
  172. package/dist/backend/services/provider-admin.service.d.ts.map +1 -0
  173. package/dist/backend/services/provider-admin.service.js +59 -0
  174. package/dist/backend/services/provider-admin.service.js.map +1 -0
  175. package/dist/backend/services/provider-admin.types.d.ts +6 -0
  176. package/dist/backend/services/provider-admin.types.d.ts.map +1 -0
  177. package/dist/backend/services/provider-admin.types.js +2 -0
  178. package/dist/backend/services/provider-admin.types.js.map +1 -0
  179. package/dist/backend/services/queues/newsletter-queues.d.ts +34 -0
  180. package/dist/backend/services/queues/newsletter-queues.d.ts.map +1 -0
  181. package/dist/backend/services/queues/newsletter-queues.js +58 -0
  182. package/dist/backend/services/queues/newsletter-queues.js.map +1 -0
  183. package/dist/backend/services/self.service.d.ts +17 -0
  184. package/dist/backend/services/self.service.d.ts.map +1 -0
  185. package/dist/backend/services/self.service.js +44 -0
  186. package/dist/backend/services/self.service.js.map +1 -0
  187. package/dist/backend/services/stats.service.d.ts +10 -0
  188. package/dist/backend/services/stats.service.d.ts.map +1 -0
  189. package/dist/backend/services/stats.service.js +45 -0
  190. package/dist/backend/services/stats.service.js.map +1 -0
  191. package/dist/backend/services/subscriber-admin.service.d.ts +26 -0
  192. package/dist/backend/services/subscriber-admin.service.d.ts.map +1 -0
  193. package/dist/backend/services/subscriber-admin.service.js +163 -0
  194. package/dist/backend/services/subscriber-admin.service.js.map +1 -0
  195. package/dist/backend/services/subscriber.service.d.ts +91 -0
  196. package/dist/backend/services/subscriber.service.d.ts.map +1 -0
  197. package/dist/backend/services/subscriber.service.js +280 -0
  198. package/dist/backend/services/subscriber.service.js.map +1 -0
  199. package/dist/backend/services/tag.service.d.ts +16 -0
  200. package/dist/backend/services/tag.service.d.ts.map +1 -0
  201. package/dist/backend/services/tag.service.js +79 -0
  202. package/dist/backend/services/tag.service.js.map +1 -0
  203. package/dist/backend/services/token.helper.d.ts +32 -0
  204. package/dist/backend/services/token.helper.d.ts.map +1 -0
  205. package/dist/backend/services/token.helper.js +76 -0
  206. package/dist/backend/services/token.helper.js.map +1 -0
  207. package/dist/backend/services/tracking.service.d.ts +14 -0
  208. package/dist/backend/services/tracking.service.d.ts.map +1 -0
  209. package/dist/backend/services/tracking.service.js +45 -0
  210. package/dist/backend/services/tracking.service.js.map +1 -0
  211. package/dist/manifest.d.ts +192 -0
  212. package/dist/manifest.d.ts.map +1 -0
  213. package/dist/manifest.js +211 -0
  214. package/dist/manifest.js.map +1 -0
  215. package/dist/migrations/20260629T200954_newsletter_init.d.ts +6 -0
  216. package/dist/migrations/20260629T200954_newsletter_init.d.ts.map +1 -0
  217. package/dist/migrations/20260629T200954_newsletter_init.js +261 -0
  218. package/dist/migrations/20260629T200954_newsletter_init.js.map +1 -0
  219. package/dist/migrations/20260830T212736_newsletter_organization_attribution.d.ts +116 -0
  220. package/dist/migrations/20260830T212736_newsletter_organization_attribution.d.ts.map +1 -0
  221. package/dist/migrations/20260830T212736_newsletter_organization_attribution.js +186 -0
  222. package/dist/migrations/20260830T212736_newsletter_organization_attribution.js.map +1 -0
  223. package/dist/migrations/20260903T101752_newsletter_namespace_block_names.d.ts +6 -0
  224. package/dist/migrations/20260903T101752_newsletter_namespace_block_names.d.ts.map +1 -0
  225. package/dist/migrations/20260903T101752_newsletter_namespace_block_names.js +58 -0
  226. package/dist/migrations/20260903T101752_newsletter_namespace_block_names.js.map +1 -0
  227. package/dist/migrations/20260912T125702_newsletter_subscriber_tenant_scope_index.d.ts +23 -0
  228. package/dist/migrations/20260912T125702_newsletter_subscriber_tenant_scope_index.d.ts.map +1 -0
  229. package/dist/migrations/20260912T125702_newsletter_subscriber_tenant_scope_index.js +27 -0
  230. package/dist/migrations/20260912T125702_newsletter_subscriber_tenant_scope_index.js.map +1 -0
  231. package/dist/migrations/index.d.ts +30 -0
  232. package/dist/migrations/index.d.ts.map +1 -0
  233. package/dist/migrations/index.js +35 -0
  234. package/dist/migrations/index.js.map +1 -0
  235. package/docs/newsletter.md +94 -0
  236. package/i18n/en.json +27 -0
  237. package/i18n/pl.json +27 -0
  238. package/package.json +119 -0
  239. package/tailwind.css +14 -0
@@ -0,0 +1,186 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+ /**
3
+ * D-187 — `newsletter_subscribers` gains its organisation, and a row that names
4
+ * a customer account carries one.
5
+ *
6
+ * Feature 087 Group B, class 4 of 4 and the last of the five
7
+ * (`specs/087-tenant-scope-enforcement/`). `NewsletterSubscriber` is
8
+ * `@CustomerScoped`, and until this migration the `allowed-set` arm of
9
+ * `customerFilterCond` had no column to grant on: a sales representative
10
+ * assigned to the buyer's own organisation was shown **none** of their
11
+ * subscribers. The column is what turns that into an answer.
12
+ *
13
+ * ## The column and the read arrive together, on purpose
14
+ *
15
+ * `customerOrganizationColumn` asks the ORM's own metadata whether the filtered
16
+ * entity carries `organizationId`, **per query**, so the entity property in
17
+ * this same merge request is what switches the grant on — there is no third
18
+ * artefact, no flag and no staging. At the same instant the
19
+ * `ORGANIZATION_ATTRIBUTION_PENDING` disclosure retires itself: it is keyed on
20
+ * the column's *absence*.
21
+ *
22
+ * That pairing is why this migration is not "column and backfill". From the
23
+ * moment the grant is live, a row inserted without an organisation is invisible
24
+ * to the representative who serves that organisation, on a screen that has just
25
+ * stopped explaining itself — and MikroORM applies no filter to `INSERT`
26
+ * (`r1-spike.md` §4, measured), so the filter cannot refuse it. The `CHECK`
27
+ * below is the only refusal an `INSERT` has.
28
+ *
29
+ * ## An implication, not an equivalence
30
+ *
31
+ * `customer_account_id is null or organization_id is not null` is FR-010 and
32
+ * FR-011 together and nothing wider: an **owned** row has an organisation, an
33
+ * **ownerless** row need not. This table is ownerless by construction for the
34
+ * storefront's sign-up form — `POST /api/v1/newsletter/subscribe` passes no
35
+ * account at all, so every subscriber who has never signed in is such a row,
36
+ * and on this table they are the majority rather than the exception. Who they
37
+ * belong to is R-6's open question. The equivalence would answer it in the
38
+ * schema, which D-187 declines to do.
39
+ *
40
+ * The shape the implication leaves open — an **ownerless** row still carrying
41
+ * an organisation — is unreachable here, for `inventory`'s reason rather than
42
+ * `pwa`'s. `subscriber.service.ts` writes `customer_account_id` in exactly two
43
+ * places and both of them *acquire* an owner: the `em.create` on first
44
+ * subscribe, and the adoption line that fills in the account when a subscriber
45
+ * who signed up anonymously later signs in. Nothing in this module ever clears
46
+ * it (grep of `packages/modules/newsletter/src` for `customerAccountId`: two
47
+ * writes, both assignments of a non-null id, the rest reads). So there is no
48
+ * de-association direction for the constraint to be silent about.
49
+ *
50
+ * ## The e-mail address is not a derivation
51
+ *
52
+ * `newsletter_subscribers.email` is globally unique and `customer_accounts`
53
+ * has an `email` too, so the backfill below could have matched them and
54
+ * attributed far more rows. It deliberately does not. That match would claim
55
+ * rows the schema does not link, silently and irreversibly, for whoever
56
+ * happened to sign up with the address their employer later registered — it is
57
+ * R-6 answer (2) guessed rather than decided. The only derivation is
58
+ * `customer_account_id`.
59
+ *
60
+ * ## No foreign key
61
+ *
62
+ * Matching `Cart`, `Comparison`, `PushSubscription` and
63
+ * `AvailabilityNotification`, none of which carries one. D-187 withdraws
64
+ * `r1-spike.md` §8's `on delete set null` recommendation as contradicting
65
+ * FR-011: it produces a row that names an account and no organisation, which is
66
+ * precisely the state this migration makes unreachable, and under the
67
+ * constraint below it would abort an unrelated organisation delete with a
68
+ * message about a table the operator was not touching. If one is ever taken
69
+ * here it must be `restrict`, and it is not this migration's decision.
70
+ *
71
+ * ## Derive, then count, then refuse — never delete
72
+ *
73
+ * The derivation is the owning account's own organisation. **On this table the
74
+ * refusal is a real branch, not a formality**: like `push_subscriptions` and
75
+ * `availability_notifications`, and unlike `comparisons`,
76
+ * `newsletter_subscribers` has **no foreign key** on `customer_account_id` —
77
+ * `20260629T200954_newsletter_init.ts` declares it `uuid null` and constrains
78
+ * nothing — so a row naming an account that is gone is representable here.
79
+ *
80
+ * It is nevertheless empty today, and the reason is worth knowing rather than
81
+ * hoping: a customer account is never hard-deleted in this tree.
82
+ * `customer-account-lifecycle-ports.ts` soft-deletes, restores and
83
+ * **anonymises** — the last one rewrites the e-mail and the name and keeps the
84
+ * row — and every one of those still satisfies `organization_id NOT NULL`
85
+ * (D-178), so it still derives. Change any of that and the count below is the
86
+ * only thing that would notice. The anonymise path is worth a second look on
87
+ * *this* table in particular, because it rewrites the very column an e-mail
88
+ * match would have keyed on; deriving from `customer_account_id` is unaffected
89
+ * by it.
90
+ *
91
+ * It raises with the count and up to twenty ids and deletes nothing (D-184), in
92
+ * the shape `20260825T141659_customer_accounts_organization_required.ts`
93
+ * established and the three Group B migrations before this one repeated.
94
+ * Deleting would be particularly wrong here: a subscriber row is a consent
95
+ * record, and dropping one destroys the evidence that the address may be
96
+ * mailed at all.
97
+ *
98
+ * ## Nothing to declare in the manifest
99
+ *
100
+ * `newsletter` declares `customers` in its manifest `dependencies`, whose
101
+ * transitive closure reaches `customer_accounts` and `organizations`, so the
102
+ * table this reads is created before this runs. No foreign key is added, so
103
+ * `fk-dependency-drift` has nothing to say either, and a migration naming
104
+ * another module's table is outside `check:module-boundary`'s population by
105
+ * that check's own rule.
106
+ *
107
+ * The standing guard is
108
+ * `backend/test/integration/tenancy/customer-scoped-organization-completeness.test.ts`,
109
+ * which derives its population from the ORM's metadata rather than from a list
110
+ * — so this class is covered by gaining the column, with no edit to that file.
111
+ */
112
+ export class Migration20260830T212736NewsletterOrganizationAttribution extends Migration {
113
+ async up() {
114
+ // 1. The column. Nullable, because a storefront sign-up legitimately has
115
+ // none (FR-011) and on this table that is the ordinary case rather than
116
+ // the edge.
117
+ this.addSql(`alter table "newsletter_subscribers" add column "organization_id" uuid null;`);
118
+ // 2. Derive the organisation from the account that owns the subscription.
119
+ // Every account carries one since D-178, including a soft-deleted or
120
+ // anonymised one, so this is total for every row whose account is still
121
+ // there. Step 3 is about the rows for which it is not.
122
+ this.addSql(`
123
+ update "newsletter_subscribers" ns
124
+ set "organization_id" = ca."organization_id"
125
+ from "customer_accounts" ca
126
+ where ca."id" = ns."customer_account_id"
127
+ and ns."organization_id" is null;
128
+ `);
129
+ // 3. Count what is left and refuse. This table has no foreign key on
130
+ // `customer_account_id`, so a dangling account id is representable and
131
+ // this branch is real. It deletes nothing (D-184).
132
+ this.addSql(`
133
+ do $$
134
+ declare
135
+ remaining bigint;
136
+ sample text;
137
+ begin
138
+ select count(*) into remaining
139
+ from "newsletter_subscribers"
140
+ where "customer_account_id" is not null
141
+ and "organization_id" is null;
142
+ if remaining > 0 then
143
+ select string_agg(id::text, ', ') into sample from (
144
+ select "id" from "newsletter_subscribers"
145
+ where "customer_account_id" is not null
146
+ and "organization_id" is null
147
+ order by "id" limit 20
148
+ ) s;
149
+ raise exception
150
+ 'D-187: % newsletter_subscribers row(s) still name a customer account with organization_id IS NULL after derivation. First ids: %. Every customer account has an organization (D-178) and none is ever hard-deleted, so these rows name an account that is gone. Do not delete them - they are consent records - and do not match them by e-mail address, which is R-6 guessed. Find out how they got here.',
151
+ remaining, sample;
152
+ end if;
153
+ end $$;
154
+ `);
155
+ // 4. The constraint.
156
+ this.addSql(`
157
+ alter table "newsletter_subscribers"
158
+ add constraint "newsletter_subscribers_organization_attribution_chk"
159
+ check ("customer_account_id" is null or "organization_id" is not null);
160
+ `);
161
+ // 5. The index the entity property declares, named
162
+ // `newsletter_subscribers_organization_idx` — **this table's own style**
163
+ // and not the previous batch's. Every index the init migration wrote
164
+ // here is `newsletter_subscribers_<subject>_idx`, and the one other
165
+ // `*_id` column it indexes is spelled with the subject alone:
166
+ // `sales_channel_id` is `newsletter_subscribers_channel_idx`, not
167
+ // `..._sales_channel_id_index`. `comparisons` uses `idx_<table>_<col>`,
168
+ // `pwa` uses `<table>_<col>_idx` and `inventory` uses
169
+ // `<table>_<col>_index`; each followed its own table, and this follows
170
+ // this one. Whole rather than partial, like every index above it here.
171
+ this.addSql(`
172
+ create index "newsletter_subscribers_organization_idx"
173
+ on "newsletter_subscribers" ("organization_id");
174
+ `);
175
+ }
176
+ async down() {
177
+ // The constraint, the index and the column. The organisations step 2
178
+ // derived go with the column they were written into — there is nothing to
179
+ // preserve, because each of them was already implied by the account its row
180
+ // names.
181
+ this.addSql(`alter table "newsletter_subscribers" drop constraint if exists "newsletter_subscribers_organization_attribution_chk";`);
182
+ this.addSql(`drop index if exists "newsletter_subscribers_organization_idx";`);
183
+ this.addSql(`alter table "newsletter_subscribers" drop column if exists "organization_id";`);
184
+ }
185
+ }
186
+ //# sourceMappingURL=20260830T212736_newsletter_organization_attribution.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"20260830T212736_newsletter_organization_attribution.js","sourceRoot":"","sources":["../../src/migrations/20260830T212736_newsletter_organization_attribution.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAElD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6GG;AACH,MAAM,OAAO,yDAA0D,SAAQ,SAAS;IAC7E,KAAK,CAAC,EAAE;QACf,yEAAyE;QACzE,2EAA2E;QAC3E,eAAe;QACf,IAAI,CAAC,MAAM,CAAC,8EAA8E,CAAC,CAAC;QAE5F,0EAA0E;QAC1E,wEAAwE;QACxE,2EAA2E;QAC3E,0DAA0D;QAC1D,IAAI,CAAC,MAAM,CAAC;;;;;;KAMX,CAAC,CAAC;QAEH,qEAAqE;QACrE,0EAA0E;QAC1E,sDAAsD;QACtD,IAAI,CAAC,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;KAsBX,CAAC,CAAC;QAEH,qBAAqB;QACrB,IAAI,CAAC,MAAM,CAAC;;;;KAIX,CAAC,CAAC;QAEH,mDAAmD;QACnD,4EAA4E;QAC5E,wEAAwE;QACxE,uEAAuE;QACvE,iEAAiE;QACjE,qEAAqE;QACrE,2EAA2E;QAC3E,yDAAyD;QACzD,0EAA0E;QAC1E,0EAA0E;QAC1E,IAAI,CAAC,MAAM,CAAC;;;KAGX,CAAC,CAAC;IACL,CAAC;IAEQ,KAAK,CAAC,IAAI;QACjB,qEAAqE;QACrE,0EAA0E;QAC1E,4EAA4E;QAC5E,SAAS;QACT,IAAI,CAAC,MAAM,CACT,uHAAuH,CACxH,CAAC;QACF,IAAI,CAAC,MAAM,CAAC,iEAAiE,CAAC,CAAC;QAC/E,IAAI,CAAC,MAAM,CACT,+EAA+E,CAChF,CAAC;IACJ,CAAC;CACF"}
@@ -0,0 +1,6 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+ export declare class Migration20260903T101752NewsletterNamespaceBlockNames extends Migration {
3
+ up(): Promise<void>;
4
+ down(): Promise<void>;
5
+ }
6
+ //# sourceMappingURL=20260903T101752_newsletter_namespace_block_names.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"20260903T101752_newsletter_namespace_block_names.d.ts","sourceRoot":"","sources":["../../src/migrations/20260903T101752_newsletter_namespace_block_names.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAmDlD,qBAAa,qDAAsD,SAAQ,SAAS;IACnE,EAAE,IAAI,OAAO,CAAC,IAAI,CAAC;IAOnB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;CAMrC"}
@@ -0,0 +1,58 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+ import { FROZEN_BLOCK_RENAMES, FROZEN_BLOCK_RENAMES_INVERSE, applyRenameFunctionSql, createRenameFunctionSql, dropRenameFunctionSql, } from '@endora-commerce/page-builder-core/migration';
3
+ /**
4
+ * Namespace the Page Builder block names stored in `newsletter`'s 2 `jsonb` columns
5
+ * — feature 096, T407 (`contracts/block-name-migration.md`).
6
+ *
7
+ * **The rewrite is structural, never textual.** Twelve of the 74 renamed names
8
+ * are ordinary English words that occur throughout shop content — `Row`,
9
+ * `Text`, `Image`, `Map`, `Button` among them — so a text substitution over the
10
+ * column would corrupt a `RawHtml` block's markup and every `alt` attribute in
11
+ * the shop. `pg_temp.rename_block_names_newsletter` descends the document and replaces the value of a
12
+ * `type` property in a **node** position and nothing else; the walk is
13
+ * generated from `FROZEN_BLOCK_RENAMES` by
14
+ * `@endora-commerce/page-builder-core/migration`, so five migrations share one
15
+ * definition of what a node is.
16
+ *
17
+ * **This migration belongs to `newsletter` because `newsletter` owns these tables**,
18
+ * not because it owns the new names (`contracts/block-name-migration.md` §2).
19
+ * Some of the names it writes belong to other modules; that creates no
20
+ * obligation on them and **no new manifest `dependencies` edge**, because a
21
+ * block name is a string value inside a JSONB document — not a foreign key and
22
+ * not a table identifier.
23
+ *
24
+ * **It cannot fail on its input.** A name the map does not hold is left
25
+ * byte-identical: an already-namespaced one and an unrecognised one alike,
26
+ * because the map's domain is bare and its codomain is dotted, so the two sets
27
+ * are disjoint. That is also what makes a second run rewrite nothing — FR-013
28
+ * is a property of the map rather than an outcome a branch has to remember to
29
+ * produce, and no dot test appears in this SQL.
30
+ *
31
+ * `down()` applies the inverse over the identical walk and is **exact for the
32
+ * frozen set**. It is partial by construction: a name with no pre-migration
33
+ * form — a block authored after the upgrade, a third party's `acme.Banner` —
34
+ * has nothing to return to and is left alone. That is correct, and it is why
35
+ * the operator pre-flight (§6) is a backup rather than a `down()`.
36
+ */
37
+ /**
38
+ * Named per module: the five rename migrations may run on one pooled session,
39
+ * and a second `create function` over the same name fails. Each drops its own
40
+ * when it is done, and a rollback removes it with everything else — `pg_temp`
41
+ * DDL is transactional.
42
+ */
43
+ const FN = 'rename_block_names_newsletter';
44
+ export class Migration20260903T101752NewsletterNamespaceBlockNames extends Migration {
45
+ async up() {
46
+ this.addSql(createRenameFunctionSql(FN, FROZEN_BLOCK_RENAMES));
47
+ this.addSql(applyRenameFunctionSql(FN, 'newsletter_campaigns', 'content'));
48
+ this.addSql(applyRenameFunctionSql(FN, 'newsletter_email_blocks', 'content'));
49
+ this.addSql(dropRenameFunctionSql(FN));
50
+ }
51
+ async down() {
52
+ this.addSql(createRenameFunctionSql(FN, FROZEN_BLOCK_RENAMES_INVERSE));
53
+ this.addSql(applyRenameFunctionSql(FN, 'newsletter_campaigns', 'content'));
54
+ this.addSql(applyRenameFunctionSql(FN, 'newsletter_email_blocks', 'content'));
55
+ this.addSql(dropRenameFunctionSql(FN));
56
+ }
57
+ }
58
+ //# sourceMappingURL=20260903T101752_newsletter_namespace_block_names.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"20260903T101752_newsletter_namespace_block_names.js","sourceRoot":"","sources":["../../src/migrations/20260903T101752_newsletter_namespace_block_names.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAClD,OAAO,EACL,oBAAoB,EACpB,4BAA4B,EAC5B,sBAAsB,EACtB,uBAAuB,EACvB,qBAAqB,GACtB,MAAM,8CAA8C,CAAC;AAEtD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH;;;;;GAKG;AACH,MAAM,EAAE,GAAG,+BAA+B,CAAC;AAE3C,MAAM,OAAO,qDAAsD,SAAQ,SAAS;IACzE,KAAK,CAAC,EAAE;QACf,IAAI,CAAC,MAAM,CAAC,uBAAuB,CAAC,EAAE,EAAE,oBAAoB,CAAC,CAAC,CAAC;QAC/D,IAAI,CAAC,MAAM,CAAC,sBAAsB,CAAC,EAAE,EAAE,sBAAsB,EAAE,SAAS,CAAC,CAAC,CAAC;QAC3E,IAAI,CAAC,MAAM,CAAC,sBAAsB,CAAC,EAAE,EAAE,yBAAyB,EAAE,SAAS,CAAC,CAAC,CAAC;QAC9E,IAAI,CAAC,MAAM,CAAC,qBAAqB,CAAC,EAAE,CAAC,CAAC,CAAC;IACzC,CAAC;IAEQ,KAAK,CAAC,IAAI;QACjB,IAAI,CAAC,MAAM,CAAC,uBAAuB,CAAC,EAAE,EAAE,4BAA4B,CAAC,CAAC,CAAC;QACvE,IAAI,CAAC,MAAM,CAAC,sBAAsB,CAAC,EAAE,EAAE,sBAAsB,EAAE,SAAS,CAAC,CAAC,CAAC;QAC3E,IAAI,CAAC,MAAM,CAAC,sBAAsB,CAAC,EAAE,EAAE,yBAAyB,EAAE,SAAS,CAAC,CAAC,CAAC;QAC9E,IAAI,CAAC,MAAM,CAAC,qBAAqB,CAAC,EAAE,CAAC,CAAC,CAAC;IACzC,CAAC;CACF"}
@@ -0,0 +1,23 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+ /**
3
+ * The tenant-key index on `newsletter_subscribers.customer_account_id`
4
+ * (feature 050, research §R5).
5
+ *
6
+ * It was created by the platform's frozen
7
+ * `Migration20260717T134752CoreTenantScopeIndexes` until
8
+ * `specs/120-migration-closure-bridge-ownership/` Phase 3. Under D-226 the
9
+ * platform declares no dependencies and so can never name a module's table:
10
+ * an instance that omits `newsletter` had a frozen corpus indexing a table
11
+ * nothing builds. `newsletter_subscribers` is this module's own table, so
12
+ * the closure here is trivial.
13
+ *
14
+ * The statement is the frozen one verbatim, `if not exists` and all. A
15
+ * database that has already applied the frozen migration is offered nothing
16
+ * from it — the storage keys on the class name and holds no checksum — and
17
+ * this one is the no-op it already reads as.
18
+ */
19
+ export declare class Migration20260912T125702NewsletterSubscriberTenantScopeIndex extends Migration {
20
+ up(): Promise<void>;
21
+ down(): Promise<void>;
22
+ }
23
+ //# sourceMappingURL=20260912T125702_newsletter_subscriber_tenant_scope_index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"20260912T125702_newsletter_subscriber_tenant_scope_index.d.ts","sourceRoot":"","sources":["../../src/migrations/20260912T125702_newsletter_subscriber_tenant_scope_index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAElD;;;;;;;;;;;;;;;;GAgBG;AACH,qBAAa,4DAA6D,SAAQ,SAAS;IAC1E,EAAE,IAAI,OAAO,CAAC,IAAI,CAAC;IAMnB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;CAGrC"}
@@ -0,0 +1,27 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+ /**
3
+ * The tenant-key index on `newsletter_subscribers.customer_account_id`
4
+ * (feature 050, research §R5).
5
+ *
6
+ * It was created by the platform's frozen
7
+ * `Migration20260717T134752CoreTenantScopeIndexes` until
8
+ * `specs/120-migration-closure-bridge-ownership/` Phase 3. Under D-226 the
9
+ * platform declares no dependencies and so can never name a module's table:
10
+ * an instance that omits `newsletter` had a frozen corpus indexing a table
11
+ * nothing builds. `newsletter_subscribers` is this module's own table, so
12
+ * the closure here is trivial.
13
+ *
14
+ * The statement is the frozen one verbatim, `if not exists` and all. A
15
+ * database that has already applied the frozen migration is offered nothing
16
+ * from it — the storage keys on the class name and holds no checksum — and
17
+ * this one is the no-op it already reads as.
18
+ */
19
+ export class Migration20260912T125702NewsletterSubscriberTenantScopeIndex extends Migration {
20
+ async up() {
21
+ this.addSql('create index if not exists "newsletter_subscribers_customer_account_id_index" on "newsletter_subscribers" ("customer_account_id");');
22
+ }
23
+ async down() {
24
+ this.addSql('drop index if exists "newsletter_subscribers_customer_account_id_index";');
25
+ }
26
+ }
27
+ //# sourceMappingURL=20260912T125702_newsletter_subscriber_tenant_scope_index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"20260912T125702_newsletter_subscriber_tenant_scope_index.js","sourceRoot":"","sources":["../../src/migrations/20260912T125702_newsletter_subscriber_tenant_scope_index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAElD;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,OAAO,4DAA6D,SAAQ,SAAS;IAChF,KAAK,CAAC,EAAE;QACf,IAAI,CAAC,MAAM,CACT,oIAAoI,CACrI,CAAC;IACJ,CAAC;IAEQ,KAAK,CAAC,IAAI;QACjB,IAAI,CAAC,MAAM,CAAC,0EAA0E,CAAC,CAAC;IAC1F,CAAC;CACF"}
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The `./migrations` subpath — every migration class this module owns, as one
3
+ * ordered `migrations` array.
4
+ *
5
+ * The array is what the platform reads when this module is **installed**:
6
+ * `src/packages/package-runtime.ts` takes `exported['migrations']` and refuses
7
+ * the package outright when it is absent (D-168).
8
+ *
9
+ * Listed in ascending timestamp, which is the order of this module's own
10
+ * migrations and of nothing else (feature 081): a manifest `dependencies` array
11
+ * is the only thing ordering this block against another module's.
12
+ *
13
+ * The **named** exports stay beside the array, and the asymmetry with
14
+ * `./backend` — which publishes an array and no named class (D-168) — is
15
+ * deliberate. `db/migrations-registry.generated.ts` imports each class by name
16
+ * from this specifier, and a migration class name is contract in a way an entity
17
+ * class name is not: `mikro_orm_migrations` persists it, so it is a string every
18
+ * already-migrated database holds.
19
+ *
20
+ * A class that is in neither the array nor the barrel is a migration that does
21
+ * not run: `migration:pending` reports nothing pending and the first symptom is
22
+ * a query against a table nobody created.
23
+ */
24
+ import { Migration20260629T200954NewsletterInit } from './20260629T200954_newsletter_init.js';
25
+ import { Migration20260830T212736NewsletterOrganizationAttribution } from './20260830T212736_newsletter_organization_attribution.js';
26
+ import { Migration20260903T101752NewsletterNamespaceBlockNames } from './20260903T101752_newsletter_namespace_block_names.js';
27
+ import { Migration20260912T125702NewsletterSubscriberTenantScopeIndex } from './20260912T125702_newsletter_subscriber_tenant_scope_index.js';
28
+ export declare const migrations: (typeof Migration20260629T200954NewsletterInit)[];
29
+ export { Migration20260629T200954NewsletterInit, Migration20260830T212736NewsletterOrganizationAttribution, Migration20260903T101752NewsletterNamespaceBlockNames, Migration20260912T125702NewsletterSubscriberTenantScopeIndex, };
30
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,sCAAsC,EAAE,MAAM,sCAAsC,CAAC;AAC9F,OAAO,EAAE,yDAAyD,EAAE,MAAM,0DAA0D,CAAC;AACrI,OAAO,EAAE,qDAAqD,EAAE,MAAM,uDAAuD,CAAC;AAC9H,OAAO,EAAE,4DAA4D,EAAE,MAAM,+DAA+D,CAAC;AAE7I,eAAO,MAAM,UAAU,mDAKtB,CAAC;AAEF,OAAO,EACL,sCAAsC,EACtC,yDAAyD,EACzD,qDAAqD,EACrD,4DAA4D,GAC7D,CAAC"}
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The `./migrations` subpath — every migration class this module owns, as one
3
+ * ordered `migrations` array.
4
+ *
5
+ * The array is what the platform reads when this module is **installed**:
6
+ * `src/packages/package-runtime.ts` takes `exported['migrations']` and refuses
7
+ * the package outright when it is absent (D-168).
8
+ *
9
+ * Listed in ascending timestamp, which is the order of this module's own
10
+ * migrations and of nothing else (feature 081): a manifest `dependencies` array
11
+ * is the only thing ordering this block against another module's.
12
+ *
13
+ * The **named** exports stay beside the array, and the asymmetry with
14
+ * `./backend` — which publishes an array and no named class (D-168) — is
15
+ * deliberate. `db/migrations-registry.generated.ts` imports each class by name
16
+ * from this specifier, and a migration class name is contract in a way an entity
17
+ * class name is not: `mikro_orm_migrations` persists it, so it is a string every
18
+ * already-migrated database holds.
19
+ *
20
+ * A class that is in neither the array nor the barrel is a migration that does
21
+ * not run: `migration:pending` reports nothing pending and the first symptom is
22
+ * a query against a table nobody created.
23
+ */
24
+ import { Migration20260629T200954NewsletterInit } from './20260629T200954_newsletter_init.js';
25
+ import { Migration20260830T212736NewsletterOrganizationAttribution } from './20260830T212736_newsletter_organization_attribution.js';
26
+ import { Migration20260903T101752NewsletterNamespaceBlockNames } from './20260903T101752_newsletter_namespace_block_names.js';
27
+ import { Migration20260912T125702NewsletterSubscriberTenantScopeIndex } from './20260912T125702_newsletter_subscriber_tenant_scope_index.js';
28
+ export const migrations = [
29
+ Migration20260629T200954NewsletterInit,
30
+ Migration20260830T212736NewsletterOrganizationAttribution,
31
+ Migration20260903T101752NewsletterNamespaceBlockNames,
32
+ Migration20260912T125702NewsletterSubscriberTenantScopeIndex,
33
+ ];
34
+ export { Migration20260629T200954NewsletterInit, Migration20260830T212736NewsletterOrganizationAttribution, Migration20260903T101752NewsletterNamespaceBlockNames, Migration20260912T125702NewsletterSubscriberTenantScopeIndex, };
35
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,sCAAsC,EAAE,MAAM,sCAAsC,CAAC;AAC9F,OAAO,EAAE,yDAAyD,EAAE,MAAM,0DAA0D,CAAC;AACrI,OAAO,EAAE,qDAAqD,EAAE,MAAM,uDAAuD,CAAC;AAC9H,OAAO,EAAE,4DAA4D,EAAE,MAAM,+DAA+D,CAAC;AAE7I,MAAM,CAAC,MAAM,UAAU,GAAG;IACxB,sCAAsC;IACtC,yDAAyD;IACzD,qDAAqD;IACrD,4DAA4D;CAC7D,CAAC;AAEF,OAAO,EACL,sCAAsC,EACtC,yDAAyD,EACzD,qDAAqD,EACrD,4DAA4D,GAC7D,CAAC"}
@@ -0,0 +1,94 @@
1
+ ---
2
+ title: newsletter
3
+ description: Own-infrastructure newsletter — subscriber list, tags, segments, one-off campaigns and multi-step automations with per-channel opt-in
4
+ ---
5
+
6
+ # `newsletter`
7
+
8
+ Own-infrastructure newsletter sending. Lets operators grow a subscriber list,
9
+ segment it with **tags** and **custom fields**, and reach it through one-off
10
+ **campaigns** and multi-step **automations** (linear send/wait sequences).
11
+ Storefront visitors and signed-in customers subscribe with a per-Sales-Channel
12
+ **opt-in** model; every email carries a working unsubscribe link. Content reuses
13
+ the email-safe renderer and `{{var}}/{{if}}/{{for}}` directive engine from the
14
+ `transactional_emails` stack (`@endora-commerce/email-components`). Bulk delivery goes
15
+ through the module's own configurable **sending provider** (an SMTP adapter that
16
+ reaches Amazon SES SMTP, Mailgun, or any relay), independent of the
17
+ transactional-email transport. The whole module can be **enabled/disabled** so
18
+ it never collides with an external ESP (MailerLite, GetResponse, …).
19
+
20
+ ## Concepts
21
+
22
+ - **Subscriber** — keyed by email (global identity). Status is `pending` →
23
+ `active` → `unsubscribed` / `deactivated`. Re-submitting an email merges tags
24
+ and custom fields rather than duplicating. A separate **suppression** list
25
+ (unsubscribe / bounce / complaint), keyed by email, survives deletion and
26
+ overrides all targeting.
27
+ - **Opt-in** — per Sales Channel via Settings `newsletter.opt_in_mode`
28
+ (`single` | `double`). Double opt-in issues a signed, TTL-bounded confirmation
29
+ link; unconfirmed `pending` subscribers expire after
30
+ `newsletter.confirm_ttl_hours`.
31
+ - **Tags & custom fields** — operator-defined; tags drive campaign targeting and
32
+ automation triggers, custom fields enrich subscribers (set via API, Admin UI,
33
+ or signup) and feed automation criteria.
34
+ - **Campaign** — a one-off send to `all` / a manual `group` / a `tag` / a
35
+ `tag_list`. Authored in the shared email Page Builder (same palette as
36
+ transactional emails) with subject + Puck content tree + variables; previewed
37
+ with sample data; sent now or scheduled.
38
+ - **Automation** — a linear `send` / `wait N days` sequence triggered by
39
+ all/tag/tag-list. Send steps use the same email Page Builder. The step model
40
+ is designed to extend to conditional branching later without rework.
41
+ - **Email blocks** — reusable email-safe fragments edited with the same Puck
42
+ editor and embeddable via `EmailInsertBlock` where configured.
43
+ - **Variables** — newsletter catalogue includes `subscriber.email`,
44
+ `customFields.*`, `unsubscribeUrl`, `webviewUrl`, `channel.id`, plus branding
45
+ keys; the admin **Insert variable** picker works on subject and content.
46
+ - **Provider** — selected + configured in the Admin UI; the SMTP password is
47
+ stored as a Settings `secret` (AES-256-GCM, write-only at the boundary).
48
+
49
+ ## Delivery
50
+
51
+ Dispatch is queue-backed on Redis/BullMQ:
52
+
53
+ - `newsletter.campaign.plan` — resolves the audience and **atomically claims** a
54
+ `newsletter_send_records` row per recipient (`INSERT … ON CONFLICT DO
55
+ NOTHING`), then enqueues a send job for each freshly-claimed recipient.
56
+ - `newsletter.send` — renders + dispatches one recipient, idempotent on the
57
+ send-record id (used as the provider `messageId`), throttled by the
58
+ send-worker rate limiter (`newsletter.rate_limit_per_second`).
59
+ - `newsletter.automation.step` — executes a step; `wait` steps schedule the next
60
+ step as a BullMQ **delayed job**. A run self-cancels if its subscriber
61
+ unsubscribes mid-sequence.
62
+
63
+ Workers run under the separable `worker.ts` entrypoint and pause when the module
64
+ is disabled. Producers only enqueue — never inline-execute — so N≥2 workers
65
+ never double-send.
66
+
67
+ ## Engagement
68
+
69
+ Open tracking uses a 1×1 pixel; click tracking rewrites links through a signed
70
+ redirect. Per-campaign counts of sent / delivered / failed / opened / clicked
71
+ (plus per-link clicks) are aggregated from `newsletter_send_records` and
72
+ `newsletter_engagement_events`. Tracking can be disabled per campaign.
73
+
74
+ ## Permissions
75
+
76
+ - `newsletter:read` — view subscribers, campaigns, automations, stats.
77
+ - `newsletter:write` — manage subscribers, campaigns, automations, blocks, and
78
+ the sending provider.
79
+
80
+ ## Storefront
81
+
82
+ A reusable signup component (server action, tag-attachable), a double-opt-in
83
+ confirmation landing, an unsubscribe page (optional reason), and an account
84
+ panel showing subscription status + tags with subscribe/unsubscribe actions.
85
+
86
+ ## Schema
87
+
88
+ Migration `083_newsletter_init.ts` creates `newsletter_subscribers`,
89
+ `newsletter_tags`, `newsletter_subscriber_tags`, `newsletter_custom_fields`,
90
+ `newsletter_suppressions`, `newsletter_email_blocks`(+ channel bridge),
91
+ `newsletter_campaigns`(+ group bridge), `newsletter_send_records`,
92
+ `newsletter_engagement_events`, `newsletter_automations`, and
93
+ `newsletter_automation_runs`. Provider config + opt-in mode live in the
94
+ **Settings** module (no bespoke credential table).
package/i18n/en.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "actions.openNewsletter.label": "Newsletter",
3
+ "actions.openNewsletter.description": "Manage newsletter subscribers, campaigns, and automations",
4
+ "actions.newCampaign.label": "New newsletter campaign",
5
+ "actions.newCampaign.description": "Create and send a new newsletter campaign",
6
+ "newsletter.nav.subscribers": "Subscribers",
7
+ "newsletter.nav.campaigns": "Campaigns",
8
+ "newsletter.nav.automations": "Automations",
9
+ "newsletter.nav.tags": "Tags",
10
+ "newsletter.nav.customFields": "Custom fields",
11
+ "newsletter.nav.provider": "Sending provider",
12
+ "newsletter.editor.viewportDesktop": "Desktop mail (600px)",
13
+ "newsletter.editor.viewportNarrow": "Narrow (320px)",
14
+ "newsletter.editor.insertVariable": "Insert variable",
15
+ "newsletter.editor.variable": "Variable",
16
+ "newsletter.editor.livePreview": "Live HTML preview",
17
+ "actions.openCampaigns.label": "Newsletter campaigns",
18
+ "actions.openCampaigns.description": "Newsletter campaigns & sends",
19
+ "actions.openAutomations.label": "Newsletter automations",
20
+ "actions.openAutomations.description": "Automated newsletter workflows",
21
+ "nav.subscribers.label": "Subscribers",
22
+ "nav.campaigns.label": "Campaigns",
23
+ "nav.automations.label": "Automations",
24
+ "nav.tags.label": "Tags & Fields",
25
+ "nav.blocks.label": "Blocks",
26
+ "nav.provider.label": "Provider"
27
+ }
package/i18n/pl.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "actions.openNewsletter.label": "Newsletter",
3
+ "actions.openNewsletter.description": "Zarządzaj subskrybentami, kampaniami i automatyzacjami newslettera",
4
+ "actions.newCampaign.label": "Nowa kampania newslettera",
5
+ "actions.newCampaign.description": "Utwórz i wyślij nową kampanię newslettera",
6
+ "newsletter.nav.subscribers": "Subskrybenci",
7
+ "newsletter.nav.campaigns": "Kampanie",
8
+ "newsletter.nav.automations": "Automatyzacje",
9
+ "newsletter.nav.tags": "Tagi",
10
+ "newsletter.nav.customFields": "Pola dodatkowe",
11
+ "newsletter.nav.provider": "Usługa wysyłki",
12
+ "newsletter.editor.viewportDesktop": "Mail desktop (600px)",
13
+ "newsletter.editor.viewportNarrow": "Wąski (320px)",
14
+ "newsletter.editor.insertVariable": "Wstaw zmienną",
15
+ "newsletter.editor.variable": "Zmienna",
16
+ "newsletter.editor.livePreview": "Podgląd HTML na żywo",
17
+ "actions.openCampaigns.label": "Kampanie newslettera",
18
+ "actions.openCampaigns.description": "Kampanie newslettera i wysyłki",
19
+ "actions.openAutomations.label": "Automatyzacje newslettera",
20
+ "actions.openAutomations.description": "Automatyczne procesy newslettera",
21
+ "nav.subscribers.label": "Subskrybenci",
22
+ "nav.campaigns.label": "Kampanie",
23
+ "nav.automations.label": "Automatyzacje",
24
+ "nav.tags.label": "Tagi i pola",
25
+ "nav.blocks.label": "Bloki",
26
+ "nav.provider.label": "Usługa wysyłki"
27
+ }