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.
Files changed (61) hide show
  1. package/CHANGELOG.md +2 -2
  2. package/CLAUDE.md +14 -6
  3. package/README.md +6 -6
  4. package/TODO-MARKETING.md +3 -0
  5. package/TODO-PAYMENT-v2.md +71 -0
  6. package/TODO.md +7 -0
  7. package/package.json +3 -3
  8. package/src/cli/commands/{emulators.js → emulator.js} +15 -15
  9. package/src/cli/commands/index.js +1 -1
  10. package/src/cli/commands/setup-tests/{emulators-config.js → emulator-config.js} +4 -4
  11. package/src/cli/commands/setup-tests/index.js +2 -2
  12. package/src/cli/commands/setup-tests/project-id-consistency.js +1 -1
  13. package/src/cli/commands/test.js +16 -16
  14. package/src/cli/index.js +4 -4
  15. package/src/manager/events/auth/on-create.js +5 -158
  16. package/src/manager/events/firestore/payments-webhooks/analytics.js +4 -3
  17. package/src/manager/events/firestore/payments-webhooks/on-write.js +56 -6
  18. package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js +3 -3
  19. package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-failed.js +1 -1
  20. package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +32 -28
  21. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/cancellation-requested.js +3 -3
  22. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js +3 -3
  23. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js +3 -3
  24. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js +3 -3
  25. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js +3 -3
  26. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js +3 -3
  27. package/src/manager/functions/core/actions/api/admin/send-email.js +0 -131
  28. package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +2 -137
  29. package/src/manager/functions/core/actions/api/general/emails/general:download-app-link.js +2 -2
  30. package/src/manager/functions/core/actions/api/user/sign-up.js +1 -1
  31. package/src/manager/index.js +12 -0
  32. package/src/manager/libraries/email.js +523 -0
  33. package/src/manager/libraries/infer-contact.js +140 -0
  34. package/src/manager/libraries/prompts/infer-contact.md +43 -0
  35. package/src/manager/routes/admin/backup/post.js +4 -3
  36. package/src/manager/routes/admin/email/post.js +11 -428
  37. package/src/manager/routes/admin/hook/post.js +3 -2
  38. package/src/manager/routes/admin/notification/post.js +14 -12
  39. package/src/manager/routes/admin/post/post.js +5 -6
  40. package/src/manager/routes/admin/post/put.js +3 -2
  41. package/src/manager/routes/admin/stats/get.js +19 -10
  42. package/src/manager/routes/general/email/post.js +8 -21
  43. package/src/manager/routes/general/email/templates/download-app-link.js +2 -2
  44. package/src/manager/routes/marketing/contact/post.js +2 -100
  45. package/src/manager/routes/payments/intent/post.js +0 -2
  46. package/src/manager/routes/payments/intent/processors/test.js +9 -10
  47. package/src/manager/routes/user/oauth2/_helpers.js +3 -2
  48. package/src/manager/routes/user/oauth2/delete.js +3 -3
  49. package/src/manager/routes/user/oauth2/get.js +2 -2
  50. package/src/manager/routes/user/oauth2/post.js +9 -9
  51. package/src/manager/routes/user/sessions/delete.js +4 -3
  52. package/src/manager/routes/user/signup/post.js +254 -54
  53. package/src/manager/schemas/admin/email/post.js +13 -8
  54. package/src/test/run-tests.js +1 -1
  55. package/test/functions/admin/send-email.js +1 -88
  56. package/test/helpers/email.js +421 -0
  57. package/test/helpers/infer-contact.js +299 -0
  58. package/test/routes/admin/email.js +41 -90
  59. package/REFACTOR-BEM-API.md +0 -76
  60. package/REFACTOR-MIDDLEWARE.md +0 -62
  61. 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, admin, e);
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, admin, null);
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, admin, error) {
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
- * Admin-only endpoint to send transactional emails
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
- const { FieldValue } = require('firebase-admin/firestore');
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
- // Build email object
39
- const email = await buildEmail(Manager, assistant, settings).catch(e => e);
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
- return assistant.respond({
61
- status: 'queued',
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
- // Send email via SendGrid
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(Manager, assistant, settings.path);
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(Manager, assistant, hookPath) {
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, Manager, user, settings, analytics, libraries }) => {
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(Manager, assistant, admin, notification, filterOptions, response);
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(Manager, assistant, admin, notification, options, response) {
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, admin, [options.token], 0, notification, response);
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, admin, batchTokens, index, notification, response);
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, admin, batch, id, notification, response) {
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, admin, batch, result.responses, id, response);
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, admin, batch, results, id, response) {
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, admin, item.token, item.error.code, response);
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, admin, token, errorCode, response) {
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(Manager, assistant, octokit, settings).catch(e => e);
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(Manager, assistant, octokit, settings) {
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(Manager, assistant, image.src, image.alt).catch(e => e);
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(Manager, assistant, src, alt) {
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(Manager, assistant, settings.url).catch(e => e);
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(Manager, assistant, url) {
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