backend-manager 5.0.154 → 5.0.157
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.
- package/CHANGELOG.md +41 -0
- package/CLAUDE.md +2 -2
- package/package.json +1 -1
- package/src/cli/commands/setup-tests/firestore-indexes-required.js +1 -1
- package/src/cli/commands/setup-tests/functions-package.js +3 -1
- package/src/cli/commands/setup-tests/{required-indexes.js → helpers/required-indexes.js} +22 -0
- package/src/cli/commands/setup-tests/helpers/seed-campaigns.js +132 -0
- package/src/cli/commands/setup-tests/index.js +2 -0
- package/src/cli/commands/setup-tests/marketing-campaigns-seeded.js +109 -0
- package/src/manager/cron/daily/marketing-prune.js +140 -0
- package/src/manager/cron/frequent/marketing-campaigns.js +158 -0
- package/src/manager/events/auth/on-create.js +36 -1
- package/src/manager/libraries/email/constants.js +46 -2
- package/src/manager/libraries/email/index.js +13 -3
- package/src/manager/libraries/email/marketing/index.js +205 -33
- package/src/manager/libraries/email/providers/beehiiv.js +90 -0
- package/src/manager/libraries/email/providers/sendgrid.js +179 -1
- package/src/manager/libraries/email/transactional/index.js +17 -0
- package/src/manager/libraries/email/utm.js +116 -0
- package/src/manager/libraries/notification.js +223 -0
- package/src/manager/routes/admin/notification/post.js +16 -241
- package/src/manager/routes/marketing/campaign/delete.js +45 -0
- package/src/manager/routes/marketing/campaign/get.js +69 -0
- package/src/manager/routes/marketing/campaign/post.js +161 -0
- package/src/manager/routes/marketing/campaign/put.js +122 -0
- package/src/manager/routes/user/data-request/delete.js +3 -1
- package/src/manager/routes/user/data-request/get.js +3 -1
- package/src/manager/routes/user/data-request/post.js +3 -1
- package/src/manager/routes/user/delete.js +4 -3
- package/src/manager/routes/user/signup/post.js +10 -8
- package/src/manager/schemas/marketing/campaign/delete.js +6 -0
- package/src/manager/schemas/marketing/campaign/get.js +11 -0
- package/src/manager/schemas/marketing/campaign/post.js +35 -0
- package/src/manager/schemas/marketing/campaign/put.js +35 -0
- package/templates/backend-manager-config.json +3 -0
|
@@ -1,262 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* POST /admin/notification - Send FCM push notification
|
|
3
|
-
* Admin-only endpoint to send push notifications
|
|
3
|
+
* Admin-only endpoint to send push notifications.
|
|
4
|
+
* Uses shared notification library (also used by marketing-campaigns cron).
|
|
4
5
|
*/
|
|
5
|
-
const
|
|
6
|
-
const BAD_TOKEN_REASONS = [
|
|
7
|
-
'messaging/invalid-registration-token',
|
|
8
|
-
'messaging/registration-token-not-registered',
|
|
9
|
-
];
|
|
10
|
-
const BATCH_SIZE = 500;
|
|
6
|
+
const notification = require('../../../libraries/notification.js');
|
|
11
7
|
|
|
12
8
|
module.exports = async ({ assistant, user, settings, analytics }) => {
|
|
13
|
-
// Require authentication
|
|
14
9
|
if (!user.authenticated) {
|
|
15
10
|
return assistant.respond('Authentication required', { code: 401 });
|
|
16
11
|
}
|
|
17
|
-
|
|
18
|
-
// Require admin
|
|
19
12
|
if (!user.roles.admin) {
|
|
20
13
|
return assistant.respond('Admin required.', { code: 403 });
|
|
21
14
|
}
|
|
22
15
|
|
|
23
|
-
// Set up response tracking
|
|
24
|
-
const response = {
|
|
25
|
-
subscribers: 0,
|
|
26
|
-
batches: 0,
|
|
27
|
-
sent: 0,
|
|
28
|
-
deleted: 0,
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
// Validate required fields
|
|
32
16
|
if (!settings.notification.title || !settings.notification.body) {
|
|
33
17
|
return assistant.respond('Parameters <title> and <body> required', { code: 400 });
|
|
34
18
|
}
|
|
35
19
|
|
|
36
|
-
|
|
37
|
-
const notification = {
|
|
20
|
+
const result = await notification.send(assistant, {
|
|
38
21
|
title: settings.notification.title,
|
|
39
22
|
body: settings.notification.body,
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
||
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
try {
|
|
49
|
-
const url = new URL(notification.click_action);
|
|
50
|
-
url.searchParams.set('cb', new Date().getTime());
|
|
51
|
-
notification.click_action = url.toString();
|
|
52
|
-
} catch (e) {
|
|
53
|
-
return assistant.respond(`Failed to add cb to URL: ${e}`, { code: 400 });
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
assistant.log('Resolved notification payload', notification);
|
|
57
|
-
|
|
58
|
-
// Build filter options
|
|
59
|
-
const filterOptions = {
|
|
60
|
-
tags: settings.filters.tags || false,
|
|
61
|
-
owner: settings.filters.owner || null,
|
|
62
|
-
token: settings.filters.token || null,
|
|
63
|
-
limit: settings.filters.limit || null,
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
// Process tokens and send notifications
|
|
67
|
-
await processTokens(assistant, notification, filterOptions, response);
|
|
68
|
-
|
|
69
|
-
// Track analytics
|
|
70
|
-
analytics.event('admin/notification', { sent: response.sent });
|
|
71
|
-
|
|
72
|
-
return assistant.respond(response);
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
// Helper: Process tokens and send notifications
|
|
76
|
-
async function processTokens(assistant, notification, options, response) {
|
|
77
|
-
const Manager = assistant.Manager;
|
|
78
|
-
|
|
79
|
-
// If a specific token is provided, send directly to it (useful for testing)
|
|
80
|
-
if (options.token) {
|
|
81
|
-
assistant.log(`Sending to specific token: ${options.token}`);
|
|
82
|
-
|
|
83
|
-
try {
|
|
84
|
-
await sendBatch(assistant, [options.token], 0, notification, response);
|
|
85
|
-
assistant.log('Single token notification sent successfully.');
|
|
86
|
-
} catch (e) {
|
|
87
|
-
assistant.error('Error sending to specific token', e);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Build query conditions
|
|
94
|
-
const queryConditions = [];
|
|
95
|
-
|
|
96
|
-
// Filter by tags
|
|
97
|
-
if (options.tags) {
|
|
98
|
-
queryConditions.push({ field: 'tags', operator: 'array-contains-any', value: options.tags });
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Filter by owner UID
|
|
102
|
-
if (options.owner) {
|
|
103
|
-
queryConditions.push({ field: 'owner', operator: '==', value: options.owner });
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Calculate max batches based on limit
|
|
107
|
-
const maxBatches = options.limit
|
|
108
|
-
? Math.ceil(options.limit / BATCH_SIZE)
|
|
109
|
-
: Infinity;
|
|
110
|
-
|
|
111
|
-
// Log filter options
|
|
112
|
-
assistant.log('Processing tokens with filters:', {
|
|
113
|
-
tags: options.tags,
|
|
114
|
-
owner: options.owner,
|
|
115
|
-
limit: options.limit,
|
|
116
|
-
maxBatches: maxBatches,
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
// Track tokens processed for limit
|
|
120
|
-
let tokensProcessed = 0;
|
|
121
|
-
|
|
122
|
-
// Batch processing logic
|
|
123
|
-
await Manager.Utilities().iterateCollection(
|
|
124
|
-
async (batch, index) => {
|
|
125
|
-
let batchTokens = [];
|
|
126
|
-
|
|
127
|
-
// Collect tokens from the current batch
|
|
128
|
-
for (const doc of batch.docs) {
|
|
129
|
-
// Stop if we've hit the limit
|
|
130
|
-
if (options.limit && tokensProcessed >= options.limit) {
|
|
131
|
-
break;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const data = doc.data();
|
|
135
|
-
batchTokens.push(data.token);
|
|
136
|
-
tokensProcessed++;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// Skip if no tokens to send
|
|
140
|
-
if (batchTokens.length === 0) {
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// Send the batch
|
|
145
|
-
try {
|
|
146
|
-
assistant.log(`Sending batch ${index} with ${batchTokens.length} tokens.`);
|
|
147
|
-
await sendBatch(assistant, batchTokens, index, notification, response);
|
|
148
|
-
} catch (e) {
|
|
149
|
-
assistant.error(`Error sending batch ${index}`, e);
|
|
150
|
-
}
|
|
23
|
+
icon: settings.notification.icon,
|
|
24
|
+
clickAction: settings.notification.clickAction
|
|
25
|
+
|| settings.notification.click_action,
|
|
26
|
+
filters: {
|
|
27
|
+
tags: settings.filters.tags || false,
|
|
28
|
+
owner: settings.filters.owner || null,
|
|
29
|
+
token: settings.filters.token || null,
|
|
30
|
+
limit: settings.filters.limit || null,
|
|
151
31
|
},
|
|
152
|
-
|
|
153
|
-
collection: PATH_NOTIFICATIONS,
|
|
154
|
-
where: queryConditions,
|
|
155
|
-
batchSize: BATCH_SIZE,
|
|
156
|
-
maxBatches: maxBatches,
|
|
157
|
-
log: true,
|
|
158
|
-
}
|
|
159
|
-
)
|
|
160
|
-
.then(() => {
|
|
161
|
-
assistant.log('All batches processed successfully.');
|
|
162
|
-
})
|
|
163
|
-
.catch(e => {
|
|
164
|
-
assistant.error(`Error during token processing: ${e}`);
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Helper: Send batch of notifications
|
|
169
|
-
async function sendBatch(assistant, batch, id, notification, response) {
|
|
170
|
-
const { admin } = assistant.Manager.libraries;
|
|
171
|
-
try {
|
|
172
|
-
assistant.log(`Sending batch #${id}: tokens=${batch.length}...`, notification);
|
|
173
|
-
|
|
174
|
-
// Prepare messages
|
|
175
|
-
const messages = batch.map(token => ({
|
|
176
|
-
token: token,
|
|
177
|
-
notification: {
|
|
178
|
-
title: notification.title,
|
|
179
|
-
body: notification.body,
|
|
180
|
-
imageUrl: notification.imageUrl,
|
|
181
|
-
},
|
|
182
|
-
webpush: {
|
|
183
|
-
notification: {
|
|
184
|
-
title: notification.title,
|
|
185
|
-
body: notification.body,
|
|
186
|
-
icon: notification.imageUrl,
|
|
187
|
-
click_action: notification.click_action,
|
|
188
|
-
},
|
|
189
|
-
data: {
|
|
190
|
-
click_action: notification.click_action,
|
|
191
|
-
},
|
|
192
|
-
fcm_options: {
|
|
193
|
-
link: notification.click_action,
|
|
194
|
-
}
|
|
195
|
-
},
|
|
196
|
-
data: {
|
|
197
|
-
click_action: notification.click_action,
|
|
198
|
-
},
|
|
199
|
-
}));
|
|
200
|
-
|
|
201
|
-
// Send the batch
|
|
202
|
-
const result = await admin.messaging().sendEach(messages);
|
|
203
|
-
|
|
204
|
-
assistant.log(`Sent batch #${id}: tokens=${batch.length}, success=${result.successCount}, failures=${result.failureCount}`, JSON.stringify(result));
|
|
205
|
-
|
|
206
|
-
// Attach token to response
|
|
207
|
-
result.responses = result.responses.map((item, index) => {
|
|
208
|
-
item.token = batch[index];
|
|
209
|
-
return item;
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
// Clean bad tokens
|
|
213
|
-
if (result.failureCount > 0) {
|
|
214
|
-
await cleanTokens(assistant, batch, result.responses, id, response);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// Update response
|
|
218
|
-
response.sent += (batch.length - result.failureCount);
|
|
219
|
-
} catch (e) {
|
|
220
|
-
throw new Error(`Error sending batch ${id}: ${e}`);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// Helper: Clean bad tokens
|
|
225
|
-
async function cleanTokens(assistant, batch, results, id, response) {
|
|
226
|
-
assistant.log(`Cleaning ${results.length} tokens of batch ID: ${id}`);
|
|
227
|
-
|
|
228
|
-
const cleanPromises = results
|
|
229
|
-
.map((item, index) => {
|
|
230
|
-
const shouldClean = BAD_TOKEN_REASONS.includes(item?.error?.code);
|
|
231
|
-
|
|
232
|
-
assistant.log(`Checking #${index}: success=${item.success}, error=${item?.error?.code || null}, clean=${shouldClean}`, item.error);
|
|
233
|
-
|
|
234
|
-
if (!item.error || !shouldClean) {
|
|
235
|
-
return null;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
return deleteToken(assistant, item.token, item.error.code, response);
|
|
239
|
-
})
|
|
240
|
-
.filter(Boolean);
|
|
241
|
-
|
|
242
|
-
try {
|
|
243
|
-
await Promise.all(cleanPromises);
|
|
244
|
-
assistant.log(`Completed cleaning tokens for batch ID: ${id}`);
|
|
245
|
-
} catch (e) {
|
|
246
|
-
assistant.error(`Error cleaning tokens for batch ID: ${id}`, e);
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// Helper: Delete bad token
|
|
251
|
-
async function deleteToken(assistant, token, errorCode, response) {
|
|
252
|
-
const { admin } = assistant.Manager.libraries;
|
|
253
|
-
try {
|
|
254
|
-
await admin.firestore().doc(`${PATH_NOTIFICATIONS}/${token}`).delete();
|
|
32
|
+
});
|
|
255
33
|
|
|
256
|
-
|
|
34
|
+
analytics.event('admin/notification', { sent: result.sent });
|
|
257
35
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
assistant.error(`Failed to delete bad token: ${token} (Reason: ${errorCode})`, error);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
36
|
+
return assistant.respond(result);
|
|
37
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DELETE /marketing/campaign - Delete a marketing campaign
|
|
3
|
+
* Admin-only. Can only delete pending campaigns.
|
|
4
|
+
*/
|
|
5
|
+
module.exports = async ({ assistant, user, Manager, settings, analytics }) => {
|
|
6
|
+
|
|
7
|
+
if (!user.authenticated) {
|
|
8
|
+
return assistant.respond('Authentication required', { code: 401 });
|
|
9
|
+
}
|
|
10
|
+
if (!user.roles.admin) {
|
|
11
|
+
return assistant.respond('Admin access required', { code: 403 });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const { admin } = Manager.libraries;
|
|
15
|
+
const campaignId = (settings.id || '').trim();
|
|
16
|
+
|
|
17
|
+
if (!campaignId) {
|
|
18
|
+
return assistant.respond('Campaign ID is required', { code: 400 });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const docRef = admin.firestore().doc(`marketing-campaigns/${campaignId}`);
|
|
22
|
+
const doc = await docRef.get();
|
|
23
|
+
|
|
24
|
+
if (!doc.exists) {
|
|
25
|
+
return assistant.respond('Campaign not found', { code: 404 });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const existing = doc.data();
|
|
29
|
+
|
|
30
|
+
// Can only delete pending campaigns (sent/failed are historical records)
|
|
31
|
+
if (existing.status !== 'pending') {
|
|
32
|
+
return assistant.respond(`Cannot delete campaign with status "${existing.status}"`, { code: 400 });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
await docRef.delete();
|
|
36
|
+
|
|
37
|
+
assistant.log('marketing/campaign deleted:', { campaignId });
|
|
38
|
+
|
|
39
|
+
analytics.event('marketing/campaign', { action: 'delete' });
|
|
40
|
+
|
|
41
|
+
return assistant.respond({
|
|
42
|
+
success: true,
|
|
43
|
+
deleted: campaignId,
|
|
44
|
+
});
|
|
45
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /marketing/campaign - List or get marketing campaigns
|
|
3
|
+
* Admin-only. Used by calendar frontend.
|
|
4
|
+
*
|
|
5
|
+
* Query params:
|
|
6
|
+
* id — Get a single campaign by ID
|
|
7
|
+
* start — Filter campaigns with sendAt >= start (unix timestamp)
|
|
8
|
+
* end — Filter campaigns with sendAt <= end (unix timestamp)
|
|
9
|
+
* status — Filter by status (pending, sent, failed)
|
|
10
|
+
* type — Filter by type (email, push)
|
|
11
|
+
* limit — Max results (default 100)
|
|
12
|
+
*/
|
|
13
|
+
module.exports = async ({ assistant, user, Manager, settings }) => {
|
|
14
|
+
|
|
15
|
+
if (!user.authenticated) {
|
|
16
|
+
return assistant.respond('Authentication required', { code: 401 });
|
|
17
|
+
}
|
|
18
|
+
if (!user.roles.admin) {
|
|
19
|
+
return assistant.respond('Admin access required', { code: 403 });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const { admin } = Manager.libraries;
|
|
23
|
+
|
|
24
|
+
// Single campaign by ID
|
|
25
|
+
if (settings.id) {
|
|
26
|
+
const doc = await admin.firestore().doc(`marketing-campaigns/${settings.id}`).get();
|
|
27
|
+
|
|
28
|
+
if (!doc.exists) {
|
|
29
|
+
return assistant.respond('Campaign not found', { code: 404 });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return assistant.respond({
|
|
33
|
+
success: true,
|
|
34
|
+
campaign: { id: doc.id, ...doc.data() },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// List campaigns with filters
|
|
39
|
+
let query = admin.firestore().collection('marketing-campaigns');
|
|
40
|
+
|
|
41
|
+
if (settings.status) {
|
|
42
|
+
query = query.where('status', '==', settings.status);
|
|
43
|
+
}
|
|
44
|
+
if (settings.type) {
|
|
45
|
+
query = query.where('type', '==', settings.type);
|
|
46
|
+
}
|
|
47
|
+
if (settings.start) {
|
|
48
|
+
query = query.where('sendAt', '>=', parseInt(settings.start, 10));
|
|
49
|
+
}
|
|
50
|
+
if (settings.end) {
|
|
51
|
+
query = query.where('sendAt', '<=', parseInt(settings.end, 10));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
query = query.orderBy('sendAt', 'asc');
|
|
55
|
+
query = query.limit(parseInt(settings.limit, 10) || 100);
|
|
56
|
+
|
|
57
|
+
const snapshot = await query.get();
|
|
58
|
+
|
|
59
|
+
const campaigns = snapshot.docs.map(doc => ({
|
|
60
|
+
id: doc.id,
|
|
61
|
+
...doc.data(),
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
return assistant.respond({
|
|
65
|
+
success: true,
|
|
66
|
+
campaigns,
|
|
67
|
+
count: campaigns.length,
|
|
68
|
+
});
|
|
69
|
+
};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /marketing/campaign - Create a marketing campaign
|
|
3
|
+
* Admin-only. Saves to marketing-campaigns collection.
|
|
4
|
+
*
|
|
5
|
+
* - sendAt defaults to 'now' (immediate send)
|
|
6
|
+
* - Future sendAt → saved as 'pending' for cron pickup
|
|
7
|
+
* - Past/now sendAt → fires immediately, saved as 'sent'/'failed'
|
|
8
|
+
* - Supports type: 'email' (default) or 'push' (future)
|
|
9
|
+
*
|
|
10
|
+
* Content is markdown — converted to HTML at send time per provider.
|
|
11
|
+
*/
|
|
12
|
+
const _ = require('lodash');
|
|
13
|
+
const moment = require('moment');
|
|
14
|
+
const pushid = require('pushid');
|
|
15
|
+
|
|
16
|
+
module.exports = async ({ assistant, user, Manager, settings, analytics }) => {
|
|
17
|
+
|
|
18
|
+
if (!user.authenticated) {
|
|
19
|
+
return assistant.respond('Authentication required', { code: 401 });
|
|
20
|
+
}
|
|
21
|
+
if (!user.roles.admin) {
|
|
22
|
+
return assistant.respond('Admin access required', { code: 403 });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const { admin } = Manager.libraries;
|
|
26
|
+
const campaignId = settings.id || pushid();
|
|
27
|
+
const now = moment();
|
|
28
|
+
|
|
29
|
+
// Normalize sendAt → unix timestamp
|
|
30
|
+
const sendAt = normalizeSendAt(settings.sendAt, now);
|
|
31
|
+
|
|
32
|
+
// Build the campaign document (settings nested, like emails-queue)
|
|
33
|
+
const campaignSettings = {};
|
|
34
|
+
|
|
35
|
+
// Required
|
|
36
|
+
campaignSettings.name = settings.name;
|
|
37
|
+
campaignSettings.subject = settings.subject;
|
|
38
|
+
|
|
39
|
+
// Content
|
|
40
|
+
if (settings.preheader) { campaignSettings.preheader = settings.preheader; }
|
|
41
|
+
if (settings.template && settings.template !== 'default') { campaignSettings.template = settings.template; }
|
|
42
|
+
if (settings.content) { campaignSettings.content = settings.content; }
|
|
43
|
+
if (settings.data && Object.keys(settings.data).length) { campaignSettings.data = settings.data; }
|
|
44
|
+
|
|
45
|
+
// Targeting
|
|
46
|
+
if (settings.lists && settings.lists.length) { campaignSettings.lists = settings.lists; }
|
|
47
|
+
if (settings.segments && settings.segments.length) { campaignSettings.segments = settings.segments; }
|
|
48
|
+
if (settings.excludeSegments && settings.excludeSegments.length) { campaignSettings.excludeSegments = settings.excludeSegments; }
|
|
49
|
+
if (settings.all) { campaignSettings.all = true; }
|
|
50
|
+
|
|
51
|
+
// UTM
|
|
52
|
+
if (settings.utm && Object.keys(settings.utm).length) { campaignSettings.utm = settings.utm; }
|
|
53
|
+
|
|
54
|
+
// Config
|
|
55
|
+
if (settings.sender) { campaignSettings.sender = settings.sender; }
|
|
56
|
+
if (settings.providers && settings.providers.length) { campaignSettings.providers = settings.providers; }
|
|
57
|
+
if (settings.group) { campaignSettings.group = settings.group; }
|
|
58
|
+
if (settings.categories && settings.categories.length) { campaignSettings.categories = settings.categories; }
|
|
59
|
+
|
|
60
|
+
// Clone and clean undefined values for Firestore
|
|
61
|
+
const settingsCloned = _.cloneDeepWith(campaignSettings, (value) => {
|
|
62
|
+
if (typeof value === 'undefined') {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const isFuture = sendAt > now.unix();
|
|
68
|
+
const type = settings.type || 'email';
|
|
69
|
+
|
|
70
|
+
const doc = {
|
|
71
|
+
settings: settingsCloned,
|
|
72
|
+
sendAt,
|
|
73
|
+
status: 'pending',
|
|
74
|
+
type,
|
|
75
|
+
...(settings.recurrence ? { recurrence: settings.recurrence } : {}),
|
|
76
|
+
metadata: {
|
|
77
|
+
created: {
|
|
78
|
+
timestamp: now.toISOString(),
|
|
79
|
+
timestampUNIX: now.unix(),
|
|
80
|
+
},
|
|
81
|
+
updated: {
|
|
82
|
+
timestamp: now.toISOString(),
|
|
83
|
+
timestampUNIX: now.unix(),
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Save to Firestore
|
|
89
|
+
await admin.firestore().doc(`marketing-campaigns/${campaignId}`).set(doc);
|
|
90
|
+
|
|
91
|
+
assistant.log('marketing/campaign created:', { campaignId, sendAt, isFuture, type });
|
|
92
|
+
|
|
93
|
+
// If sendAt is now/past, fire immediately
|
|
94
|
+
let results = null;
|
|
95
|
+
|
|
96
|
+
if (!isFuture && type === 'email') {
|
|
97
|
+
const mailer = Manager.Email(assistant);
|
|
98
|
+
results = await mailer.sendCampaign({ ...campaignSettings, sendAt: 'now' });
|
|
99
|
+
|
|
100
|
+
// Update status
|
|
101
|
+
const status = Object.values(results).some(r => r.success) ? 'sent' : 'failed';
|
|
102
|
+
|
|
103
|
+
await admin.firestore().doc(`marketing-campaigns/${campaignId}`).set({
|
|
104
|
+
status,
|
|
105
|
+
results,
|
|
106
|
+
metadata: {
|
|
107
|
+
updated: {
|
|
108
|
+
timestamp: new Date().toISOString(),
|
|
109
|
+
timestampUNIX: Math.round(Date.now() / 1000),
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
}, { merge: true });
|
|
113
|
+
|
|
114
|
+
assistant.log('marketing/campaign sent:', { campaignId, status, results });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Analytics
|
|
118
|
+
analytics.event('marketing/campaign', {
|
|
119
|
+
action: isFuture ? 'schedule' : 'send',
|
|
120
|
+
type,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return assistant.respond({
|
|
124
|
+
success: true,
|
|
125
|
+
id: campaignId,
|
|
126
|
+
status: isFuture ? 'pending' : (results ? 'sent' : 'pending'),
|
|
127
|
+
sendAt,
|
|
128
|
+
providers: results,
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Normalize sendAt to unix timestamp.
|
|
134
|
+
* Accepts: 'now', ISO string, unix timestamp (number or string), undefined/empty.
|
|
135
|
+
* Defaults to now.
|
|
136
|
+
*/
|
|
137
|
+
function normalizeSendAt(sendAt, now) {
|
|
138
|
+
if (!sendAt || sendAt === 'now') {
|
|
139
|
+
return now.unix();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Unix timestamp (number)
|
|
143
|
+
if (typeof sendAt === 'number') {
|
|
144
|
+
return sendAt;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Unix timestamp as string (all digits)
|
|
148
|
+
if (/^\d+$/.test(sendAt)) {
|
|
149
|
+
return parseInt(sendAt, 10);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ISO string or other parseable date
|
|
153
|
+
const parsed = moment(sendAt);
|
|
154
|
+
|
|
155
|
+
if (parsed.isValid()) {
|
|
156
|
+
return parsed.unix();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Fallback to now
|
|
160
|
+
return now.unix();
|
|
161
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PUT /marketing/campaign - Update a marketing campaign
|
|
3
|
+
* Admin-only. Used by calendar frontend for edits and rescheduling.
|
|
4
|
+
*
|
|
5
|
+
* Accepts any field from the POST schema. Only provided fields are updated.
|
|
6
|
+
* Changing sendAt reschedules the campaign (if still pending).
|
|
7
|
+
*/
|
|
8
|
+
const _ = require('lodash');
|
|
9
|
+
const moment = require('moment');
|
|
10
|
+
|
|
11
|
+
module.exports = async ({ assistant, user, Manager, settings, analytics }) => {
|
|
12
|
+
|
|
13
|
+
if (!user.authenticated) {
|
|
14
|
+
return assistant.respond('Authentication required', { code: 401 });
|
|
15
|
+
}
|
|
16
|
+
if (!user.roles.admin) {
|
|
17
|
+
return assistant.respond('Admin access required', { code: 403 });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { admin } = Manager.libraries;
|
|
21
|
+
const campaignId = (settings.id || '').trim();
|
|
22
|
+
|
|
23
|
+
if (!campaignId) {
|
|
24
|
+
return assistant.respond('Campaign ID is required', { code: 400 });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Fetch existing
|
|
28
|
+
const docRef = admin.firestore().doc(`marketing-campaigns/${campaignId}`);
|
|
29
|
+
const doc = await docRef.get();
|
|
30
|
+
|
|
31
|
+
if (!doc.exists) {
|
|
32
|
+
return assistant.respond('Campaign not found', { code: 404 });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const existing = doc.data();
|
|
36
|
+
|
|
37
|
+
// Can only edit pending campaigns
|
|
38
|
+
if (existing.status !== 'pending') {
|
|
39
|
+
return assistant.respond(`Cannot edit campaign with status "${existing.status}"`, { code: 400 });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Build update — merge provided fields into existing settings
|
|
43
|
+
const update = {
|
|
44
|
+
metadata: {
|
|
45
|
+
updated: {
|
|
46
|
+
timestamp: new Date().toISOString(),
|
|
47
|
+
timestampUNIX: Math.round(Date.now() / 1000),
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Update sendAt if provided
|
|
53
|
+
if (settings.sendAt !== undefined && settings.sendAt !== '') {
|
|
54
|
+
update.sendAt = normalizeSendAt(settings.sendAt);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Update type if provided
|
|
58
|
+
if (settings.type) {
|
|
59
|
+
update.type = settings.type;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Update recurrence if provided
|
|
63
|
+
if (settings.recurrence !== undefined) {
|
|
64
|
+
update.recurrence = settings.recurrence;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Update settings fields — only merge what's provided
|
|
68
|
+
const settingsUpdate = {};
|
|
69
|
+
const settingsFields = [
|
|
70
|
+
'name', 'subject', 'preheader', 'template', 'content', 'data',
|
|
71
|
+
'lists', 'segments', 'excludeSegments', 'all',
|
|
72
|
+
'utm', 'sender', 'providers', 'group', 'categories',
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
for (const field of settingsFields) {
|
|
76
|
+
if (settings[field] !== undefined && settings[field] !== '') {
|
|
77
|
+
settingsUpdate[field] = settings[field];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (Object.keys(settingsUpdate).length) {
|
|
82
|
+
// Clean undefined values for Firestore
|
|
83
|
+
const cleaned = _.cloneDeepWith(settingsUpdate, (value) => {
|
|
84
|
+
if (typeof value === 'undefined') {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
update.settings = { ...existing.settings, ...cleaned };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
await docRef.set(update, { merge: true });
|
|
93
|
+
|
|
94
|
+
assistant.log('marketing/campaign updated:', { campaignId, update });
|
|
95
|
+
|
|
96
|
+
analytics.event('marketing/campaign', { action: 'update' });
|
|
97
|
+
|
|
98
|
+
// Fetch updated doc
|
|
99
|
+
const updated = await docRef.get();
|
|
100
|
+
|
|
101
|
+
return assistant.respond({
|
|
102
|
+
success: true,
|
|
103
|
+
campaign: { id: campaignId, ...updated.data() },
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
function normalizeSendAt(sendAt) {
|
|
108
|
+
if (!sendAt || sendAt === 'now') {
|
|
109
|
+
return Math.round(Date.now() / 1000);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (typeof sendAt === 'number') {
|
|
113
|
+
return sendAt;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (/^\d+$/.test(sendAt)) {
|
|
117
|
+
return parseInt(sendAt, 10);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const parsed = moment(sendAt);
|
|
121
|
+
return parsed.isValid() ? parsed.unix() : Math.round(Date.now() / 1000);
|
|
122
|
+
}
|
|
@@ -48,6 +48,8 @@ function sendCancellationEmail(assistant, user, requestId) {
|
|
|
48
48
|
const Manager = assistant.Manager;
|
|
49
49
|
const mailer = Manager.Email(assistant);
|
|
50
50
|
const uid = user.auth.uid;
|
|
51
|
+
const firstName = user.personal?.name?.first;
|
|
52
|
+
const greeting = firstName ? `Hey ${firstName}, your` : 'Your';
|
|
51
53
|
|
|
52
54
|
mailer.send({
|
|
53
55
|
to: user,
|
|
@@ -62,7 +64,7 @@ function sendCancellationEmail(assistant, user, requestId) {
|
|
|
62
64
|
},
|
|
63
65
|
body: {
|
|
64
66
|
title: 'Data Request Cancelled',
|
|
65
|
-
message:
|
|
67
|
+
message: `${greeting} data export request has been cancelled as requested.
|
|
66
68
|
|
|
67
69
|
- **Request reference:** #${requestId}
|
|
68
70
|
- **Account UID:** ${uid}
|