@xuda.io/account_module 1.2.2309 → 1.2.2311

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/index.mjs CHANGED
@@ -3535,6 +3535,7 @@ export const get_account_name = async function (req) {
3535
3535
  active_account_profile_id,
3536
3536
  network_country_code,
3537
3537
  public_profile_disabled,
3538
+ verify_level,
3538
3539
  } = data.data.account_info;
3539
3540
 
3540
3541
  obj = {
@@ -3570,6 +3571,11 @@ export const get_account_name = async function (req) {
3570
3571
  // email,// email?.[0] || '',
3571
3572
  avatar_source,
3572
3573
  active_account_profile_id,
3574
+ // The verification level of the account behind a card. Every caller that
3575
+ // builds a contact card off another account copies this onto the card doc
3576
+ // so the background tile can say what the level is; without it a card for
3577
+ // a verified person read as L0, which is a false statement about them.
3578
+ verify_level,
3573
3579
  };
3574
3580
  }
3575
3581
  return { code: 1, data: obj }; //is_online: data?.data?.socket_id ? true : false
@@ -4247,6 +4253,7 @@ export const merge_contact = async function (req) {
4247
4253
  }
4248
4254
 
4249
4255
  const primary_contact_save_ret = await save_contact(uid, primary_contact_doc);
4256
+ log_contact_activity(uid, contact_to_merge, 'merged_in', { count: contact_to_delete.length, emails: primary_contact_doc.email, note: 'kept as the primary of a duplicate set' });
4250
4257
 
4251
4258
  for (let contact_id of contact_to_delete) {
4252
4259
  try {
@@ -4255,6 +4262,7 @@ export const merge_contact = async function (req) {
4255
4262
  contact_doc.stat_ts = Date.now();
4256
4263
  contact_doc.stat_reason = 'merge';
4257
4264
  await save_contact(uid, contact_doc);
4265
+ log_contact_activity(uid, contact_id, 'merged_away', { into: contact_to_merge, note: 'folded into the primary duplicate and closed' });
4258
4266
  } catch (error) {}
4259
4267
  }
4260
4268
 
@@ -4303,6 +4311,13 @@ const level_pattern = function (doc) {
4303
4311
  return `level-${level}-pattern.png`;
4304
4312
  };
4305
4313
 
4314
+ // The level tile is the DEFAULT for every contact card, not a fallback. It used
4315
+ // to be computed first and then overwritten by a tile keyed off where the
4316
+ // contact came from (xu / authentic / fictional / email / web), so a card for a
4317
+ // verified person showed the generic XU tile and the level never appeared on any
4318
+ // card that had a contact_uid, which is every card for a real account. Only the
4319
+ // states that say the card cannot show a level at all still win: a shared card
4320
+ // wears its owner's avatar, an avatar being processed says so, and spam says so.
4306
4321
  const get_contact_pattern = async function (doc) {
4307
4322
  let ret = level_pattern(doc);
4308
4323
 
@@ -4323,84 +4338,12 @@ const get_contact_pattern = async function (doc) {
4323
4338
  }
4324
4339
  } else if (doc.is_spam) {
4325
4340
  ret = `spam-pattern.png`;
4326
- } else {
4327
- // if (doc.account_type === 'business') {
4328
- // // if (doc?.is_real_person) {
4329
- // ret = doc.profile_picture;
4330
- // // }
4331
- // } else {
4332
- // if (doc.my_contact) {
4333
- // if (doc.account_type === 'business') {
4334
- // ret = doc.profile_picture;
4335
- // } else {
4336
- // switch (doc.avatar_source) {
4337
- // case 'fictional':
4338
- // ret = `fictional-avatar-pattern.png`;
4339
- // break;
4340
-
4341
- // case 'ai profile':
4342
- // ret = `fictional-avatar-pattern.png`;
4343
- // break;
4344
-
4345
- // case 'authentic profile':
4346
- // ret = `authentic-avatar-pattern.png`;
4347
- // break;
4348
-
4349
- // default:
4350
- // ret = `xu-pattern.png`;
4351
- // break;
4352
- // }
4353
- // }
4354
- // } else
4355
-
4356
- // if (doc?.shared_from_uid) {
4357
- // //&& (doc.pending || (doc.team_req_id && doc.contact_share_copy))) {
4358
- // // pending or active share request
4359
- // const shared_from_uid_ret = await get_account_name({ uid_query: doc.shared_from_uid });
4360
- // ret = shared_from_uid_ret.data.profile_avatar;
4361
- // }
4362
-
4363
- // else if (doc?.reference_doc?.shared_from_uid) {
4364
- // //active share
4365
- // const shared_from_uid_ret = await get_account_name({ uid_query: doc.reference_doc.shared_from_uid });
4366
- // ret = shared_from_uid_ret.data.profile_avatar;
4367
- // }
4368
- // else
4369
- if (doc.contact_uid) {
4370
- switch (doc.avatar_source) {
4371
- case 'fictional':
4372
- ret = `fictional-avatar-pattern.png`;
4373
- break;
4374
-
4375
- case 'ai profile':
4376
- ret = `fictional-avatar-pattern.png`;
4377
- break;
4378
-
4379
- case 'authentic profile':
4380
- ret = `authentic-avatar-pattern.png`;
4381
- break;
4382
-
4383
- default:
4384
- ret = `xu-pattern.png`;
4385
- break;
4386
- }
4387
- } else {
4388
- switch (doc.source) {
4389
- case 'read emails': {
4390
- ret = `email-pattern.png`;
4391
- break;
4392
- }
4393
- case 'web': {
4394
- ret = `web-pattern.png`;
4395
- break;
4396
- }
4397
-
4398
- default:
4399
- break;
4400
- }
4401
- }
4402
- // }
4403
4341
  }
4342
+ // Everything else keeps the level tile. The old where-it-came-from tiles
4343
+ // (xu / authentic / fictional for an account, email / web for a scraped
4344
+ // contact) are gone on purpose: they occupied the one surface that now
4345
+ // carries the verification level, and a contact with no account behind it
4346
+ // has no level to show, which is exactly what L0 says.
4404
4347
  return ret;
4405
4348
  };
4406
4349
 
@@ -4568,6 +4511,10 @@ export const get_contact_info = async function (uid, contact_doc, _id) {
4568
4511
  }
4569
4512
 
4570
4513
  doc.avatar_source = account_info_ret.data.avatar_source;
4514
+ // The card's background tile is the contact's verification level, and the
4515
+ // level lives on the account, not on my copy of them. Carry it over here,
4516
+ // where the account is already in hand, so the tile costs no extra read.
4517
+ doc.verify_level = account_info_ret.data.verify_level;
4571
4518
  // doc.icon_pattern = await get_contact_pattern(doc);
4572
4519
  doc.username = account_info_ret.data.username;
4573
4520
  doc.business_name = account_info_ret.data.business_name;
@@ -4632,6 +4579,7 @@ export const get_pending_contact_out = async function (uid, _id) {
4632
4579
  doc.account_type = account_info_ret.data.account_type;
4633
4580
  doc.avatar_source = account_info_ret.data.avatar_source;
4634
4581
  doc.profile_avatar = account_info_ret.data.profile_avatar;
4582
+ doc.verify_level = account_info_ret.data.verify_level;
4635
4583
  doc.name = `${account_info_ret.data.first_name} ${account_info_ret.data.last_name}`;
4636
4584
  }
4637
4585
  doc.icon_pattern = await get_contact_pattern(doc);
@@ -4663,6 +4611,7 @@ export const get_pending_contact_in = async function (uid, _id) {
4663
4611
  doc.account_type = account_info_ret.data.account_type;
4664
4612
  doc.avatar_source = account_info_ret.data.avatar_source;
4665
4613
  doc.profile_avatar = account_info_ret.data.profile_avatar;
4614
+ doc.verify_level = account_info_ret.data.verify_level;
4666
4615
  doc.name = `${account_info_ret.data.first_name} ${account_info_ret.data.last_name}`;
4667
4616
  }
4668
4617
  doc.card_background = get_contact_background({ pending: true });
@@ -4702,6 +4651,7 @@ const get_user_contact = async function (uid, uid_query) {
4702
4651
  doc.profile_avatar = account_info.profile_avatar;
4703
4652
  doc.profile_picture = account_info.profile_picture;
4704
4653
  doc.avatar_source = account_info.avatar_source;
4654
+ doc.verify_level = account_info.verify_level;
4705
4655
  doc.card_background = get_contact_background(doc);
4706
4656
  doc.icon_pattern = await get_contact_pattern(doc);
4707
4657
  doc.border = get_contact_border(doc);
@@ -4753,7 +4703,9 @@ export const update_contact = async function (req) {
4753
4703
  doc.profile_avatar = account_obj.account_info.profile_avatar;
4754
4704
  }
4755
4705
 
4756
- return await save_contact(uid, doc);
4706
+ const save_ret = await save_contact(uid, doc);
4707
+ log_contact_activity(uid, doc._id, 'updated', { stat, team_req_id, linked_account: contact_uid, note: contact_uid ? 'picture and avatar taken from the linked Xuda account' : undefined });
4708
+ return save_ret;
4757
4709
  }
4758
4710
  return { code: -1, data: 'contact not found' };
4759
4711
  };
@@ -5614,28 +5566,18 @@ setTimeout(async () => {
5614
5566
 
5615
5567
  //////// CONTACTS //////////////
5616
5568
 
5617
- function identifyPersonalProvider(domain) {
5618
- const providers = {
5619
- 'gmail.com': 'Google Gmail',
5620
- 'googlemail.com': 'Google Gmail',
5621
- 'outlook.com': 'Microsoft Outlook',
5622
- 'hotmail.com': 'Microsoft Hotmail',
5623
- 'live.com': 'Microsoft Live',
5624
- 'yahoo.com': 'Yahoo Mail',
5625
- 'ymail.com': 'Yahoo Mail',
5626
- 'aol.com': 'AOL Mail',
5627
- 'icloud.com': 'Apple iCloud',
5628
- 'me.com': 'Apple iCloud',
5629
- 'mac.com': 'Apple iCloud',
5630
- 'protonmail.com': 'ProtonMail',
5631
- 'proton.me': 'ProtonMail',
5632
- 'zoho.com': 'Zoho Mail',
5633
- 'yandex.com': 'Yandex Mail',
5634
- 'mail.com': 'Mail.com',
5635
- 'gmx.com': 'GMX Mail',
5636
- };
5569
+ // A "business" that resolves to the address's own mail provider is not the contact's
5570
+ // employer, it is Gmail. get_business_info, handed a bare gmail.com address, answers
5571
+ // "Gmail / gmail.com / Technology" rather than "Not available", so the only escape hatch the
5572
+ // classification had (business_name === 'Not available') never fired and every personal
5573
+ // Gmail contact came out as a business, storefront placeholder and all. This is the second
5574
+ // escape hatch, built on the provider list that already exists directly below.
5575
+ const businessIsJustTheMailProvider = (business_info) => !!identifyPersonalProvider(business_info?.business_domain || '');
5637
5576
 
5638
- return providers[domain.toLowerCase()];
5577
+ function identifyPersonalProvider(domain) {
5578
+ // The list moved to common so ai_module's contact classifier reads the same one. It used to
5579
+ // live here alone, which is exactly why the classifier could call gmail.com a business.
5580
+ return _common.personal_email_provider(domain);
5639
5581
  }
5640
5582
 
5641
5583
  export const add_contact = async function (req, job_id, headers) {
@@ -5663,6 +5605,16 @@ export const add_contact = async function (req, job_id, headers) {
5663
5605
 
5664
5606
  /////////////////
5665
5607
 
5608
+ // Minted up front rather than at save time, so every classification step
5609
+ // below can file its verdict under the contact it is deciding about. The
5610
+ // steps run BEFORE the doc exists, which is exactly why their reasoning
5611
+ // used to be unrecoverable.
5612
+ const contact_id = await _common.xuda_get_uuid('contact');
5613
+ const app_id = account_profile_info.app_id;
5614
+ const activity = (event, detail) => log_contact_activity(uid, contact_id, event, detail, app_id);
5615
+
5616
+ await activity('created', { source: source || 'unknown', email: email.toLowerCase(), name: name || '', from_account: !!contact_uid, subject: metadata.subject, profile_id: account_profile_obj?._id });
5617
+
5666
5618
  let is_spam = false;
5667
5619
  // let email_type_info;
5668
5620
  // let account_type_info;
@@ -5679,27 +5631,45 @@ export const add_contact = async function (req, job_id, headers) {
5679
5631
  }
5680
5632
 
5681
5633
  set_account_profile_picture(uid, contact_uid, metadata, job_id, headers, account_profile_info);
5634
+ await activity('linked_account', { contact_uid });
5682
5635
  } else {
5683
5636
  is_spam = await isLikelySpamEmail(email);
5637
+ if (is_spam) await activity('spam_check', { by: 'pattern', is_spam: true, note: 'address matched the local spam pattern list, no AI step ran' });
5684
5638
 
5685
5639
  // contact without use account
5686
5640
  cached_contact = await get_xuda_cache(uid, 'contact', email, 'metadata');
5641
+ if (!_.isEmpty(cached_contact)) {
5642
+ // Every classification below is SKIPPED on a cache hit, which is the single
5643
+ // most confusing thing about the result: the contact inherits a verdict that
5644
+ // was reached for an earlier contact on the same address or domain.
5645
+ await activity('classification_reused', { from_cache: true, key: email.toLowerCase(), account_type: cached_contact.account_type, business_name: cached_contact?.business_info?.business_name, is_spam: cached_contact.is_spam });
5646
+ }
5687
5647
 
5688
5648
  if (_.isEmpty(cached_contact)) {
5689
5649
  if (!is_spam && metadata.subject && !metadata.is_sent && !metadata.is_answered && !metadata.not_junk) {
5690
5650
  // deep search for spam
5691
5651
  is_spam = await ai_ms.is_spam_email(uid, email, metadata.subject, account_profile_info);
5652
+ await activity('spam_check', { by: 'ai', is_spam: !!is_spam, subject: metadata.subject });
5692
5653
  }
5693
5654
  if (!is_spam) {
5694
- const is_business = await ai_ms.is_business_contact(uid, email, name, metadata.subject, metadata.summarized_body, account_profile_info);
5655
+ const verdict = await ai_ms.is_business_contact(uid, email, name, metadata.subject, metadata.summarized_body, account_profile_info);
5656
+ // Tolerates the old bare boolean, in case a stale ai_module answers this call.
5657
+ const is_business = typeof verdict === 'object' && verdict !== null ? verdict.is_business : !!verdict;
5695
5658
  account_type = is_business ? 'business' : 'personal';
5659
+ await activity('classified', { by: 'ai', account_type, confidence: verdict?.confidence, why: verdict?.reason, inputs: { email: email.toLowerCase(), name: name || '', subject: metadata.subject || '' } });
5696
5660
  if (is_business) {
5697
5661
  business_info = await ai_ms.get_business_info(uid, '', email, account_profile_info, { light: true });
5698
- if (business_info.business_name !== 'Not available') {
5662
+ if (business_info.business_name !== 'Not available' && !businessIsJustTheMailProvider(business_info)) {
5663
+ await activity('business_info', { by: 'ai', depth: 'light', business_name: business_info.business_name, business_domain: business_info.business_domain, business_category: business_info.business_category });
5699
5664
  business_has_person = await ai_ms.is_business_contact_has_person(uid, email, name, metadata.subject, metadata.summarized_body, account_profile_info);
5665
+ await activity('business_has_person', { by: 'ai', has_person: !!business_has_person });
5700
5666
  } else {
5667
+ const provider = identifyPersonalProvider(business_info?.business_domain || '');
5701
5668
  account_type = 'personal';
5702
5669
  business_info = undefined;
5670
+ // The reversal matters more than the original verdict: the card says
5671
+ // personal even though the business call answered yes.
5672
+ await activity('classified', { by: 'ai', account_type, reverted_from: 'business', reason: provider ? `the only business behind the address is its mail provider (${provider})` : 'no business could be identified behind the address' });
5703
5673
  }
5704
5674
  }
5705
5675
  if (_.isEmpty(cached_contact) && account_type === 'business' && !business_has_person) {
@@ -5708,6 +5678,9 @@ export const add_contact = async function (req, job_id, headers) {
5708
5678
  } else {
5709
5679
  cached_contact = await get_xuda_cache(uid, 'contact', business_info.business_name, 'metadata');
5710
5680
  }
5681
+ if (!_.isEmpty(cached_contact)) {
5682
+ await activity('classification_reused', { from_cache: true, key: business_info.business_domain || business_info.business_name, account_type: cached_contact.account_type, business_name: cached_contact?.business_info?.business_name });
5683
+ }
5711
5684
  }
5712
5685
  }
5713
5686
  }
@@ -5715,7 +5688,7 @@ export const add_contact = async function (req, job_id, headers) {
5715
5688
 
5716
5689
  const d = Date.now();
5717
5690
  const contact_obj = {
5718
- _id: await _common.xuda_get_uuid('contact'),
5691
+ _id: contact_id,
5719
5692
  email: email.toLowerCase(),
5720
5693
  name: cached_contact?.name || name,
5721
5694
  stat: stat || 3,
@@ -5742,13 +5715,16 @@ export const add_contact = async function (req, job_id, headers) {
5742
5715
  if (!is_spam) {
5743
5716
  if (account_type === 'personal' || contact_obj?.business_has_person) {
5744
5717
  contact_obj.person_info = await ai_ms.get_person_info(uid, contact_obj.name, contact_obj.email, account_profile_info, metadata?.summarized_body, { light: true });
5718
+ await activity('person_info', { by: 'ai', depth: 'light', full_name: contact_obj.person_info?.full_name, job_title: contact_obj.person_info?.job_title, company: contact_obj.person_info?.company_name });
5745
5719
  if (!contact_obj.name) {
5746
5720
  contact_obj.name = await ai_ms.get_name_from_email_addr(uid, contact_obj.email, account_profile_info);
5721
+ await activity('name_resolved', { by: 'ai', from: 'email address', name: contact_obj.name });
5747
5722
  }
5748
5723
  }
5749
5724
 
5750
5725
  const conversation_obj = await ai_ms.create_openai_conversation();
5751
5726
  contact_obj.contact_reference_conversation_id = conversation_obj.id;
5727
+ await activity('ai_thread_opened', { conversation_id: conversation_obj.id });
5752
5728
  } else {
5753
5729
  if (!contact_obj.name) {
5754
5730
  contact_obj.name = '';
@@ -5758,6 +5734,10 @@ export const add_contact = async function (req, job_id, headers) {
5758
5734
  const save_ret = await save_contact(uid, contact_obj);
5759
5735
  save_xuda_cache(uid, 'contact', contact_obj.email, null, contact_obj);
5760
5736
 
5737
+ // The saved verdict, in one row, so the trail ends on the answer the card
5738
+ // actually shows rather than making the reader replay the steps above.
5739
+ await activity('saved', { account_type: contact_obj.account_type || 'unset', is_spam: !!contact_obj.is_spam, business_name: contact_obj?.business_info?.business_name, name: contact_obj.name || '' });
5740
+
5761
5741
  if (!contact_obj.is_spam && contact_obj.name) {
5762
5742
  set_contact_profile_picture(uid, contact_obj._id, metadata, job_id, headers, account_profile_info, false);
5763
5743
  }
@@ -5821,6 +5801,7 @@ const set_contact_profile_picture = async function (uid, contact_id, metadata, j
5821
5801
  contact_obj.profile_picture_source = file_ret.profile_picture_source;
5822
5802
 
5823
5803
  const contact_save_ret = await db_module.save_app_couch_doc(account_profile_info.app_id, contact_obj);
5804
+ log_contact_activity(uid, contact_id, 'picture_found', { by: cache ? 'cache' : 'ai', source: file_ret.profile_picture_source, account_type: contact_obj.account_type || 'unset' });
5824
5805
  // profile_picture = contact_obj.profile_picture;
5825
5806
  }
5826
5807
  }
@@ -5905,10 +5886,14 @@ const set_contact_profile_picture = async function (uid, contact_id, metadata, j
5905
5886
  contact_obj.profile_avatar = contact_obj?.profile_avatar_obj?.file_url;
5906
5887
  contact_obj.avatar_source = file_ret?.data?.avatar_source;
5907
5888
  const contact_save_ret = await db_module.save_app_couch_doc(account_profile_info.app_id, contact_obj);
5889
+ log_contact_activity(uid, contact_id, 'avatar_ready', { by: cache ? 'cache' : 'ai', source: contact_obj.avatar_source, account_type: contact_obj.account_type || 'unset' });
5908
5890
  }
5909
5891
  await update_contact_profile_picture_status(uid, contact_id, 3);
5910
5892
  } catch (err) {
5911
5893
  await update_contact_profile_picture_status(uid, contact_id, 1, err.message);
5894
+ // Worth a row of its own: a contact stuck on the shop / silhouette placeholder
5895
+ // looks like a classification result, and this is the line that says otherwise.
5896
+ log_contact_activity(uid, contact_id, 'picture_failed', { error: err?.message });
5912
5897
  delete_xuda_cache(contact_obj);
5913
5898
  }
5914
5899
  };
@@ -6141,6 +6126,7 @@ export const archive_contact = async function (req) {
6141
6126
  contact_doc.stat_reason = 'archived by the user';
6142
6127
 
6143
6128
  const contact_save_ret = await save_contact(uid, contact_doc);
6129
+ log_contact_activity(uid, contact_id, 'archived', { by: 'user', reason: contact_doc.stat_reason });
6144
6130
 
6145
6131
  return contact_save_ret;
6146
6132
  } catch (err) {
@@ -6163,6 +6149,7 @@ export const delete_contact = async function (req, job_id, headers) {
6163
6149
  contact_doc.stat_reason = 'deleted by the user';
6164
6150
 
6165
6151
  const contact_save_ret = await save_contact(uid, contact_doc);
6152
+ log_contact_activity(uid, contact_id, 'deleted', { by: 'user', reason: contact_doc.stat_reason, note: 'conversations attached to this contact were deleted with it' });
6166
6153
 
6167
6154
  ai_msa.delete_depended_chats(uid, contact_id);
6168
6155
  return contact_save_ret;
@@ -6188,6 +6175,7 @@ export const unarchive_contact = async function (req, job_id, headers) {
6188
6175
  contact_doc.stat_reason = 'unarchive by the user';
6189
6176
 
6190
6177
  const contact_save_ret = await save_contact(uid, contact_doc);
6178
+ log_contact_activity(uid, contact_id, 'unarchived', { by: 'user', reason: contact_doc.stat_reason });
6191
6179
 
6192
6180
  if (!contact_doc.is_spam) {
6193
6181
  not_spam_contact(req, job_id, headers);
@@ -6217,6 +6205,7 @@ export const unfriend_contact = async function (req) {
6217
6205
 
6218
6206
  contact_doc.team_req_id = null;
6219
6207
  const contact_save_ret = await save_contact(uid, contact_doc);
6208
+ log_contact_activity(uid, contact_id, 'unfriended', { by: 'user' });
6220
6209
 
6221
6210
  const req_save_ret = await db_module.save_couch_doc('xuda_team', req_doc);
6222
6211
 
@@ -6236,6 +6225,7 @@ export const pin_contact = async function (req) {
6236
6225
  contact_doc.pinned = true;
6237
6226
 
6238
6227
  const contact_save_ret = await save_contact(uid, contact_doc);
6228
+ log_contact_activity(uid, contact_id, 'pinned', { by: 'user' });
6239
6229
 
6240
6230
  ws_dashboard_msa.emit_message_to_dashboard({
6241
6231
  service: 'contact_pinned',
@@ -6280,6 +6270,7 @@ export const unpin_contact = async function (req) {
6280
6270
  contact_doc.pinned = false;
6281
6271
 
6282
6272
  const contact_save_ret = await save_contact(uid, contact_doc);
6273
+ log_contact_activity(uid, contact_id, 'unpinned', { by: 'user' });
6283
6274
 
6284
6275
  ws_dashboard_msa.emit_message_to_dashboard({
6285
6276
  service: 'contact_unpinned',
@@ -6311,22 +6302,42 @@ export const not_spam_contact = async function (req, job_id, headers) {
6311
6302
  stat: 3,
6312
6303
  });
6313
6304
  }
6305
+ const was_spam = !!contact_doc.is_spam;
6314
6306
  contact_doc.is_spam = false;
6307
+ log_contact_activity(uid, contact_id, 'marked_not_spam', { by: 'user', was_spam, note: was_spam ? 'address added to the spam whitelist, classification is being redone' : 'classification is being redone' });
6315
6308
 
6316
6309
  if (!contact_doc.contact_reference_conversation_id) {
6317
6310
  const conversation_obj = await ai_ms.create_openai_conversation();
6318
6311
  contact_doc.contact_reference_conversation_id = conversation_obj.id;
6312
+ await log_contact_activity(uid, contact_id, 'ai_thread_opened', { conversation_id: conversation_obj.id });
6319
6313
  }
6320
6314
 
6321
- const is_business = await ai_ms.is_business_contact(uid, contact_doc.email, contact_doc.name, contact_doc?.metadata?.subject, contact_doc?.metadata?.summarized_body, account_profile_info);
6315
+ const previous_account_type = contact_doc.account_type;
6316
+ const verdict = await ai_ms.is_business_contact(uid, contact_doc.email, contact_doc.name, contact_doc?.metadata?.subject, contact_doc?.metadata?.summarized_body, account_profile_info);
6317
+ const is_business = typeof verdict === 'object' && verdict !== null ? verdict.is_business : !!verdict;
6322
6318
  contact_doc.account_type = is_business ? 'business' : 'personal';
6319
+ await log_contact_activity(uid, contact_id, 'classified', { by: 'ai', trigger: 'not spam', account_type: contact_doc.account_type, confidence: verdict?.confidence, why: verdict?.reason, previous_account_type: previous_account_type || 'unset' });
6320
+ if (!is_business && (contact_doc.business_info || contact_doc.business_has_person)) {
6321
+ // A contact that reclassifies from business to person used to keep the company profile
6322
+ // the old verdict attached to it, because business_info is only ever written inside the
6323
+ // is_business branch and nothing clears it on the way out. The card then showed a person
6324
+ // with a company still on file.
6325
+ const dropped = contact_doc?.business_info?.business_name;
6326
+ contact_doc.business_info = undefined;
6327
+ contact_doc.business_has_person = undefined;
6328
+ await log_contact_activity(uid, contact_id, 'business_info_cleared', { reason: 'contact reclassified as a person', dropped });
6329
+ }
6323
6330
  if (is_business) {
6324
6331
  contact_doc.business_info = await ai_ms.get_business_info(uid, '', contact_doc.email, account_profile_info, { light: true });
6325
- if (contact_doc.business_info.business_name !== 'Not available') {
6332
+ if (contact_doc.business_info.business_name !== 'Not available' && !businessIsJustTheMailProvider(contact_doc.business_info)) {
6333
+ await log_contact_activity(uid, contact_id, 'business_info', { by: 'ai', depth: 'light', business_name: contact_doc.business_info.business_name, business_domain: contact_doc.business_info.business_domain, business_category: contact_doc.business_info.business_category });
6326
6334
  contact_doc.business_has_person = await ai_ms.is_business_contact_has_person(uid, contact_doc.email, contact_doc.name, contact_doc?.metadata?.subject, contact_doc?.metadata?.summarized_body, account_profile_info);
6335
+ await log_contact_activity(uid, contact_id, 'business_has_person', { by: 'ai', has_person: !!contact_doc.business_has_person });
6327
6336
  } else {
6337
+ const provider = identifyPersonalProvider(contact_doc.business_info?.business_domain || '');
6328
6338
  contact_doc.account_type = 'personal';
6329
6339
  contact_doc.business_info = undefined;
6340
+ await log_contact_activity(uid, contact_id, 'classified', { by: 'ai', account_type: 'personal', reverted_from: 'business', reason: provider ? `the only business behind the address is its mail provider (${provider})` : 'no business could be identified behind the address' });
6330
6341
  }
6331
6342
  }
6332
6343
 
@@ -6362,6 +6373,7 @@ export const not_spam_contact = async function (req, job_id, headers) {
6362
6373
  req.email_id = email._id;
6363
6374
  await email_ms.process_pending_email(req, job_id, headers);
6364
6375
  }
6376
+ if (emails.docs.length) log_contact_activity(uid, contact_id, 'emails_reprocessed', { count: emails.docs.length, trigger: 'not spam', note: 'partially processed emails were re-read in full' });
6365
6377
 
6366
6378
  return contact_save_ret;
6367
6379
  } catch (err) {
@@ -6390,6 +6402,7 @@ export const generate_contact_avatar = async function (req, job_id, headers) {
6390
6402
 
6391
6403
  const contact_save_ret = await save_contact(uid, contact_doc);
6392
6404
  await delete_xuda_cache(contact_doc);
6405
+ log_contact_activity(uid, contact_id, 'avatar_regenerate_requested', { by: 'user', note: 'existing picture and avatar cleared, a new avatar is being generated' });
6393
6406
  set_contact_profile_picture(uid, contact_doc._id, {}, job_id, headers, account_profile_info, true);
6394
6407
 
6395
6408
  return contact_save_ret;
@@ -6407,21 +6420,28 @@ export const set_deep_research_contact = async function (req, job_id, headers) {
6407
6420
  try {
6408
6421
  var contact_doc = await get_contact(uid, contact_id);
6409
6422
  contact_doc.deep_research = true;
6423
+ await log_contact_activity(uid, contact_id, 'deep_research_started', { by: 'user', account_type: contact_doc.account_type || 'unset' });
6410
6424
 
6411
6425
  if (contact_doc.account_type === 'business') {
6412
6426
  contact_doc.business_info = await ai_ms.get_business_info(uid, '', contact_doc.email, account_profile_info);
6413
- if (contact_doc.business_info.business_name !== 'Not available') {
6427
+ if (contact_doc.business_info.business_name !== 'Not available' && !businessIsJustTheMailProvider(contact_doc.business_info)) {
6428
+ await log_contact_activity(uid, contact_id, 'business_info', { by: 'ai', depth: 'deep', business_name: contact_doc.business_info.business_name, business_domain: contact_doc.business_info.business_domain, business_category: contact_doc.business_info.business_category });
6414
6429
  contact_doc.business_has_person = await ai_ms.is_business_contact_has_person(uid, contact_doc.email, contact_doc.name, contact_doc?.metadata?.subject, contact_doc?.metadata?.summarized_body, account_profile_info);
6430
+ await log_contact_activity(uid, contact_id, 'business_has_person', { by: 'ai', has_person: !!contact_doc.business_has_person });
6415
6431
  } else {
6432
+ const provider = identifyPersonalProvider(contact_doc.business_info?.business_domain || '');
6416
6433
  contact_doc.account_type = 'personal';
6417
6434
  contact_doc.business_info = undefined;
6435
+ await log_contact_activity(uid, contact_id, 'classified', { by: 'ai', account_type: 'personal', reverted_from: 'business', reason: provider ? `the only business behind the address is its mail provider (${provider})` : 'deep research found no business behind the address' });
6418
6436
  }
6419
6437
  }
6420
6438
 
6421
6439
  if (contact_doc.account_type === 'personal' || contact_doc?.business_has_person) {
6422
6440
  contact_doc.person_info = await ai_ms.get_person_info(uid, contact_doc.name, contact_doc.email, account_profile_info, contact_doc?.metadata?.summarized_body);
6441
+ await log_contact_activity(uid, contact_id, 'person_info', { by: 'ai', depth: 'deep', full_name: contact_doc.person_info?.full_name, job_title: contact_doc.person_info?.job_title, company: contact_doc.person_info?.company_name });
6423
6442
  if (!contact_doc.name) {
6424
6443
  contact_doc.name = await ai_ms.get_name_from_email_addr(uid, contact_doc.email, account_profile_info);
6444
+ await log_contact_activity(uid, contact_id, 'name_resolved', { by: 'ai', from: 'email address', name: contact_doc.name });
6425
6445
  }
6426
6446
  }
6427
6447
 
@@ -6456,6 +6476,8 @@ export const set_deep_research_contact = async function (req, job_id, headers) {
6456
6476
  throw err;
6457
6477
  }
6458
6478
  }
6479
+ if (conversation_items.docs.length) log_contact_activity(uid, contact_id, 'attachments_transcribed', { count: conversation_items.docs.length, trigger: 'deep research' });
6480
+ log_contact_activity(uid, contact_id, 'deep_research_finished', { account_type: contact_doc.account_type || 'unset', business_name: contact_doc?.business_info?.business_name });
6459
6481
 
6460
6482
  return contact_save_ret;
6461
6483
  } catch (err) {
@@ -6475,6 +6497,7 @@ export const unset_deep_research_contact = async function (req, job_id, headers)
6475
6497
 
6476
6498
  const contact_save_ret = await save_contact(uid, contact_doc);
6477
6499
  await delete_xuda_cache(contact_doc);
6500
+ log_contact_activity(uid, contact_id, 'deep_research_turned_off', { by: 'user' });
6478
6501
 
6479
6502
  return contact_save_ret;
6480
6503
  } catch (err) {
@@ -6484,6 +6507,89 @@ export const unset_deep_research_contact = async function (req, job_id, headers)
6484
6507
  };
6485
6508
  }
6486
6509
  };
6510
+
6511
+ // The trail for one contact, newest first.
6512
+ //
6513
+ // Recorded rows are only half the answer: every contact that existed before the
6514
+ // trail did has none, and those are most of them. So the contact doc is read back
6515
+ // into the same shape first (derived: true). The doc keeps one timestamp per
6516
+ // FIELD, not per step, so a derived row is stamped with the closest timestamp it
6517
+ // honestly has (date_created for the creation and the classification that ran
6518
+ // inside it, profile_avatar_stat_ts for the picture, stat_ts for the archive) and
6519
+ // says derived so the UI can mark it as reconstructed rather than observed.
6520
+ export const get_contact_activity = async function (req) {
6521
+ const { uid, contact_id } = req;
6522
+ try {
6523
+ if (!contact_id) throw new Error('contact_id is missing');
6524
+ const account_profile_info = await get_active_account_profile_info(uid);
6525
+ // get_app_couch_doc_native THROWS a bare couch 'missing' for an id that is not in
6526
+ // this account's app db (an id from another account is the common way to get here),
6527
+ // so the not-found check below never ran and the caller saw "missing" with nothing
6528
+ // to act on. Catch it and answer the question that was actually asked.
6529
+ let contact_doc;
6530
+ try {
6531
+ contact_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, contact_id);
6532
+ } catch (_) {
6533
+ contact_doc = null;
6534
+ }
6535
+ if (!contact_doc || contact_doc.docType !== 'contact') throw new Error(`contact ${contact_id} not found`);
6536
+ if (contact_doc.uid !== uid && contact_doc?.uid_created !== uid) throw new Error('Operation not allowed');
6537
+
6538
+ const recorded_ret = await db_module.find_app_couch_query(account_profile_info.app_id, {
6539
+ selector: { docType: 'contact_activity', contact_id },
6540
+ limit: 500,
6541
+ });
6542
+ const recorded = (recorded_ret?.docs || []).map((d) => ({ event: d.event, detail: d.detail || {}, ts: d.ts, derived: false }));
6543
+
6544
+ const derived = [];
6545
+ const add = (event, ts, detail) => {
6546
+ if (!ts) return;
6547
+ // A recorded row always wins: it has the real timestamp and the real inputs.
6548
+ if (recorded.some((r) => r.event === event)) return;
6549
+ derived.push({ event, detail, ts, derived: true });
6550
+ };
6551
+
6552
+ const created_ts = contact_doc.date_created || contact_doc.ts;
6553
+ add('created', created_ts, { source: contact_doc.source || 'unknown', email: contact_doc.email, name: contact_doc.name || '', from_account: !!contact_doc.contact_uid, subject: contact_doc?.metadata?.subject });
6554
+ if (contact_doc.is_spam) add('spam_check', created_ts, { is_spam: true });
6555
+ if (contact_doc.account_type) add('classified', created_ts, { account_type: contact_doc.account_type });
6556
+ if (contact_doc?.business_info?.business_name) add('business_info', created_ts, { business_name: contact_doc.business_info.business_name, business_domain: contact_doc.business_info.business_domain, business_category: contact_doc.business_info.business_category });
6557
+ if (contact_doc.business_has_person !== undefined && contact_doc.business_has_person !== null) add('business_has_person', created_ts, { has_person: !!contact_doc.business_has_person });
6558
+ if (contact_doc?.person_info) add('person_info', created_ts, { full_name: contact_doc.person_info.full_name || contact_doc.person_info.person_full_name, job_title: contact_doc.person_info.job_title, company: contact_doc.person_info.company_name });
6559
+ if (contact_doc.contact_reference_conversation_id) add('ai_thread_opened', created_ts, { conversation_id: contact_doc.contact_reference_conversation_id });
6560
+ if (contact_doc.profile_picture) add('picture_found', contact_doc.profile_avatar_stat_ts || created_ts, { source: contact_doc.profile_picture_source });
6561
+ if (contact_doc.profile_avatar) add('avatar_ready', contact_doc.profile_avatar_stat_ts || created_ts, { source: contact_doc.avatar_source });
6562
+ if (contact_doc.profile_avatar_stat === 1 && contact_doc.profile_avatar_error) add('picture_failed', contact_doc.profile_avatar_stat_ts, { error: contact_doc.profile_avatar_error });
6563
+ if (contact_doc.deep_research) add('deep_research_started', contact_doc.stat_ts || created_ts, {});
6564
+ if (contact_doc.pinned) add('pinned', contact_doc.stat_ts || created_ts, {});
6565
+ // stat carries only the LAST transition, which is why an archive/unarchive
6566
+ // history is exactly what the recorded trail adds from here on.
6567
+ if (contact_doc.stat === 5) add('archived', contact_doc.stat_ts, { reason: contact_doc.stat_reason });
6568
+ if (contact_doc.stat === 4) add('deleted', contact_doc.stat_ts, { reason: contact_doc.stat_reason });
6569
+
6570
+ const rows = [...recorded, ...derived].sort((a, b) => (b.ts || 0) - (a.ts || 0));
6571
+
6572
+ return {
6573
+ code: 1,
6574
+ data: {
6575
+ contact_id,
6576
+ // What the card shows today, so the UI can head the trail with the outcome.
6577
+ current: {
6578
+ account_type: contact_doc.account_type || null,
6579
+ is_spam: !!contact_doc.is_spam,
6580
+ stat: contact_doc.stat,
6581
+ source: contact_doc.source || null,
6582
+ deep_research: !!contact_doc.deep_research,
6583
+ business_name: contact_doc?.business_info?.business_name || null,
6584
+ profile_avatar_stat: contact_doc.profile_avatar_stat,
6585
+ },
6586
+ rows,
6587
+ },
6588
+ };
6589
+ } catch (err) {
6590
+ return { code: -25, data: err.message };
6591
+ }
6592
+ };
6487
6593
  //////// PROFILES //////////////
6488
6594
 
6489
6595
  export const get_pending_share_profile_in = async function (uid, _id) {
@@ -6545,7 +6651,11 @@ export const get_account_profile_info = async function (uid, contact_profile_doc
6545
6651
 
6546
6652
  const get_pattern = async function (doc) {
6547
6653
  // Same level-based default as get_contact_pattern, so a card shows the same
6548
- // verification tile whichever path built it.
6654
+ // verification tile whichever path built it, and the same two states that
6655
+ // outrank a level: a shared profile wears its owner's avatar, spam says
6656
+ // spam. A business profile used to paint its own logo across the card face,
6657
+ // which is why a verified business showed no level at all; the logo is
6658
+ // already the avatar on the same card, so the tile is the level here too.
6549
6659
  let ret = level_pattern(doc);
6550
6660
 
6551
6661
  if (doc.shared_from_uid) {
@@ -6554,26 +6664,6 @@ export const get_account_profile_info = async function (uid, contact_profile_doc
6554
6664
  ret = shared_from_uid_ret.data.profile_avatar;
6555
6665
  } else if (doc.is_spam) {
6556
6666
  ret = `spam-pattern.png`;
6557
- } else if (doc.account_type === 'business') {
6558
- ret = doc.profile_picture;
6559
- } else {
6560
- switch (doc.avatar_source) {
6561
- case 'fictional':
6562
- ret = `fictional-avatar-pattern.png`;
6563
- break;
6564
-
6565
- case 'ai profile':
6566
- ret = `fictional-avatar-pattern.png`;
6567
- break;
6568
-
6569
- case 'authentic profile':
6570
- ret = `authentic-avatar-pattern.png`;
6571
- break;
6572
-
6573
- default:
6574
- ret = `xu-pattern.png`;
6575
- break;
6576
- }
6577
6667
  }
6578
6668
 
6579
6669
  return ret;
@@ -6631,6 +6721,10 @@ export const get_account_profile_info = async function (uid, contact_profile_doc
6631
6721
  // console.log(stringToColour('Project Z')); // -> Consistent color for "Project Z"
6632
6722
 
6633
6723
  doc.avatar_source = account_info_ret.data.avatar_source;
6724
+ // A profile inherits the verification level of the account that owns it (a
6725
+ // shared profile, the account that shared it), so its card reads the same
6726
+ // level as its owner's contact card instead of falling back to L0.
6727
+ doc.verify_level = account_info_ret.data.verify_level;
6634
6728
  doc.icon_pattern = await get_pattern(doc);
6635
6729
  doc.name = doc.profile_name;
6636
6730
  doc.interactions = 0;
@@ -7030,6 +7124,48 @@ export const get_contact = async function (uid, contact_id) {
7030
7124
  return contact_ret;
7031
7125
  };
7032
7126
 
7127
+ // ---------------------------------------------------------------------------
7128
+ // Contact activity trail
7129
+ //
7130
+ // A contact accumulates a lot of history that was previously only visible as
7131
+ // the END STATE on its own doc: it was created from somewhere, several AI steps
7132
+ // ran on it and each decided something (spam, business or personal, the business
7133
+ // profile, the person profile, the name, the avatar), and it may since have been
7134
+ // archived, unarchived, pinned, re-classified or sent to deep research. The doc
7135
+ // keeps the last answer of each and nothing about when, in what order, or what
7136
+ // the previous answer was, so "why is this contact a business" had no trace to
7137
+ // read back.
7138
+ //
7139
+ // One `contact_activity` doc per event, in the same app db as the contact.
7140
+ // Deliberately append-only and deliberately silent: a failure to WRITE the trail
7141
+ // must never fail the action being recorded, so every call is wrapped and logged
7142
+ // to the console instead of thrown. Callers do not await it for that reason
7143
+ // either, except where the next line already awaits something else anyway.
7144
+ // ---------------------------------------------------------------------------
7145
+ const log_contact_activity = async function (uid, contact_id, event, detail = {}, app_id) {
7146
+ try {
7147
+ if (!uid || !contact_id || !event) return null;
7148
+ const app = app_id || (await get_active_account_profile_info(uid))?.app_id;
7149
+ if (!app) return null;
7150
+
7151
+ return await db_module.save_app_couch_doc_native(app, {
7152
+ _id: await _common.xuda_get_uuid('contact_activity'),
7153
+ docType: 'contact_activity',
7154
+ contact_id,
7155
+ uid,
7156
+ event,
7157
+ // Values the UI prints back verbatim, so keep them short and human. Never
7158
+ // put a full AI response or an email body here: the trail is a summary.
7159
+ detail,
7160
+ ts: Date.now(),
7161
+ stat: 3,
7162
+ });
7163
+ } catch (err) {
7164
+ console.error('[account_module] contact activity not recorded:', event, contact_id, err?.message);
7165
+ return null;
7166
+ }
7167
+ };
7168
+
7033
7169
  export const save_contact = async function (uid, contact_doc) {
7034
7170
  const account_profile_info = await get_active_account_profile_info(uid);
7035
7171
  const contact_ret = await db_module.save_app_couch_doc(account_profile_info.app_id, contact_doc);
package/index_ms.mjs CHANGED
@@ -449,6 +449,10 @@ export const unset_deep_research_contact = async function (...args) {
449
449
  return await broker.send_to_queue("unset_deep_research_contact", ...args);
450
450
  };
451
451
 
452
+ export const get_contact_activity = async function (...args) {
453
+ return await broker.send_to_queue("get_contact_activity", ...args);
454
+ };
455
+
452
456
  export const get_pending_share_profile_in = async function (...args) {
453
457
  return await broker.send_to_queue("get_pending_share_profile_in", ...args);
454
458
  };
package/index_msa.mjs CHANGED
@@ -449,6 +449,10 @@ export const unset_deep_research_contact = function (...args) {
449
449
  broker.send_to_queue_async("unset_deep_research_contact", ...args);
450
450
  };
451
451
 
452
+ export const get_contact_activity = function (...args) {
453
+ broker.send_to_queue_async("get_contact_activity", ...args);
454
+ };
455
+
452
456
  export const get_pending_share_profile_in = function (...args) {
453
457
  broker.send_to_queue_async("get_pending_share_profile_in", ...args);
454
458
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xuda.io/account_module",
3
- "version": "1.2.2309",
3
+ "version": "1.2.2311",
4
4
  "description": "Xuda Account Server Module",
5
5
  "main": "index.mjs",
6
6
  "dependencies": {