backend-manager 5.0.150 → 5.0.151

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.150",
3
+ "version": "5.0.151",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -95,10 +95,12 @@ Module.prototype.main = function () {
95
95
 
96
96
  // Infer name if not provided
97
97
  let nameInferred = null;
98
+ let company = '';
98
99
  if (!firstName && !lastName) {
99
100
  nameInferred = await inferContact(email, assistant);
100
101
  firstName = nameInferred.firstName;
101
102
  lastName = nameInferred.lastName;
103
+ company = nameInferred.company;
102
104
  }
103
105
 
104
106
  // Add to providers
@@ -112,6 +114,7 @@ Module.prototype.main = function () {
112
114
  email,
113
115
  firstName,
114
116
  lastName,
117
+ company,
115
118
  source,
116
119
  });
117
120
  }
@@ -144,6 +144,7 @@ const FIELDS = {
144
144
 
145
145
  // User identity
146
146
  user_auth_uid: { source: 'user', path: 'auth.uid', type: 'text' },
147
+ user_personal_company: { source: 'user', path: 'personal.company.name', type: 'text' },
147
148
  user_personal_country: { source: 'user', path: 'personal.location.country', type: 'text' },
148
149
  user_metadata_signup_date: { source: 'user', path: 'metadata.created.timestamp', type: 'date' },
149
150
  user_metadata_last_activity: { source: 'user', path: 'metadata.updated.timestamp', type: 'date' },
@@ -62,7 +62,7 @@ function Marketing(assistant) {
62
62
  Marketing.prototype.add = async function (options) {
63
63
  const self = this;
64
64
  const assistant = self.assistant;
65
- const { email, firstName, lastName, source, customFields } = options;
65
+ const { email, firstName, lastName, company, source, customFields } = options;
66
66
 
67
67
  if (!email) {
68
68
  assistant.warn('Marketing.add(): No email provided, skipping');
@@ -85,6 +85,7 @@ Marketing.prototype.add = async function (options) {
85
85
  email,
86
86
  firstName,
87
87
  lastName,
88
+ company,
88
89
  customFields,
89
90
  }).then((r) => { results.sendgrid = r; })
90
91
  );
@@ -96,6 +97,7 @@ Marketing.prototype.add = async function (options) {
96
97
  email,
97
98
  firstName,
98
99
  lastName,
100
+ company,
99
101
  source,
100
102
  }).then((r) => { results.beehiiv = r; })
101
103
  );
@@ -215,20 +215,25 @@ async function getPublicationId() {
215
215
  * @param {Array<{name: string, value: string}>} [options.customFields] - Pre-built custom fields
216
216
  * @returns {{ success: boolean, id?: string, error?: string }}
217
217
  */
218
- async function addContact({ email, firstName, lastName, source, customFields }) {
218
+ async function addContact({ email, firstName, lastName, company, source, customFields }) {
219
219
  const publicationId = await getPublicationId();
220
220
 
221
221
  if (!publicationId) {
222
222
  return { success: false, error: 'Publication not found' };
223
223
  }
224
224
 
225
+ const fields = [...(customFields || [])];
226
+ if (company) {
227
+ fields.push({ name: 'company', value: company });
228
+ }
229
+
225
230
  return addSubscriber({
226
231
  email,
227
232
  firstName,
228
233
  lastName,
229
234
  source,
230
235
  publicationId,
231
- customFields: customFields || [],
236
+ customFields: fields,
232
237
  });
233
238
  }
234
239
 
@@ -370,7 +370,7 @@ async function listSingleSends(options) {
370
370
  * @param {object} [options.customFields] - Pre-built custom_fields object (keyed by SendGrid field IDs)
371
371
  * @returns {{ success: boolean, jobId?: string, listId?: string, error?: string }}
372
372
  */
373
- async function addContact({ email, firstName, lastName, customFields }) {
373
+ async function addContact({ email, firstName, lastName, company, customFields }) {
374
374
  const contact = {
375
375
  email: email.toLowerCase(),
376
376
  first_name: firstName || undefined,
@@ -378,6 +378,15 @@ async function addContact({ email, firstName, lastName, customFields }) {
378
378
  custom_fields: customFields || {},
379
379
  };
380
380
 
381
+ // Add company to custom fields if provided (requires field provisioned via OMEGA)
382
+ if (company) {
383
+ const idMap = await resolveFieldIds();
384
+ const companyFieldId = idMap['user_personal_company'];
385
+ if (companyFieldId) {
386
+ contact.custom_fields[companyFieldId] = company;
387
+ }
388
+ }
389
+
381
390
  const listId = await getListId();
382
391
  const result = await upsertContacts({
383
392
  contacts: [contact],
@@ -31,12 +31,14 @@ const GENERIC_DOMAINS = new Set([
31
31
  async function inferContact(email, assistant) {
32
32
  if (process.env.BACKEND_MANAGER_OPENAI_API_KEY) {
33
33
  const aiResult = await inferContactWithAI(email, assistant);
34
- if (aiResult && (aiResult.firstName || aiResult.lastName)) {
34
+ if (aiResult) {
35
35
  return aiResult;
36
36
  }
37
37
  }
38
38
 
39
- return inferContactFromEmail(email);
39
+ // TODO: Re-enable regex fallback if needed
40
+ // return inferContactFromEmail(email);
41
+ return { firstName: '', lastName: '', company: '', confidence: 0, method: 'none' };
40
42
  }
41
43
 
42
44
  /**
@@ -48,7 +50,7 @@ async function inferContact(email, assistant) {
48
50
  */
49
51
  async function inferContactWithAI(email, assistant) {
50
52
  try {
51
- const ai = assistant.Manager.AI(assistant);
53
+ const ai = assistant.Manager.AI(assistant, process.env.BACKEND_MANAGER_OPENAI_API_KEY);
52
54
  const result = await ai.request({
53
55
  model: 'gpt-5-mini',
54
56
  timeout: 30000,
@@ -63,12 +65,13 @@ async function inferContactWithAI(email, assistant) {
63
65
  },
64
66
  });
65
67
 
66
- if (result?.firstName !== undefined) {
68
+ const parsed = result?.content;
69
+ if (parsed?.firstName !== undefined) {
67
70
  return {
68
- firstName: capitalize(result.firstName || ''),
69
- lastName: capitalize(result.lastName || ''),
70
- company: capitalize(result.company || ''),
71
- confidence: typeof result.confidence === 'number' ? result.confidence : 0.5,
71
+ firstName: capitalize(parsed.firstName || ''),
72
+ lastName: capitalize(parsed.lastName || ''),
73
+ company: capitalize(parsed.company || ''),
74
+ confidence: typeof parsed.confidence === 'number' ? parsed.confidence : 0.5,
72
75
  method: 'ai',
73
76
  };
74
77
  }
@@ -1,9 +1,19 @@
1
1
  <identity>
2
- You extract names and company from email addresses.
2
+ You extract real human names and company from email addresses.
3
3
  </identity>
4
4
 
5
+ <rules>
6
+ - Only return a name if it is a REAL human name. If the local part contains placeholder text, gibberish, generic words, or fictional/brand names, return empty strings for firstName and lastName.
7
+ - Placeholder/test examples that are NOT real names: firstname.lastname, first.last, asdf.qwerty, test.user, mixed.case, bobs.burgers, john.doe (this is a well-known placeholder)
8
+ - Generic role words are NOT names: admin, info, support, hello, contact, sales, billing, noreply, webmaster, postmaster, dev, ceo
9
+ - Single letters or initials ARE acceptable (e.g., "j" from j@company.com → firstName: "J")
10
+ - ALWAYS infer company from the domain, even when there is no name. Generic email providers (gmail.com, yahoo.com, hotmail.com, outlook.com, aol.com, icloud.com, mail.com, protonmail.com, proton.me, zoho.com, yandex.com, gmx.com, live.com, msn.com, me.com) should return empty company.
11
+ - Hyphenated names should preserve capitalization on both parts (e.g., jean-pierre → Jean-Pierre)
12
+ - confidence should reflect how certain you are that the name is a REAL person's name. Placeholders and gibberish should never have confidence above 0.
13
+ </rules>
14
+
5
15
  <format>
6
- Return ONLY valid JSON like so:
16
+ Return ONLY valid JSON:
7
17
  {
8
18
  "firstName": "...",
9
19
  "lastName": "...",
@@ -11,12 +21,12 @@ Return ONLY valid JSON like so:
11
21
  "confidence": "..."
12
22
  }
13
23
 
14
- - firstName: First name (string), capitalized
15
- - lastName: Last name (string), capitalized
16
- - company: Company name (string), capitalized
17
- - confidence: Confidence level (number), 0-1 scale
24
+ - firstName: First name (string), properly capitalized
25
+ - lastName: Last name (string), properly capitalized
26
+ - company: Company name (string), capitalized. Infer from domain (not generic providers).
27
+ - confidence: Confidence level (number), 0-1 scale. How sure you are the name is real.
18
28
 
19
- If you cannot determine a name, use empty strings.
29
+ If you cannot determine a real name, use empty strings for firstName and lastName.
20
30
  </format>
21
31
 
22
32
  <examples>
@@ -30,7 +40,7 @@ If you cannot determine a name, use empty strings.
30
40
  </example>
31
41
  <example>
32
42
  <input>support@bigcorp.io</input>
33
- <output>{"firstName": "", "lastName": "", "company": "Bigcorp", "confidence": 0.7}</output>
43
+ <output>{"firstName": "", "lastName": "", "company": "Bigcorp", "confidence": 0}</output>
34
44
  </example>
35
45
  <example>
36
46
  <input>mary_jane_watson@stark-industries.com</input>
@@ -38,6 +48,26 @@ If you cannot determine a name, use empty strings.
38
48
  </example>
39
49
  <example>
40
50
  <input>info@company.org</input>
41
- <output>{"firstName": "", "lastName": "", "company": "Company", "confidence": 0.6}</output>
51
+ <output>{"firstName": "", "lastName": "", "company": "Company", "confidence": 0}</output>
52
+ </example>
53
+ <example>
54
+ <input>firstname.lastname@company.com</input>
55
+ <output>{"firstName": "", "lastName": "", "company": "Company", "confidence": 0}</output>
56
+ </example>
57
+ <example>
58
+ <input>asdf.qwerty@outlook.com</input>
59
+ <output>{"firstName": "", "lastName": "", "company": "", "confidence": 0}</output>
60
+ </example>
61
+ <example>
62
+ <input>jean-pierre.dupont@orange.fr</input>
63
+ <output>{"firstName": "Jean-Pierre", "lastName": "Dupont", "company": "Orange", "confidence": 0.95}</output>
64
+ </example>
65
+ <example>
66
+ <input>bobs.burgers@example.com</input>
67
+ <output>{"firstName": "", "lastName": "", "company": "Example", "confidence": 0}</output>
68
+ </example>
69
+ <example>
70
+ <input>j@company.com</input>
71
+ <output>{"firstName": "J", "lastName": "", "company": "Company", "confidence": 0.3}</output>
42
72
  </example>
43
73
  </examples>
@@ -0,0 +1,34 @@
1
+ /**
2
+ * POST /admin/infer-contact - Infer contact info from email addresses
3
+ * Admin-only endpoint for testing/debugging the inferContact pipeline
4
+ */
5
+ const { inferContact } = require('../../../libraries/infer-contact.js');
6
+
7
+ module.exports = async ({ assistant, user, settings }) => {
8
+
9
+ // Require authentication
10
+ if (!user.authenticated) {
11
+ return assistant.respond('Authentication required', { code: 401 });
12
+ }
13
+
14
+ // Require admin
15
+ if (!user.roles.admin) {
16
+ return assistant.respond('Admin required.', { code: 403 });
17
+ }
18
+
19
+ // Accept single email or array of emails
20
+ const emails = Array.isArray(settings.emails)
21
+ ? settings.emails
22
+ : [settings.email];
23
+
24
+ const results = await Promise.all(
25
+ emails
26
+ .filter(Boolean)
27
+ .map(async (email) => {
28
+ const result = await inferContact(email, assistant);
29
+ return { email, ...result };
30
+ })
31
+ );
32
+
33
+ return assistant.respond({ results });
34
+ };
@@ -83,12 +83,14 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
83
83
  }
84
84
  }
85
85
 
86
- // Infer name if not provided
86
+ // Infer contact info if name not provided
87
87
  let nameInferred = null;
88
+ let company = '';
88
89
  if (!firstName && !lastName) {
89
90
  nameInferred = await inferContact(email, assistant);
90
91
  firstName = nameInferred.firstName;
91
92
  lastName = nameInferred.lastName;
93
+ company = nameInferred.company;
92
94
  }
93
95
 
94
96
  // Add to providers
@@ -102,6 +104,7 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
102
104
  email,
103
105
  firstName,
104
106
  lastName,
107
+ company,
105
108
  source,
106
109
  });
107
110
  }
@@ -0,0 +1,12 @@
1
+ module.exports = () => ({
2
+ email: {
3
+ types: ['string'],
4
+ default: '',
5
+ required: false,
6
+ },
7
+ emails: {
8
+ types: ['array'],
9
+ default: [],
10
+ required: false,
11
+ },
12
+ });
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Test: POST /admin/infer-contact
3
+ * Tests the admin infer-contact endpoint for inferring names from email addresses
4
+ *
5
+ * AI inference tests only run when TEST_EXTENDED_MODE is set (requires BACKEND_MANAGER_OPENAI_API_KEY)
6
+ */
7
+ module.exports = {
8
+ description: 'Admin infer contact',
9
+ type: 'group',
10
+ tests: [
11
+ // ─── Auth rejection ───
12
+
13
+ {
14
+ name: 'unauthenticated-rejected',
15
+ auth: 'none',
16
+ timeout: 15000,
17
+
18
+ async run({ http, assert }) {
19
+ const response = await http.post('admin/infer-contact', {
20
+ email: 'john.smith@gmail.com',
21
+ });
22
+
23
+ assert.isError(response, 401, 'Should reject unauthenticated requests');
24
+ },
25
+ },
26
+
27
+ {
28
+ name: 'non-admin-rejected',
29
+ auth: 'user',
30
+ timeout: 15000,
31
+
32
+ async run({ http, assert }) {
33
+ const response = await http.post('admin/infer-contact', {
34
+ email: 'john.smith@gmail.com',
35
+ });
36
+
37
+ assert.isError(response, 403, 'Should reject non-admin users');
38
+ },
39
+ },
40
+
41
+ // ─── Single email ───
42
+
43
+ {
44
+ name: 'single-email-returns-result',
45
+ auth: 'admin',
46
+ timeout: 30000,
47
+
48
+ async run({ http, assert }) {
49
+ const response = await http.post('admin/infer-contact', {
50
+ email: 'john.smith@gmail.com',
51
+ });
52
+
53
+ assert.isSuccess(response, 'Should succeed for admin');
54
+ assert.hasProperty(response, 'data.results', 'Should have results array');
55
+ assert.equal(response.data.results.length, 1, 'Should have 1 result');
56
+
57
+ const result = response.data.results[0];
58
+ assert.equal(result.email, 'john.smith@gmail.com', 'Should include email');
59
+ assert.ok(result.firstName, 'Should infer a first name');
60
+ assert.ok(result.lastName, 'Should infer a last name');
61
+ assert.hasProperty(result, 'method', 'Should include method');
62
+ assert.hasProperty(result, 'confidence', 'Should include confidence');
63
+ },
64
+ },
65
+
66
+ // ─── Batch emails ───
67
+
68
+ {
69
+ name: 'batch-emails-returns-all-results',
70
+ auth: 'admin',
71
+ timeout: 60000,
72
+
73
+ async run({ http, assert }) {
74
+ const emails = [
75
+ 'sarah.connor@skynet.io',
76
+ 'bob-jones@hotmail.com',
77
+ 'admin@acme.com',
78
+ ];
79
+
80
+ const response = await http.post('admin/infer-contact', { emails });
81
+
82
+ assert.isSuccess(response, 'Should succeed for batch');
83
+ assert.equal(response.data.results.length, 3, 'Should have 3 results');
84
+
85
+ // Verify each email is in results
86
+ for (let i = 0; i < emails.length; i++) {
87
+ assert.equal(response.data.results[i].email, emails[i], `Result ${i} should match input email`);
88
+ }
89
+ },
90
+ },
91
+
92
+ // ─── Name parsing (regex) ───
93
+
94
+ {
95
+ name: 'regex-parses-dot-separated-names',
96
+ auth: 'admin',
97
+ timeout: 30000,
98
+
99
+ async run({ http, assert }) {
100
+ const response = await http.post('admin/infer-contact', {
101
+ email: 'alice.wonderland@example.com',
102
+ });
103
+
104
+ assert.isSuccess(response);
105
+ const result = response.data.results[0];
106
+
107
+ // AI or regex — either way should get the name right
108
+ assert.equal(result.firstName, 'Alice', 'Should parse first name');
109
+ assert.equal(result.lastName, 'Wonderland', 'Should parse last name');
110
+ },
111
+ },
112
+
113
+ {
114
+ name: 'infers-company-from-custom-domain',
115
+ auth: 'admin',
116
+ timeout: 30000,
117
+
118
+ async run({ http, assert }) {
119
+ const response = await http.post('admin/infer-contact', {
120
+ email: 'ceo@my-startup.com',
121
+ });
122
+
123
+ assert.isSuccess(response);
124
+ const result = response.data.results[0];
125
+ assert.ok(result.company, 'Should infer company from custom domain');
126
+ },
127
+ },
128
+
129
+ {
130
+ name: 'no-company-from-generic-domain',
131
+ auth: 'admin',
132
+ timeout: 30000,
133
+
134
+ async run({ http, assert }) {
135
+ const response = await http.post('admin/infer-contact', {
136
+ email: 'someone@gmail.com',
137
+ });
138
+
139
+ assert.isSuccess(response);
140
+ const result = response.data.results[0];
141
+ assert.equal(result.company, '', 'Should not infer company from gmail');
142
+ },
143
+ },
144
+
145
+ // ─── AI inference (requires TEST_EXTENDED_MODE) ───
146
+
147
+ {
148
+ name: 'ai-inference',
149
+ auth: 'admin',
150
+ timeout: 60000,
151
+ skip: !process.env.TEST_EXTENDED_MODE
152
+ ? 'TEST_EXTENDED_MODE not set (skipping AI inference test)'
153
+ : false,
154
+
155
+ async run({ http, assert }) {
156
+ const response = await http.post('admin/infer-contact', {
157
+ emails: [
158
+ 'john.smith@microsoft.com',
159
+ 'xkcd42@gmail.com',
160
+ 'bobs.burgers@example.com',
161
+ ],
162
+ });
163
+
164
+ assert.isSuccess(response, 'AI inference should succeed');
165
+
166
+ const results = response.data.results;
167
+ const aiResults = results.filter(r => r.method === 'ai');
168
+
169
+ assert.ok(aiResults.length > 0, 'At least one result should use AI method');
170
+
171
+ // john.smith should be parsed correctly regardless of method
172
+ const john = results.find(r => r.email === 'john.smith@microsoft.com');
173
+ assert.equal(john.firstName, 'John', 'Should infer John');
174
+ assert.equal(john.lastName, 'Smith', 'Should infer Smith');
175
+ },
176
+ },
177
+
178
+ // ─── Edge cases ───
179
+
180
+ {
181
+ name: 'empty-emails-array-returns-empty',
182
+ auth: 'admin',
183
+ timeout: 15000,
184
+
185
+ async run({ http, assert }) {
186
+ const response = await http.post('admin/infer-contact', {
187
+ emails: [],
188
+ });
189
+
190
+ assert.isSuccess(response);
191
+ assert.equal(response.data.results.length, 0, 'Empty input should return empty results');
192
+ },
193
+ },
194
+ ],
195
+ };