backend-manager 5.0.91 → 5.0.93
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 +2 -2
- package/CLAUDE.md +14 -6
- package/README.md +6 -6
- package/TODO-MARKETING.md +3 -0
- package/TODO-PAYMENT-v2.md +71 -0
- package/TODO.md +7 -0
- package/package.json +3 -3
- package/src/cli/commands/{emulators.js → emulator.js} +15 -15
- package/src/cli/commands/index.js +1 -1
- package/src/cli/commands/setup-tests/{emulators-config.js → emulator-config.js} +4 -4
- package/src/cli/commands/setup-tests/index.js +2 -2
- package/src/cli/commands/setup-tests/project-id-consistency.js +1 -1
- package/src/cli/commands/test.js +16 -16
- package/src/cli/index.js +4 -4
- package/src/manager/events/auth/on-create.js +5 -158
- package/src/manager/events/firestore/payments-webhooks/analytics.js +4 -3
- package/src/manager/events/firestore/payments-webhooks/on-write.js +56 -6
- package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js +3 -3
- package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-failed.js +1 -1
- package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +32 -28
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/cancellation-requested.js +3 -3
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js +3 -3
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js +3 -3
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js +3 -3
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js +3 -3
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js +3 -3
- package/src/manager/functions/core/actions/api/admin/send-email.js +0 -131
- package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +2 -137
- package/src/manager/functions/core/actions/api/general/emails/general:download-app-link.js +2 -2
- package/src/manager/functions/core/actions/api/user/sign-up.js +1 -1
- package/src/manager/index.js +12 -0
- package/src/manager/libraries/email.js +523 -0
- package/src/manager/libraries/infer-contact.js +140 -0
- package/src/manager/libraries/prompts/infer-contact.md +43 -0
- package/src/manager/routes/admin/backup/post.js +4 -3
- package/src/manager/routes/admin/email/post.js +11 -428
- package/src/manager/routes/admin/hook/post.js +3 -2
- package/src/manager/routes/admin/notification/post.js +14 -12
- package/src/manager/routes/admin/post/post.js +5 -6
- package/src/manager/routes/admin/post/put.js +3 -2
- package/src/manager/routes/admin/stats/get.js +19 -10
- package/src/manager/routes/general/email/post.js +8 -21
- package/src/manager/routes/general/email/templates/download-app-link.js +2 -2
- package/src/manager/routes/marketing/contact/post.js +2 -100
- package/src/manager/routes/payments/intent/post.js +0 -2
- package/src/manager/routes/payments/intent/processors/test.js +9 -10
- package/src/manager/routes/user/oauth2/_helpers.js +3 -2
- package/src/manager/routes/user/oauth2/delete.js +3 -3
- package/src/manager/routes/user/oauth2/get.js +2 -2
- package/src/manager/routes/user/oauth2/post.js +9 -9
- package/src/manager/routes/user/sessions/delete.js +4 -3
- package/src/manager/routes/user/signup/post.js +254 -54
- package/src/manager/schemas/admin/email/post.js +13 -8
- package/src/test/run-tests.js +1 -1
- package/test/functions/admin/send-email.js +1 -88
- package/test/helpers/email.js +421 -0
- package/test/helpers/infer-contact.js +299 -0
- package/test/routes/admin/email.js +41 -90
- package/REFACTOR-BEM-API.md +0 -76
- package/REFACTOR-MIDDLEWARE.md +0 -62
- package/REFACTOR-PAYMENT.md +0 -66
|
@@ -43,7 +43,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics, librari
|
|
|
43
43
|
outputUriPrefix: bucketAddress,
|
|
44
44
|
collectionIds: [],
|
|
45
45
|
}).catch(async (e) => {
|
|
46
|
-
await setMetaStats(assistant,
|
|
46
|
+
await setMetaStats(assistant, e);
|
|
47
47
|
return e;
|
|
48
48
|
});
|
|
49
49
|
|
|
@@ -55,7 +55,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics, librari
|
|
|
55
55
|
|
|
56
56
|
assistant.log('Saved backup successfully:', response.metadata.outputUriPrefix);
|
|
57
57
|
|
|
58
|
-
await setMetaStats(assistant,
|
|
58
|
+
await setMetaStats(assistant, null);
|
|
59
59
|
|
|
60
60
|
// Track analytics
|
|
61
61
|
analytics.event('admin/backup', { status: 'success' });
|
|
@@ -64,7 +64,8 @@ module.exports = async ({ assistant, Manager, user, settings, analytics, librari
|
|
|
64
64
|
};
|
|
65
65
|
|
|
66
66
|
// Helper: Set meta stats
|
|
67
|
-
async function setMetaStats(assistant,
|
|
67
|
+
async function setMetaStats(assistant, error) {
|
|
68
|
+
const { admin } = assistant.Manager.libraries;
|
|
68
69
|
const isError = error instanceof Error;
|
|
69
70
|
|
|
70
71
|
await admin.firestore().doc('meta/stats')
|
|
@@ -1,19 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* POST /admin/email - Send email via SendGrid
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* Admin-only endpoint to send transactional emails.
|
|
5
|
+
* Supports flexible recipient formats (string, object, UID, or arrays of mixed).
|
|
6
|
+
*
|
|
7
|
+
* See: src/manager/libraries/email.js for the shared email builder and sender.
|
|
4
8
|
*/
|
|
5
|
-
|
|
6
|
-
const _ = require('lodash');
|
|
7
|
-
const moment = require('moment');
|
|
8
|
-
const powertools = require('node-powertools');
|
|
9
|
-
const crypto = require('crypto');
|
|
10
|
-
|
|
11
|
-
// SendGrid limit for scheduled emails (72 hours, but use 71 for buffer)
|
|
12
|
-
const SEND_AT_LIMIT = 71;
|
|
13
|
-
|
|
14
|
-
module.exports = async ({ assistant, Manager, user, settings, analytics, libraries }) => {
|
|
15
|
-
const { admin } = libraries;
|
|
16
|
-
|
|
9
|
+
module.exports = async ({ assistant, user, settings }) => {
|
|
17
10
|
// Require authentication
|
|
18
11
|
if (!user.authenticated) {
|
|
19
12
|
return assistant.respond('Authentication required', { code: 401 });
|
|
@@ -29,424 +22,14 @@ module.exports = async ({ assistant, Manager, user, settings, analytics, librari
|
|
|
29
22
|
return assistant.respond('SendGrid API key not configured.', { code: 500 });
|
|
30
23
|
}
|
|
31
24
|
|
|
32
|
-
// Initialize SendGrid
|
|
33
|
-
const sendgrid = Manager.require('@sendgrid/mail');
|
|
34
|
-
sendgrid.setApiKey(process.env.SENDGRID_API_KEY);
|
|
35
|
-
|
|
36
25
|
assistant.log('Request:', settings);
|
|
37
26
|
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
assistant.log('Email:', email, JSON.stringify(email, null, 2));
|
|
42
|
-
|
|
43
|
-
// Check for error
|
|
44
|
-
if (email instanceof Error) {
|
|
45
|
-
return assistant.respond(email.message, { code: 400 });
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Check for duplicate emails
|
|
49
|
-
const isUnique = await ensureFirstInstance(Manager, assistant, admin, settings, email);
|
|
50
|
-
|
|
51
|
-
// If not unique, return early
|
|
52
|
-
if (!isUnique) {
|
|
53
|
-
return assistant.respond({ status: 'non-unique' });
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// If scheduled beyond SendGrid's limit, queue it
|
|
57
|
-
if (email.sendAt && email.sendAt >= moment().add(SEND_AT_LIMIT, 'hours').unix()) {
|
|
58
|
-
await saveToEmailQueue(assistant, admin, email).catch(e => e);
|
|
27
|
+
const email = assistant.Manager.Email(assistant);
|
|
28
|
+
const result = await email.send(settings).catch(e => e);
|
|
59
29
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
options: email,
|
|
63
|
-
response: null,
|
|
64
|
-
});
|
|
30
|
+
if (result instanceof Error) {
|
|
31
|
+
return assistant.respond(result.message, { code: result.code || 500, sentry: result.code !== 400 });
|
|
65
32
|
}
|
|
66
33
|
|
|
67
|
-
|
|
68
|
-
const send = await sendgrid.send(email).catch(e => e);
|
|
69
|
-
|
|
70
|
-
// Check for error
|
|
71
|
-
if (send instanceof Error) {
|
|
72
|
-
const e = send?.response?.body?.errors || send;
|
|
73
|
-
assistant.error('Email send failed:', e);
|
|
74
|
-
return assistant.respond(`Failed to send email: ${JSON.stringify(e)}`, { code: 500, sentry: true });
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Extract message id
|
|
78
|
-
const messageId = send[0].headers['x-message-id'];
|
|
79
|
-
|
|
80
|
-
assistant.log('Email send succeeded:', messageId, send);
|
|
81
|
-
|
|
82
|
-
// Clear email before storage
|
|
83
|
-
const emailCloned = _.cloneDeepWith(email, (value) => {
|
|
84
|
-
if (typeof value === 'undefined') {
|
|
85
|
-
return null;
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
delete emailCloned.dynamicTemplateData._stringified;
|
|
89
|
-
|
|
90
|
-
// Save email to firestore for audit trail
|
|
91
|
-
await admin.firestore().doc(`emails/${messageId}`)
|
|
92
|
-
.set({
|
|
93
|
-
id: messageId,
|
|
94
|
-
request: emailCloned,
|
|
95
|
-
body: {
|
|
96
|
-
html: '',
|
|
97
|
-
text: '',
|
|
98
|
-
},
|
|
99
|
-
created: assistant.meta.startTime,
|
|
100
|
-
})
|
|
101
|
-
.then(() => {
|
|
102
|
-
assistant.log(`Email save succeeded ${messageId}`);
|
|
103
|
-
})
|
|
104
|
-
.catch((e) => {
|
|
105
|
-
assistant.error(`Email save failed ${messageId}`, e);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
// Track analytics
|
|
109
|
-
analytics.event('admin/email', { status: 'sent' });
|
|
110
|
-
|
|
111
|
-
return assistant.respond({
|
|
112
|
-
status: 'sent',
|
|
113
|
-
options: email,
|
|
114
|
-
response: send,
|
|
115
|
-
});
|
|
34
|
+
return assistant.respond(result);
|
|
116
35
|
};
|
|
117
|
-
|
|
118
|
-
// Helper: Build email object
|
|
119
|
-
async function buildEmail(Manager, assistant, settings) {
|
|
120
|
-
const fetch = Manager.require('wonderful-fetch');
|
|
121
|
-
|
|
122
|
-
const email = {
|
|
123
|
-
dynamicTemplateData: {
|
|
124
|
-
email: {},
|
|
125
|
-
personalization: {},
|
|
126
|
-
signoff: {},
|
|
127
|
-
app: {},
|
|
128
|
-
user: {},
|
|
129
|
-
data: {},
|
|
130
|
-
},
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
// Build email from settings
|
|
134
|
-
settings.categories = powertools.arrayify(settings.categories);
|
|
135
|
-
|
|
136
|
-
email.to = powertools.arrayify(settings.to);
|
|
137
|
-
email.cc = powertools.arrayify(settings.cc);
|
|
138
|
-
email.bcc = powertools.arrayify(settings.bcc);
|
|
139
|
-
email.replyTo = settings.replyTo;
|
|
140
|
-
email.subject = settings.subject;
|
|
141
|
-
email.sendAt = settings.sendAt;
|
|
142
|
-
|
|
143
|
-
email.templateId = settings.template;
|
|
144
|
-
email.asm = {
|
|
145
|
-
groupId: settings.group,
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
// Set dynamic template data
|
|
149
|
-
email.dynamicTemplateData.data = settings.data;
|
|
150
|
-
|
|
151
|
-
email.dynamicTemplateData.email = {};
|
|
152
|
-
email.dynamicTemplateData.email.id = Manager.require('uuid').v4();
|
|
153
|
-
email.dynamicTemplateData.email.subject = settings?.data?.email?.subject || null;
|
|
154
|
-
email.dynamicTemplateData.email.preview = settings?.data?.email?.preview || null;
|
|
155
|
-
email.dynamicTemplateData.email.body = settings?.data?.email?.body || null;
|
|
156
|
-
email.dynamicTemplateData.email.unsubscribeUrl = settings?.data?.email?.unsubscribeUrl || null;
|
|
157
|
-
email.dynamicTemplateData.email.categories = [];
|
|
158
|
-
email.dynamicTemplateData.email.footer = {};
|
|
159
|
-
email.dynamicTemplateData.email.footer.text = settings?.data?.email?.footer?.text || null;
|
|
160
|
-
|
|
161
|
-
email.dynamicTemplateData.personalization = settings?.data?.personalization || {};
|
|
162
|
-
|
|
163
|
-
email.dynamicTemplateData.signoff = settings?.data?.signoff || {};
|
|
164
|
-
email.dynamicTemplateData.signoff.type = settings?.data?.signoff?.type || 'team';
|
|
165
|
-
|
|
166
|
-
if (email.dynamicTemplateData.signoff.type === 'personal') {
|
|
167
|
-
email.dynamicTemplateData.signoff.image = settings?.data?.signoff?.image
|
|
168
|
-
|| 'https://cdn.itwcreativeworks.com/assets/ian-wiedenman/images/website/ian-wiedenman-headshot-2021-color-1024x1024.jpg';
|
|
169
|
-
email.dynamicTemplateData.signoff.name = settings?.data?.signoff?.name || 'Ian Wiedenman, CEO';
|
|
170
|
-
email.dynamicTemplateData.signoff.url = settings?.data?.signoff?.url || 'https://ianwiedenman.com';
|
|
171
|
-
email.dynamicTemplateData.signoff.urlText = settings?.data?.signoff?.urlText || '@ianwieds';
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
email.dynamicTemplateData.user = Manager.User(settings.user).properties;
|
|
175
|
-
|
|
176
|
-
// Get app configuration from Manager.config.brand
|
|
177
|
-
const brand = Manager.config?.brand;
|
|
178
|
-
if (!brand) {
|
|
179
|
-
throw new Error('Missing brand configuration in backend-manager-config.json');
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// Build app object from brand config
|
|
183
|
-
const app = {
|
|
184
|
-
id: brand.id,
|
|
185
|
-
name: brand.name,
|
|
186
|
-
url: brand.url,
|
|
187
|
-
email: brand.contact?.email,
|
|
188
|
-
images: brand.images || {},
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
if (!app.email) {
|
|
192
|
-
throw new Error('Missing brand.contact.email in backend-manager-config.json');
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
email.dynamicTemplateData.app = app;
|
|
196
|
-
|
|
197
|
-
// Add user to recipients
|
|
198
|
-
email.to.push({
|
|
199
|
-
email: email.dynamicTemplateData.user.auth.email,
|
|
200
|
-
name: email.dynamicTemplateData.user.personal.name.first,
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
// Add carbon copy recipients
|
|
204
|
-
if (settings.copy) {
|
|
205
|
-
email.cc.push({
|
|
206
|
-
email: email.dynamicTemplateData.app.email,
|
|
207
|
-
name: email.dynamicTemplateData.app.name,
|
|
208
|
-
});
|
|
209
|
-
email.bcc.push(
|
|
210
|
-
{
|
|
211
|
-
email: 'support@itwcreativeworks.com',
|
|
212
|
-
name: 'ITW Creative Works',
|
|
213
|
-
},
|
|
214
|
-
{
|
|
215
|
-
email: 'parser+carboncopy@sendgrid-parser.itwcreativeworks.com',
|
|
216
|
-
name: 'ITW Creative Works (Carbon Copy)',
|
|
217
|
-
}
|
|
218
|
-
);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// Set email properties
|
|
222
|
-
email.replyTo = email.replyTo || email.dynamicTemplateData.app.email;
|
|
223
|
-
email.subject = email.subject || email.dynamicTemplateData.email.subject;
|
|
224
|
-
email.dynamicTemplateData.email.subject = email.dynamicTemplateData.email.subject || email.subject;
|
|
225
|
-
email.from = settings.from || {
|
|
226
|
-
email: email.dynamicTemplateData.app.email,
|
|
227
|
-
name: email.dynamicTemplateData.app.name,
|
|
228
|
-
};
|
|
229
|
-
email.sendAt = settings.sendAt;
|
|
230
|
-
|
|
231
|
-
// Set categories
|
|
232
|
-
email.categories = ['transactional', email.dynamicTemplateData.app.id, ...settings.categories];
|
|
233
|
-
|
|
234
|
-
// Remove duplicates from email lists
|
|
235
|
-
email.to = filterEmails(email.to);
|
|
236
|
-
email.cc = filterEmails(email.cc);
|
|
237
|
-
email.bcc = filterEmails(email.bcc);
|
|
238
|
-
email.categories = _.uniq(email.categories);
|
|
239
|
-
|
|
240
|
-
// Remove cc/bcc entries that are also in to
|
|
241
|
-
email.cc = email.cc.filter(obj => !email.to.some(obj2 => obj.email === obj2.email));
|
|
242
|
-
email.bcc = email.bcc.filter(obj => !email.to.some(obj2 => obj.email === obj2.email));
|
|
243
|
-
|
|
244
|
-
// Try to get contact name from SendGrid
|
|
245
|
-
await fetch(`https://api.sendgrid.com/v3/marketing/contacts/search/emails`, {
|
|
246
|
-
method: 'post',
|
|
247
|
-
response: 'json',
|
|
248
|
-
timeout: 60000,
|
|
249
|
-
headers: {
|
|
250
|
-
'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}`,
|
|
251
|
-
'Content-Type': 'application/json',
|
|
252
|
-
},
|
|
253
|
-
body: {
|
|
254
|
-
emails: email.to.map(obj => obj.email),
|
|
255
|
-
},
|
|
256
|
-
})
|
|
257
|
-
.then((json) => {
|
|
258
|
-
assistant.log('Got contact names', json);
|
|
259
|
-
|
|
260
|
-
// Update names from contacts
|
|
261
|
-
email.to.forEach((to) => {
|
|
262
|
-
const match = json.result[to.email];
|
|
263
|
-
if (match) {
|
|
264
|
-
email.to[0].name = match.contact.first_name || email.dynamicTemplateData.user.personal.name.first;
|
|
265
|
-
}
|
|
266
|
-
});
|
|
267
|
-
})
|
|
268
|
-
.catch((e) => {
|
|
269
|
-
if (e.status === 404) {
|
|
270
|
-
assistant.log('Contact does not exist in database');
|
|
271
|
-
} else {
|
|
272
|
-
assistant.error('Failed to get contact names', e);
|
|
273
|
-
}
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
// Log resolved email
|
|
277
|
-
assistant.log('Resolved email.to', email.to);
|
|
278
|
-
|
|
279
|
-
// Delete empty names
|
|
280
|
-
email.to.forEach((to) => {
|
|
281
|
-
if (!to.name) {
|
|
282
|
-
delete to.name;
|
|
283
|
-
}
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
// Validate required fields
|
|
287
|
-
if (!email.to.length || !email.to[0].email) {
|
|
288
|
-
throw new Error('Parameter to is required with at least one email');
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
if (!email.templateId && !settings.html) {
|
|
292
|
-
throw new Error('Parameter <template> is required');
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
if (!email.asm.groupId) {
|
|
296
|
-
throw new Error('Parameter <group> is required');
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
if (!email.subject) {
|
|
300
|
-
throw new Error('Parameter <subject> is required');
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// Set personalization data
|
|
304
|
-
email.dynamicTemplateData.personalization = {
|
|
305
|
-
email: email.to[0].email,
|
|
306
|
-
name: email.to[0].name,
|
|
307
|
-
};
|
|
308
|
-
|
|
309
|
-
// Build unsubscribe URL
|
|
310
|
-
email.dynamicTemplateData.email.unsubscribeUrl = `https://itwcreativeworks.com/portal/account/email-preferences?email=${encode(email.to[0].email)}&asmId=${encode(email.asm.groupId)}&templateId=${encode(email.templateId)}&appName=${email.dynamicTemplateData.app.name}&appUrl=${email.dynamicTemplateData.app.url}`;
|
|
311
|
-
email.dynamicTemplateData.email.categories = email.categories;
|
|
312
|
-
email.dynamicTemplateData.email.carbonCopy = settings.copy;
|
|
313
|
-
email.dynamicTemplateData.email.ensureUnique = settings.ensureUnique;
|
|
314
|
-
|
|
315
|
-
// Handle raw HTML content (overrides template)
|
|
316
|
-
if (settings.html) {
|
|
317
|
-
email.content = [
|
|
318
|
-
{
|
|
319
|
-
type: 'text/html',
|
|
320
|
-
value: settings.html,
|
|
321
|
-
},
|
|
322
|
-
];
|
|
323
|
-
delete email.templateId;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// Set SendGrid options
|
|
327
|
-
email.substitutionWrappers = ['{{', '}}'];
|
|
328
|
-
email.headers = {
|
|
329
|
-
'List-Unsubscribe': `<${email.dynamicTemplateData.email.unsubscribeUrl}>`,
|
|
330
|
-
};
|
|
331
|
-
|
|
332
|
-
// Generate email hash for deduplication
|
|
333
|
-
email.hash = crypto.createHash('sha256');
|
|
334
|
-
email.hash.update(
|
|
335
|
-
email.to.map(obj => obj.email).join(',')
|
|
336
|
-
+ email.from.email
|
|
337
|
-
+ email.subject
|
|
338
|
-
+ settings.categories.join(',')
|
|
339
|
-
);
|
|
340
|
-
email.hash = email.hash.digest('hex');
|
|
341
|
-
|
|
342
|
-
// Clone and clean data for stringified version
|
|
343
|
-
const emailClonedData = _.cloneDeep(email.dynamicTemplateData);
|
|
344
|
-
emailClonedData.app.sponsorships = {};
|
|
345
|
-
email.dynamicTemplateData._stringified = JSON.stringify(emailClonedData, null, 2);
|
|
346
|
-
|
|
347
|
-
return email;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// Helper: Save to email queue for deferred sending
|
|
351
|
-
async function saveToEmailQueue(assistant, admin, email) {
|
|
352
|
-
// Clear email before storage
|
|
353
|
-
const emailCloned = _.cloneDeepWith(email, (value) => {
|
|
354
|
-
if (typeof value === 'undefined') {
|
|
355
|
-
return null;
|
|
356
|
-
}
|
|
357
|
-
});
|
|
358
|
-
delete emailCloned.dynamicTemplateData._stringified;
|
|
359
|
-
|
|
360
|
-
assistant.log(`saveToEmailQueue(): Saving email ${email.dynamicTemplateData.email.id} to email-queue`, emailCloned);
|
|
361
|
-
|
|
362
|
-
await admin.firestore().doc(`email-queue/${email.dynamicTemplateData.email.id}`)
|
|
363
|
-
.set(emailCloned)
|
|
364
|
-
.then(() => {
|
|
365
|
-
assistant.log(`saveToEmailQueue(): Success ${email.dynamicTemplateData.email.id}`);
|
|
366
|
-
})
|
|
367
|
-
.catch((e) => {
|
|
368
|
-
assistant.error(`saveToEmailQueue(): Failed ${email.dynamicTemplateData.email.id}`, e);
|
|
369
|
-
throw e;
|
|
370
|
-
});
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// Helper: Ensure this is the first instance of this email (deduplication)
|
|
374
|
-
async function ensureFirstInstance(Manager, assistant, admin, settings, email) {
|
|
375
|
-
const timeout = assistant.isDevelopment() ? 3000 : 45000;
|
|
376
|
-
|
|
377
|
-
const hash = email.hash;
|
|
378
|
-
const id = email.dynamicTemplateData.email.id;
|
|
379
|
-
|
|
380
|
-
assistant.log(`ensureFirstInstance(): Checking for unique email hash=${hash}, id=${id}`);
|
|
381
|
-
|
|
382
|
-
// Skip uniqueness check if disabled
|
|
383
|
-
if (!settings.ensureUnique) {
|
|
384
|
-
assistant.log(`ensureFirstInstance(): Skipping unique email check`);
|
|
385
|
-
return true;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
// Save email to temporary storage
|
|
389
|
-
await admin.firestore().doc(`temporary/email-queue`).set({
|
|
390
|
-
[hash]: {
|
|
391
|
-
[id]: assistant.meta.startTime.timestampUNIX,
|
|
392
|
-
},
|
|
393
|
-
}, { merge: true })
|
|
394
|
-
.then(() => {
|
|
395
|
-
assistant.log(`ensureFirstInstance(): Saved email to temporary storage`, hash);
|
|
396
|
-
})
|
|
397
|
-
.catch((e) => {
|
|
398
|
-
assistant.error(`ensureFirstInstance(): Failed to save email to temporary storage`, hash, e);
|
|
399
|
-
});
|
|
400
|
-
|
|
401
|
-
// Wait for timeout to allow duplicates to register
|
|
402
|
-
assistant.log(`ensureFirstInstance(): Waiting for ${timeout / 1000} sec`);
|
|
403
|
-
await powertools.poll(async () => {
|
|
404
|
-
return false;
|
|
405
|
-
}, { interval: 1000, timeout: timeout })
|
|
406
|
-
.catch(() => {
|
|
407
|
-
assistant.log(`ensureFirstInstance(): Timeout reached`);
|
|
408
|
-
});
|
|
409
|
-
|
|
410
|
-
// Check if this is the first instance
|
|
411
|
-
const result = await admin.firestore().doc(`temporary/email-queue`).get()
|
|
412
|
-
.then((doc) => doc.data()?.[hash] || {})
|
|
413
|
-
.catch(() => ({}));
|
|
414
|
-
|
|
415
|
-
const length = Object.keys(result).length;
|
|
416
|
-
const isFirstInstance = length === 1 || result[id] === Math.min(...Object.values(result));
|
|
417
|
-
|
|
418
|
-
assistant.log(`ensureFirstInstance(): Result`, result);
|
|
419
|
-
assistant.log(`ensureFirstInstance(): Result isFirstInstance`, length, isFirstInstance);
|
|
420
|
-
|
|
421
|
-
if (isFirstInstance) {
|
|
422
|
-
// Delete email from temporary storage
|
|
423
|
-
await admin.firestore().doc(`temporary/email-queue`).set({
|
|
424
|
-
[hash]: FieldValue.delete(),
|
|
425
|
-
}, { merge: true })
|
|
426
|
-
.then(() => {
|
|
427
|
-
assistant.log(`ensureFirstInstance(): Deleted email from temporary storage`, hash);
|
|
428
|
-
})
|
|
429
|
-
.catch((e) => {
|
|
430
|
-
assistant.error(`ensureFirstInstance(): Failed to delete email from temporary storage`, hash, e);
|
|
431
|
-
});
|
|
432
|
-
|
|
433
|
-
return true;
|
|
434
|
-
} else {
|
|
435
|
-
assistant.warn(`ensureFirstInstance(): Email is not unique`, hash, length, result);
|
|
436
|
-
return false;
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
// Helper: URL-encode base64
|
|
441
|
-
function encode(s) {
|
|
442
|
-
return encodeURIComponent(Buffer.from(String(s)).toString('base64'));
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
// Helper: Filter and deduplicate email array
|
|
446
|
-
function filterEmails(array) {
|
|
447
|
-
return array
|
|
448
|
-
.filter(obj => obj.email && typeof obj.email === 'string')
|
|
449
|
-
.map(obj => JSON.stringify(obj))
|
|
450
|
-
.filter((obj, index, self) => self.indexOf(obj) === index)
|
|
451
|
-
.map(obj => JSON.parse(obj));
|
|
452
|
-
}
|
|
@@ -22,7 +22,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
|
|
|
22
22
|
assistant.log('Running hook:', settings.path);
|
|
23
23
|
|
|
24
24
|
// Load the hook
|
|
25
|
-
const hook = loadHook(
|
|
25
|
+
const hook = loadHook(assistant, settings.path);
|
|
26
26
|
|
|
27
27
|
if (!hook) {
|
|
28
28
|
return assistant.respond(`Hook not found: ${settings.path}`, { code: 404 });
|
|
@@ -55,7 +55,8 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
|
|
|
55
55
|
};
|
|
56
56
|
|
|
57
57
|
// Helper: Load hook from multiple paths
|
|
58
|
-
function loadHook(
|
|
58
|
+
function loadHook(assistant, hookPath) {
|
|
59
|
+
const Manager = assistant.Manager;
|
|
59
60
|
const paths = [
|
|
60
61
|
`${Manager.rootDirectory}/functions/core/${hookPath}`,
|
|
61
62
|
`${Manager.cwd}/${hookPath}`,
|
|
@@ -9,9 +9,7 @@ const BAD_TOKEN_REASONS = [
|
|
|
9
9
|
];
|
|
10
10
|
const BATCH_SIZE = 500;
|
|
11
11
|
|
|
12
|
-
module.exports = async ({ assistant,
|
|
13
|
-
const { admin } = libraries;
|
|
14
|
-
|
|
12
|
+
module.exports = async ({ assistant, user, settings, analytics }) => {
|
|
15
13
|
// Require authentication
|
|
16
14
|
if (!user.authenticated) {
|
|
17
15
|
return assistant.respond('Authentication required', { code: 401 });
|
|
@@ -66,7 +64,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics, librari
|
|
|
66
64
|
};
|
|
67
65
|
|
|
68
66
|
// Process tokens and send notifications
|
|
69
|
-
await processTokens(
|
|
67
|
+
await processTokens(assistant, notification, filterOptions, response);
|
|
70
68
|
|
|
71
69
|
// Track analytics
|
|
72
70
|
analytics.event('admin/notification', { sent: response.sent });
|
|
@@ -75,13 +73,15 @@ module.exports = async ({ assistant, Manager, user, settings, analytics, librari
|
|
|
75
73
|
};
|
|
76
74
|
|
|
77
75
|
// Helper: Process tokens and send notifications
|
|
78
|
-
async function processTokens(
|
|
76
|
+
async function processTokens(assistant, notification, options, response) {
|
|
77
|
+
const Manager = assistant.Manager;
|
|
78
|
+
|
|
79
79
|
// If a specific token is provided, send directly to it (useful for testing)
|
|
80
80
|
if (options.token) {
|
|
81
81
|
assistant.log(`Sending to specific token: ${options.token}`);
|
|
82
82
|
|
|
83
83
|
try {
|
|
84
|
-
await sendBatch(assistant,
|
|
84
|
+
await sendBatch(assistant, [options.token], 0, notification, response);
|
|
85
85
|
assistant.log('Single token notification sent successfully.');
|
|
86
86
|
} catch (e) {
|
|
87
87
|
assistant.error('Error sending to specific token', e);
|
|
@@ -144,7 +144,7 @@ async function processTokens(Manager, assistant, admin, notification, options, r
|
|
|
144
144
|
// Send the batch
|
|
145
145
|
try {
|
|
146
146
|
assistant.log(`Sending batch ${index} with ${batchTokens.length} tokens.`);
|
|
147
|
-
await sendBatch(assistant,
|
|
147
|
+
await sendBatch(assistant, batchTokens, index, notification, response);
|
|
148
148
|
} catch (e) {
|
|
149
149
|
assistant.error(`Error sending batch ${index}`, e);
|
|
150
150
|
}
|
|
@@ -166,7 +166,8 @@ async function processTokens(Manager, assistant, admin, notification, options, r
|
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
// Helper: Send batch of notifications
|
|
169
|
-
async function sendBatch(assistant,
|
|
169
|
+
async function sendBatch(assistant, batch, id, notification, response) {
|
|
170
|
+
const { admin } = assistant.Manager.libraries;
|
|
170
171
|
try {
|
|
171
172
|
assistant.log(`Sending batch #${id}: tokens=${batch.length}...`, notification);
|
|
172
173
|
|
|
@@ -210,7 +211,7 @@ async function sendBatch(assistant, admin, batch, id, notification, response) {
|
|
|
210
211
|
|
|
211
212
|
// Clean bad tokens
|
|
212
213
|
if (result.failureCount > 0) {
|
|
213
|
-
await cleanTokens(assistant,
|
|
214
|
+
await cleanTokens(assistant, batch, result.responses, id, response);
|
|
214
215
|
}
|
|
215
216
|
|
|
216
217
|
// Update response
|
|
@@ -221,7 +222,7 @@ async function sendBatch(assistant, admin, batch, id, notification, response) {
|
|
|
221
222
|
}
|
|
222
223
|
|
|
223
224
|
// Helper: Clean bad tokens
|
|
224
|
-
async function cleanTokens(assistant,
|
|
225
|
+
async function cleanTokens(assistant, batch, results, id, response) {
|
|
225
226
|
assistant.log(`Cleaning ${results.length} tokens of batch ID: ${id}`);
|
|
226
227
|
|
|
227
228
|
const cleanPromises = results
|
|
@@ -234,7 +235,7 @@ async function cleanTokens(assistant, admin, batch, results, id, response) {
|
|
|
234
235
|
return null;
|
|
235
236
|
}
|
|
236
237
|
|
|
237
|
-
return deleteToken(assistant,
|
|
238
|
+
return deleteToken(assistant, item.token, item.error.code, response);
|
|
238
239
|
})
|
|
239
240
|
.filter(Boolean);
|
|
240
241
|
|
|
@@ -247,7 +248,8 @@ async function cleanTokens(assistant, admin, batch, results, id, response) {
|
|
|
247
248
|
}
|
|
248
249
|
|
|
249
250
|
// Helper: Delete bad token
|
|
250
|
-
async function deleteToken(assistant,
|
|
251
|
+
async function deleteToken(assistant, token, errorCode, response) {
|
|
252
|
+
const { admin } = assistant.Manager.libraries;
|
|
251
253
|
try {
|
|
252
254
|
await admin.firestore().doc(`${PATH_NOTIFICATIONS}/${token}`).delete();
|
|
253
255
|
|
|
@@ -93,7 +93,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
|
|
|
93
93
|
assistant.log('main(): Creating post...', settings);
|
|
94
94
|
|
|
95
95
|
// Extract all images
|
|
96
|
-
const imageResult = await extractImages(
|
|
96
|
+
const imageResult = await extractImages(assistant, octokit, settings).catch(e => e);
|
|
97
97
|
if (imageResult instanceof Error) {
|
|
98
98
|
return assistant.respond(imageResult.message, { code: 400 });
|
|
99
99
|
}
|
|
@@ -119,8 +119,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
|
|
|
119
119
|
};
|
|
120
120
|
|
|
121
121
|
// Helper: Extract and upload images
|
|
122
|
-
async function extractImages(
|
|
123
|
-
const fetch = Manager.require('wonderful-fetch');
|
|
122
|
+
async function extractImages(assistant, octokit, settings) {
|
|
124
123
|
|
|
125
124
|
const matches = settings.body.matchAll(IMAGE_REGEX);
|
|
126
125
|
const images = Array.from(matches).map(match => ({
|
|
@@ -146,7 +145,7 @@ async function extractImages(Manager, assistant, octokit, settings) {
|
|
|
146
145
|
const image = images[index];
|
|
147
146
|
|
|
148
147
|
// Download image
|
|
149
|
-
const download = await downloadImage(
|
|
148
|
+
const download = await downloadImage(assistant, image.src, image.alt).catch(e => e);
|
|
150
149
|
|
|
151
150
|
assistant.log('extractImages(): download', download);
|
|
152
151
|
|
|
@@ -176,8 +175,8 @@ async function extractImages(Manager, assistant, octokit, settings) {
|
|
|
176
175
|
}
|
|
177
176
|
|
|
178
177
|
// Helper: Download image
|
|
179
|
-
async function downloadImage(
|
|
180
|
-
const fetch = Manager.require('wonderful-fetch');
|
|
178
|
+
async function downloadImage(assistant, src, alt) {
|
|
179
|
+
const fetch = assistant.Manager.require('wonderful-fetch');
|
|
181
180
|
const hyphenated = hyphenate(alt);
|
|
182
181
|
|
|
183
182
|
assistant.log(`downloadImage(): src=${src}, alt=${alt}, hyphenated=${hyphenated}`);
|
|
@@ -66,7 +66,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
|
|
|
66
66
|
assistant.log('main(): Editing post...', settings);
|
|
67
67
|
|
|
68
68
|
// Fetch existing post using NEW API format
|
|
69
|
-
const fetchedPost = await fetchPost(
|
|
69
|
+
const fetchedPost = await fetchPost(assistant, settings.url).catch(e => e);
|
|
70
70
|
if (fetchedPost instanceof Error) {
|
|
71
71
|
return assistant.respond(fetchedPost.message, { code: fetchedPost.status || 404 });
|
|
72
72
|
}
|
|
@@ -86,7 +86,8 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
|
|
|
86
86
|
};
|
|
87
87
|
|
|
88
88
|
// Helper: Fetch existing post
|
|
89
|
-
async function fetchPost(
|
|
89
|
+
async function fetchPost(assistant, url) {
|
|
90
|
+
const Manager = assistant.Manager;
|
|
90
91
|
const fetch = Manager.require('wonderful-fetch');
|
|
91
92
|
|
|
92
93
|
// Use NEW API format
|