backend-manager 5.0.154 → 5.0.156

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 (35) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/CLAUDE.md +2 -2
  3. package/package.json +1 -1
  4. package/src/cli/commands/setup-tests/firestore-indexes-required.js +1 -1
  5. package/src/cli/commands/setup-tests/functions-package.js +3 -1
  6. package/src/cli/commands/setup-tests/{required-indexes.js → helpers/required-indexes.js} +22 -0
  7. package/src/cli/commands/setup-tests/helpers/seed-campaigns.js +136 -0
  8. package/src/cli/commands/setup-tests/index.js +2 -0
  9. package/src/cli/commands/setup-tests/marketing-campaigns-seeded.js +109 -0
  10. package/src/manager/cron/daily/marketing-prune.js +140 -0
  11. package/src/manager/cron/frequent/marketing-campaigns.js +158 -0
  12. package/src/manager/events/auth/on-create.js +36 -1
  13. package/src/manager/libraries/email/constants.js +46 -2
  14. package/src/manager/libraries/email/index.js +13 -3
  15. package/src/manager/libraries/email/marketing/index.js +123 -33
  16. package/src/manager/libraries/email/providers/beehiiv.js +90 -0
  17. package/src/manager/libraries/email/providers/sendgrid.js +168 -1
  18. package/src/manager/libraries/email/transactional/index.js +17 -0
  19. package/src/manager/libraries/email/utm.js +116 -0
  20. package/src/manager/libraries/notification.js +223 -0
  21. package/src/manager/routes/admin/notification/post.js +16 -241
  22. package/src/manager/routes/marketing/campaign/delete.js +45 -0
  23. package/src/manager/routes/marketing/campaign/get.js +69 -0
  24. package/src/manager/routes/marketing/campaign/post.js +161 -0
  25. package/src/manager/routes/marketing/campaign/put.js +122 -0
  26. package/src/manager/routes/user/data-request/delete.js +3 -1
  27. package/src/manager/routes/user/data-request/get.js +3 -1
  28. package/src/manager/routes/user/data-request/post.js +3 -1
  29. package/src/manager/routes/user/delete.js +4 -3
  30. package/src/manager/routes/user/signup/post.js +10 -8
  31. package/src/manager/schemas/marketing/campaign/delete.js +6 -0
  32. package/src/manager/schemas/marketing/campaign/get.js +11 -0
  33. package/src/manager/schemas/marketing/campaign/post.js +35 -0
  34. package/src/manager/schemas/marketing/campaign/put.js +35 -0
  35. package/templates/backend-manager-config.json +3 -0
package/CHANGELOG.md CHANGED
@@ -14,6 +14,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
14
14
  - `Fixed` for any bug fixes.
15
15
  - `Security` in case of vulnerabilities.
16
16
 
17
+ # [5.0.156] - 2026-03-17
18
+ ### Added
19
+ - Marketing campaign system with full CRUD routes (`POST/GET/PUT/DELETE /marketing/campaign`)
20
+ - Calendar-backed scheduling: campaigns stored in `marketing-campaigns` Firestore collection, picked up by `bm_cronFrequent`
21
+ - Multi-provider campaign dispatch: SendGrid (Single Send) + Beehiiv (Post) + Push (FCM)
22
+ - Recurring campaigns with `recurrence` field — cron creates history docs and advances `sendAt`
23
+ - Markdown → HTML conversion at send time for campaign content
24
+ - UTM auto-tagging on brand domain links for both marketing and transactional emails (`libraries/email/utm.js`)
25
+ - Shared notification library (`libraries/notification.js`) extracted from admin route
26
+ - SEGMENTS SSOT dictionary in `constants.js` — 22 segments (subscription, lifecycle, engagement)
27
+ - Runtime segment ID resolution: `resolveSegmentIds()` maps SSOT keys to SendGrid segment IDs
28
+ - Contact pruning cron (`cron/daily/marketing-prune.js`) — monthly re-engagement + deletion of inactive contacts
29
+ - SendGrid `getSegmentContacts()` and `bulkDeleteContacts()` for segment export + batch deletion
30
+ - Seed campaigns via `npx bm setup`: `_recurring-quarterly-sale` (SendGrid) and `_recurring-weekly-newsletter` (Beehiiv) with enforced fields
31
+ - `marketing.prune.enabled` config option (default: true)
32
+ - Provider name extraction from OAuth on signup (Google, Facebook, etc.)
33
+ - Personalized greetings in welcome, checkup, deletion, and data request emails
34
+
35
+ ### Changed
36
+ - `sendCampaign()` refactored for multi-provider dispatch with automatic SSOT segment key → provider ID translation
37
+ - `POST /admin/notification` slimmed down to use shared notification library
38
+ - Setup test data files (`required-indexes.js`, `seed-campaigns.js`) moved to `helpers/` directory
39
+
40
+ # [5.0.155] - 2026-03-16
41
+ ### Added
42
+ - Setup test now ensures consuming project `functions/package.json` has `"private": true` to prevent accidental npm publish
43
+
17
44
  # [5.0.154] - 2026-03-16
18
45
  ### Changed
19
46
  - Add `display` property to all marketing FIELDS entries so display names are defined in the SSOT
package/CLAUDE.md CHANGED
@@ -1102,7 +1102,7 @@ BEM syncs user data to marketing providers (SendGrid, Beehiiv) as custom fields.
1102
1102
 
1103
1103
  8. **Increment usage before update** - Call `usage.increment()` then `usage.update()`
1104
1104
 
1105
- 9. **Add Firestore composite indexes for new compound queries** - Any new Firestore query using multiple `.where()` clauses or `.where()` + `.orderBy()` requires a composite index. Add it to `src/cli/commands/setup-tests/required-indexes.js` (the SSOT). Consumer projects pick these up via `npx bm setup`, which syncs them into `firestore.indexes.json`. Without the index, the query will crash with `FAILED_PRECONDITION` in production.
1105
+ 9. **Add Firestore composite indexes for new compound queries** - Any new Firestore query using multiple `.where()` clauses or `.where()` + `.orderBy()` requires a composite index. Add it to `src/cli/commands/setup-tests/helpers/required-indexes.js` (the SSOT). Consumer projects pick these up via `npx bm setup`, which syncs them into `firestore.indexes.json`. Without the index, the query will crash with `FAILED_PRECONDITION` in production.
1106
1106
 
1107
1107
  ## Key Files Reference
1108
1108
 
@@ -1132,7 +1132,7 @@ BEM syncs user data to marketing providers (SendGrid, Beehiiv) as custom fields.
1132
1132
  | Stripe library | `src/manager/libraries/payment/processors/stripe.js` |
1133
1133
  | PayPal library | `src/manager/libraries/payment/processors/paypal.js` |
1134
1134
  | Order ID generator | `src/manager/libraries/payment/order-id.js` |
1135
- | Required Firestore indexes (SSOT) | `src/cli/commands/setup-tests/required-indexes.js` |
1135
+ | Required Firestore indexes (SSOT) | `src/cli/commands/setup-tests/helpers/required-indexes.js` |
1136
1136
  | Test accounts | `src/test/test-accounts.js` |
1137
1137
 
1138
1138
  ## Environment Detection
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.154",
3
+ "version": "5.0.156",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -2,7 +2,7 @@ const BaseTest = require('./base-test');
2
2
  const jetpack = require('fs-jetpack');
3
3
  const _ = require('lodash');
4
4
  const chalk = require('chalk');
5
- const requiredIndexes = require('./required-indexes');
5
+ const requiredIndexes = require('./helpers/required-indexes');
6
6
 
7
7
  class FirestoreIndexesRequiredTest extends BaseTest {
8
8
  getName() {
@@ -10,13 +10,15 @@ class FunctionsPackageTest extends BaseTest {
10
10
  return !!this.context.package
11
11
  && !!this.context.package.dependencies
12
12
  && !!this.context.package.devDependencies
13
- && !!this.context.package.version;
13
+ && !!this.context.package.version
14
+ && this.context.package.private === true;
14
15
  }
15
16
 
16
17
  async fix() {
17
18
  this.context.package.dependencies = this.context.package.dependencies || {};
18
19
  this.context.package.devDependencies = this.context.package.devDependencies || {};
19
20
  this.context.package.version = this.context.package.version || '0.0.1';
21
+ this.context.package.private = true;
20
22
 
21
23
  jetpack.write(`${this.self.firebaseProjectPath}/functions/package.json`, JSON.stringify(this.context.package, null, 2));
22
24
  }
@@ -46,4 +46,26 @@ module.exports = [
46
46
  { fieldPath: 'nextReminderAt', order: 'ASCENDING' },
47
47
  ],
48
48
  },
49
+
50
+ // Marketing campaigns cron — find pending campaigns ready to send
51
+ // Query: .where('status', '==', 'pending').where('sendAt', '<=', now)
52
+ {
53
+ collectionGroup: 'marketing-campaigns',
54
+ queryScope: 'COLLECTION',
55
+ fields: [
56
+ { fieldPath: 'status', order: 'ASCENDING' },
57
+ { fieldPath: 'sendAt', order: 'ASCENDING' },
58
+ ],
59
+ },
60
+
61
+ // GET /marketing/campaign — list by type + sendAt range
62
+ // Query: .where('type', '==', type).where('sendAt', '>=', start).where('sendAt', '<=', end)
63
+ {
64
+ collectionGroup: 'marketing-campaigns',
65
+ queryScope: 'COLLECTION',
66
+ fields: [
67
+ { fieldPath: 'type', order: 'ASCENDING' },
68
+ { fieldPath: 'sendAt', order: 'ASCENDING' },
69
+ ],
70
+ },
49
71
  ];
@@ -0,0 +1,136 @@
1
+ const moment = require('moment');
2
+
3
+ /**
4
+ * Seed marketing campaigns — recurring templates created/enforced on setup.
5
+ *
6
+ * Each seed defines:
7
+ * - id: Stable Firestore doc ID (prefixed with _ for grouping)
8
+ * - doc: Full document for initial creation
9
+ * - enforced: Fields that MUST match on every setup run (overwritten if changed)
10
+ *
11
+ * Fields NOT in `enforced` are only set on creation and never touched again,
12
+ * allowing runtime changes (sendAt advances, status changes, content edits).
13
+ */
14
+
15
+ /**
16
+ * Get the next occurrence of a quarterly date from now.
17
+ * Quarters: Jan 1, Apr 1, Jul 1, Oct 1.
18
+ */
19
+ function nextQuarter(hour) {
20
+ const now = moment.utc();
21
+ const quarters = [
22
+ moment.utc({ month: 0, day: 1, hour }),
23
+ moment.utc({ month: 3, day: 1, hour }),
24
+ moment.utc({ month: 6, day: 1, hour }),
25
+ moment.utc({ month: 9, day: 1, hour }),
26
+ ];
27
+
28
+ for (const q of quarters) {
29
+ if (q.year(now.year()).isAfter(now)) {
30
+ return q.year(now.year()).unix();
31
+ }
32
+ }
33
+
34
+ return quarters[0].year(now.year() + 1).unix();
35
+ }
36
+
37
+ /**
38
+ * Get the next occurrence of a specific weekday.
39
+ * @param {number} dayOfWeek - 0=Sunday, 1=Monday, ..., 6=Saturday
40
+ * @param {number} hour - Hour (UTC)
41
+ */
42
+ function nextWeekday(dayOfWeek, hour) {
43
+ const next = moment.utc().startOf('day').hour(hour);
44
+
45
+ while (next.day() !== dayOfWeek || next.isBefore(moment.utc())) {
46
+ next.add(1, 'day');
47
+ }
48
+
49
+ return next.unix();
50
+ }
51
+
52
+ function buildSeedCampaigns() {
53
+ const now = moment.utc();
54
+ const nowISO = now.toISOString();
55
+ const nowUNIX = now.unix();
56
+
57
+ return [
58
+ {
59
+ id: '_recurring-quarterly-sale',
60
+ doc: {
61
+ settings: {
62
+ name: 'Quarterly Sale',
63
+ subject: 'Limited Time — Upgrade & Save!',
64
+ preheader: 'Our biggest discount this quarter',
65
+ content: [
66
+ '# Quarterly Sale',
67
+ '',
68
+ 'For a limited time, upgrade your plan and save big.',
69
+ '',
70
+ 'Don\'t miss out — this offer ends soon!',
71
+ ].join('\n'),
72
+ template: 'default',
73
+ sender: 'marketing',
74
+ providers: ['sendgrid'],
75
+ segments: ['subscription_free'],
76
+ excludeSegments: [],
77
+ },
78
+ sendAt: nextQuarter(14),
79
+ status: 'pending',
80
+ type: 'email',
81
+ recurrence: {
82
+ pattern: 'quarterly',
83
+ hour: 14,
84
+ },
85
+ metadata: {
86
+ created: { timestamp: nowISO, timestampUNIX: nowUNIX },
87
+ updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
88
+ },
89
+ },
90
+ // Fields enforced on every setup run (deep path → value)
91
+ enforced: {
92
+ 'type': 'email',
93
+ 'recurrence.pattern': 'quarterly',
94
+ 'recurrence.hour': 14,
95
+ 'settings.providers': ['sendgrid'],
96
+ 'settings.sender': 'marketing',
97
+ 'settings.segments': ['subscription_free'],
98
+ },
99
+ },
100
+ {
101
+ id: '_recurring-weekly-newsletter',
102
+ doc: {
103
+ settings: {
104
+ name: 'Weekly Newsletter',
105
+ subject: 'This Week\'s Update',
106
+ preheader: 'News, tips, and updates',
107
+ content: '',
108
+ sender: 'newsletter',
109
+ providers: ['beehiiv'],
110
+ },
111
+ sendAt: nextWeekday(1, 10),
112
+ status: 'pending',
113
+ type: 'email',
114
+ recurrence: {
115
+ pattern: 'weekly',
116
+ hour: 10,
117
+ day: 1,
118
+ },
119
+ metadata: {
120
+ created: { timestamp: nowISO, timestampUNIX: nowUNIX },
121
+ updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
122
+ },
123
+ },
124
+ enforced: {
125
+ 'type': 'email',
126
+ 'recurrence.pattern': 'weekly',
127
+ 'recurrence.hour': 10,
128
+ 'recurrence.day': 1,
129
+ 'settings.providers': ['beehiiv'],
130
+ 'settings.sender': 'newsletter',
131
+ },
132
+ },
133
+ ];
134
+ }
135
+
136
+ module.exports = { buildSeedCampaigns };
@@ -37,6 +37,7 @@ const HostingFolderTest = require('./hosting-folder');
37
37
  const PublicHtmlFilesTest = require('./public-html-files');
38
38
  const FirestoreIndexesRequiredTest = require('./firestore-indexes-required');
39
39
  const LegacyTestsCleanupTest = require('./legacy-tests-cleanup');
40
+ const MarketingCampaignsSeededTest = require('./marketing-campaigns-seeded');
40
41
 
41
42
  /**
42
43
  * Get all tests in the order they should run
@@ -78,6 +79,7 @@ function getTests(context) {
78
79
  new HostingFolderTest(context),
79
80
  new PublicHtmlFilesTest(context),
80
81
  new LegacyTestsCleanupTest(context),
82
+ new MarketingCampaignsSeededTest(context),
81
83
  ];
82
84
  }
83
85
 
@@ -0,0 +1,109 @@
1
+ const BaseTest = require('./base-test');
2
+ const chalk = require('chalk');
3
+ const _ = require('lodash');
4
+ const { buildSeedCampaigns } = require('./helpers/seed-campaigns');
5
+
6
+ class MarketingCampaignsSeededTest extends BaseTest {
7
+ getName() {
8
+ return 'marketing campaigns seeded in Firestore';
9
+ }
10
+
11
+ async run() {
12
+ const admin = this._getAdmin();
13
+
14
+ if (!admin) {
15
+ return true; // Can't connect — skip gracefully
16
+ }
17
+
18
+ const seeds = buildSeedCampaigns();
19
+
20
+ for (const seed of seeds) {
21
+ const doc = await admin.firestore().doc(`marketing-campaigns/${seed.id}`).get();
22
+
23
+ // Doc doesn't exist → fail
24
+ if (!doc.exists) {
25
+ return false;
26
+ }
27
+
28
+ // Check enforced fields
29
+ const data = doc.data();
30
+
31
+ for (const [path, expected] of Object.entries(seed.enforced)) {
32
+ const actual = _.get(data, path);
33
+
34
+ if (!_.isEqual(actual, expected)) {
35
+ return false;
36
+ }
37
+ }
38
+ }
39
+
40
+ return true;
41
+ }
42
+
43
+ async fix() {
44
+ const admin = this._getAdmin();
45
+
46
+ if (!admin) {
47
+ console.log(chalk.yellow(' ⚠ No Firebase connection — skipping campaign seeding'));
48
+ console.log(chalk.yellow(' Run from a project with service-account.json to seed'));
49
+ return;
50
+ }
51
+
52
+ const seeds = buildSeedCampaigns();
53
+
54
+ for (const seed of seeds) {
55
+ const docRef = admin.firestore().doc(`marketing-campaigns/${seed.id}`);
56
+ const doc = await docRef.get();
57
+
58
+ // Doc doesn't exist → create it
59
+ if (!doc.exists) {
60
+ await docRef.set(seed.doc);
61
+ console.log(chalk.green(` + Created ${chalk.cyan(seed.id)}: ${seed.doc.settings.name}`));
62
+ continue;
63
+ }
64
+
65
+ // Doc exists → check and fix enforced fields
66
+ const data = doc.data();
67
+ const updates = {};
68
+
69
+ for (const [path, expected] of Object.entries(seed.enforced)) {
70
+ const actual = _.get(data, path);
71
+
72
+ if (!_.isEqual(actual, expected)) {
73
+ _.set(updates, path, expected);
74
+ console.log(chalk.yellow(` ↻ ${seed.id}: ${chalk.cyan(path)} ${chalk.dim(JSON.stringify(actual))} → ${chalk.bold(JSON.stringify(expected))}`));
75
+ }
76
+ }
77
+
78
+ if (Object.keys(updates).length) {
79
+ updates.metadata = {
80
+ updated: {
81
+ timestamp: new Date().toISOString(),
82
+ timestampUNIX: Math.round(Date.now() / 1000),
83
+ },
84
+ };
85
+
86
+ await docRef.set(updates, { merge: true });
87
+ } else {
88
+ console.log(chalk.dim(` ✓ ${seed.id} — all enforced fields correct`));
89
+ }
90
+ }
91
+ }
92
+
93
+ _getAdmin() {
94
+ try {
95
+ const { initFirebase } = require('../firebase-init');
96
+ const { admin } = initFirebase({
97
+ firebaseProjectPath: this.self.firebaseProjectPath,
98
+ emulator: false,
99
+ });
100
+
101
+ return admin;
102
+ } catch (e) {
103
+ console.log(chalk.dim(` (firebase-init failed: ${e.message})`));
104
+ return null;
105
+ }
106
+ }
107
+ }
108
+
109
+ module.exports = MarketingCampaignsSeededTest;
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Marketing contact pruning cron job
3
+ *
4
+ * Runs daily but only acts on the 1st of each month.
5
+ * Two-stage process using SSOT segment keys from constants.js:
6
+ *
7
+ * Stage 1 (engagement_inactive_5m):
8
+ * → Send re-engagement email via sendCampaign
9
+ * → Excludes engagement_inactive_6m (those get pruned instead)
10
+ *
11
+ * Stage 2 (engagement_inactive_6m):
12
+ * → Export contacts from segment, bulk delete
13
+ * → Excludes subscription_paid (never prune paying customers)
14
+ *
15
+ * Segment keys are resolved to provider-specific IDs at runtime.
16
+ * Requires marketing.prune.enabled = true in backend-manager-config.json.
17
+ *
18
+ * Runs on bm_cronDaily.
19
+ */
20
+ const sendgridProvider = require('../../libraries/email/providers/sendgrid.js');
21
+
22
+ module.exports = async ({ Manager, assistant }) => {
23
+ // Only run on the 1st of the month
24
+ if (new Date().getDate() !== 1) {
25
+ return;
26
+ }
27
+
28
+ if (Manager.config?.marketing?.prune?.enabled === false) {
29
+ assistant.log('Marketing prune: disabled');
30
+ return;
31
+ }
32
+
33
+ assistant.log('Marketing prune: Starting monthly prune cycle');
34
+
35
+ // --- Stage 1: Re-engagement email ---
36
+ await stageReengage(Manager, assistant);
37
+
38
+ // --- Stage 2: Delete inactive contacts ---
39
+ await stagePrune(Manager, assistant);
40
+
41
+ assistant.log('Marketing prune: Completed');
42
+ };
43
+
44
+ /**
45
+ * Stage 1: Send re-engagement email to contacts inactive 5+ months
46
+ * (excluding 6+ months — those get pruned in stage 2)
47
+ */
48
+ async function stageReengage(Manager, assistant) {
49
+ assistant.log('Marketing prune: Stage 1 — Re-engagement');
50
+
51
+ const mailer = Manager.Email(assistant);
52
+ const brand = Manager.config?.brand;
53
+
54
+ const result = await mailer.sendCampaign({
55
+ name: 'Re-engagement: Are you still with us?',
56
+ subject: `We miss you at ${brand?.name || 'our service'}!`,
57
+ preheader: 'Update your preferences or say goodbye',
58
+ content: [
59
+ '# We miss you!',
60
+ '',
61
+ 'It\'s been a while since you\'ve opened one of our emails. We want to make sure we\'re sending you content you actually want.',
62
+ '',
63
+ '**If you\'d like to keep hearing from us**, simply open this email — no action needed!',
64
+ '',
65
+ 'If we don\'t hear from you, we\'ll remove you from our mailing list next month to keep your inbox clean.',
66
+ '',
67
+ `Thanks for being part of the ${brand?.name || ''} community.`,
68
+ ].join('\n'),
69
+ sender: 'hello',
70
+ segments: ['engagement_inactive_5m'],
71
+ excludeSegments: ['engagement_inactive_6m'],
72
+ sendAt: 'now',
73
+ });
74
+
75
+ assistant.log('Marketing prune: Re-engagement result:', result);
76
+ }
77
+
78
+ /**
79
+ * Stage 2: Delete contacts inactive 6+ months.
80
+ * Resolves segment IDs from SSOT keys at runtime.
81
+ * Excludes paying customers.
82
+ */
83
+ async function stagePrune(Manager, assistant) {
84
+ assistant.log('Marketing prune: Stage 2 — Prune');
85
+
86
+ const marketing = Manager.config?.marketing || {};
87
+
88
+ // --- SendGrid ---
89
+ if (marketing.sendgrid?.enabled !== false && process.env.SENDGRID_API_KEY) {
90
+ const segmentIdMap = await sendgridProvider.resolveSegmentIds();
91
+ const pruneSegmentId = segmentIdMap['engagement_inactive_6m'];
92
+
93
+ if (!pruneSegmentId) {
94
+ assistant.error('Marketing prune: engagement_inactive_6m segment not found in SendGrid');
95
+ return;
96
+ }
97
+
98
+ const exportResult = await sendgridProvider.getSegmentContacts(pruneSegmentId);
99
+
100
+ if (!exportResult.success) {
101
+ assistant.error('Marketing prune: Failed to export segment:', exportResult.error);
102
+ return;
103
+ }
104
+
105
+ if (exportResult.contacts.length === 0) {
106
+ assistant.log('Marketing prune: No contacts to prune');
107
+ return;
108
+ }
109
+
110
+ assistant.log(`Marketing prune: Deleting ${exportResult.contacts.length} contacts`);
111
+
112
+ const ids = exportResult.contacts.map(c => c.id).filter(Boolean);
113
+ let totalDeleted = 0;
114
+
115
+ for (let i = 0; i < ids.length; i += 100) {
116
+ const batch = ids.slice(i, i + 100);
117
+ const deleteResult = await sendgridProvider.bulkDeleteContacts(batch);
118
+
119
+ if (deleteResult.success) {
120
+ totalDeleted += batch.length;
121
+ } else {
122
+ assistant.error('Marketing prune: Batch delete failed:', deleteResult.error);
123
+ }
124
+ }
125
+
126
+ assistant.log(`Marketing prune: Deleted ${totalDeleted} SendGrid contacts`);
127
+
128
+ // Also remove from Beehiiv (same emails)
129
+ if (marketing.beehiiv?.enabled !== false && process.env.BEEHIIV_API_KEY) {
130
+ const beehiivProvider = require('../../libraries/email/providers/beehiiv.js');
131
+ const emails = exportResult.contacts.map(c => c.email).filter(Boolean);
132
+
133
+ assistant.log(`Marketing prune: Removing ${emails.length} contacts from Beehiiv`);
134
+
135
+ await Promise.allSettled(
136
+ emails.map(email => beehiivProvider.removeContact(email))
137
+ );
138
+ }
139
+ }
140
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Marketing campaigns cron job
3
+ *
4
+ * Picks up campaigns from the `marketing-campaigns` collection that are
5
+ * past their sendAt time and still pending. Dispatches based on type:
6
+ * - email: fires through mailer.sendCampaign()
7
+ * - push: fires through notification.send()
8
+ *
9
+ * Recurring campaigns (has `recurrence` field):
10
+ * - Creates a history doc in the same collection with results
11
+ * - Advances the recurring doc's sendAt to the next occurrence
12
+ * - Status stays 'pending' on the recurring doc
13
+ *
14
+ * Runs on bm_cronFrequent (every 10 minutes).
15
+ */
16
+ const moment = require('moment');
17
+ const pushid = require('pushid');
18
+ const notification = require('../../libraries/notification.js');
19
+
20
+ module.exports = async ({ Manager, assistant, libraries }) => {
21
+ const { admin } = libraries;
22
+ const now = Math.round(Date.now() / 1000);
23
+
24
+ // Query campaigns that are ready to send
25
+ const snapshot = await admin.firestore()
26
+ .collection('marketing-campaigns')
27
+ .where('status', '==', 'pending')
28
+ .where('sendAt', '<=', now)
29
+ .limit(20)
30
+ .get();
31
+
32
+ if (snapshot.empty) {
33
+ assistant.log('No pending campaigns ready to send');
34
+ return;
35
+ }
36
+
37
+ assistant.log(`Processing ${snapshot.size} campaign(s)...`);
38
+
39
+ const email = Manager.Email(assistant);
40
+
41
+ const results = await Promise.allSettled(snapshot.docs.map(async (doc) => {
42
+ const data = doc.data();
43
+ const { settings, type, recurrence } = data;
44
+ const campaignId = doc.id;
45
+
46
+ assistant.log(`Processing campaign ${campaignId} (${type}): ${settings.name}`);
47
+
48
+ // --- Dispatch by type ---
49
+ let campaignResults;
50
+
51
+ if (type === 'email') {
52
+ campaignResults = await email.sendCampaign({ ...settings, sendAt: 'now' });
53
+ } else if (type === 'push') {
54
+ campaignResults = {
55
+ push: await notification.send(assistant, {
56
+ title: settings.name,
57
+ body: settings.subject || settings.body,
58
+ icon: settings.icon,
59
+ clickAction: settings.clickAction,
60
+ filters: settings.filters,
61
+ }),
62
+ };
63
+ } else {
64
+ assistant.log(`Unknown campaign type "${type}", skipping ${campaignId}`);
65
+ return;
66
+ }
67
+
68
+ const success = Object.values(campaignResults).some(r => r.success || r.sent > 0);
69
+ const nowISO = new Date().toISOString();
70
+ const nowUNIX = Math.round(Date.now() / 1000);
71
+
72
+ // --- Handle recurring vs one-off ---
73
+ if (recurrence) {
74
+ // Create history record
75
+ const historyId = pushid();
76
+
77
+ await admin.firestore().doc(`marketing-campaigns/${historyId}`).set({
78
+ settings,
79
+ type,
80
+ sendAt: data.sendAt,
81
+ status: success ? 'sent' : 'failed',
82
+ results: campaignResults,
83
+ recurringId: campaignId,
84
+ metadata: {
85
+ created: { timestamp: nowISO, timestampUNIX: nowUNIX },
86
+ updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
87
+ },
88
+ });
89
+
90
+ // Advance sendAt to next occurrence
91
+ const nextSendAt = getNextOccurrence(data.sendAt, recurrence);
92
+
93
+ await doc.ref.set({
94
+ sendAt: nextSendAt,
95
+ metadata: {
96
+ updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
97
+ },
98
+ }, { merge: true });
99
+
100
+ assistant.log(`Recurring campaign ${campaignId} ${success ? 'sent' : 'failed'}, next: ${moment.unix(nextSendAt).toISOString()}`);
101
+ } else {
102
+ // One-off: update status directly
103
+ await doc.ref.set({
104
+ status: success ? 'sent' : 'failed',
105
+ results: campaignResults,
106
+ metadata: {
107
+ updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
108
+ },
109
+ }, { merge: true });
110
+
111
+ assistant.log(`Campaign ${campaignId} ${success ? 'sent' : 'failed'}`);
112
+ }
113
+ }));
114
+
115
+ const sent = results.filter(r => r.status === 'fulfilled').length;
116
+ const failed = results.filter(r => r.status === 'rejected').length;
117
+
118
+ for (const r of results) {
119
+ if (r.status === 'rejected') {
120
+ assistant.error(`Failed to process campaign: ${r.reason?.message}`, r.reason);
121
+ }
122
+ }
123
+
124
+ assistant.log(`Completed! (${sent} processed, ${failed} failed)`);
125
+ };
126
+
127
+ /**
128
+ * Calculate the next occurrence unix timestamp from the current sendAt.
129
+ *
130
+ * @param {number} currentSendAt - Current fire time (unix)
131
+ * @param {object} recurrence - { pattern, hour, day, month }
132
+ * @returns {number} Next fire time (unix)
133
+ */
134
+ function getNextOccurrence(currentSendAt, recurrence) {
135
+ const current = moment.unix(currentSendAt);
136
+ const { pattern } = recurrence;
137
+
138
+ switch (pattern) {
139
+ case 'daily':
140
+ return current.add(1, 'day').unix();
141
+
142
+ case 'weekly':
143
+ return current.add(1, 'week').unix();
144
+
145
+ case 'monthly':
146
+ return current.add(1, 'month').unix();
147
+
148
+ case 'quarterly':
149
+ return current.add(3, 'months').unix();
150
+
151
+ case 'yearly':
152
+ return current.add(1, 'year').unix();
153
+
154
+ default:
155
+ // Fallback: 1 month
156
+ return current.add(1, 'month').unix();
157
+ }
158
+ }