@xuda.io/account_module 1.2.2309 → 1.2.2310

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
@@ -4247,6 +4247,7 @@ export const merge_contact = async function (req) {
4247
4247
  }
4248
4248
 
4249
4249
  const primary_contact_save_ret = await save_contact(uid, primary_contact_doc);
4250
+ 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
4251
 
4251
4252
  for (let contact_id of contact_to_delete) {
4252
4253
  try {
@@ -4255,6 +4256,7 @@ export const merge_contact = async function (req) {
4255
4256
  contact_doc.stat_ts = Date.now();
4256
4257
  contact_doc.stat_reason = 'merge';
4257
4258
  await save_contact(uid, contact_doc);
4259
+ log_contact_activity(uid, contact_id, 'merged_away', { into: contact_to_merge, note: 'folded into the primary duplicate and closed' });
4258
4260
  } catch (error) {}
4259
4261
  }
4260
4262
 
@@ -4753,7 +4755,9 @@ export const update_contact = async function (req) {
4753
4755
  doc.profile_avatar = account_obj.account_info.profile_avatar;
4754
4756
  }
4755
4757
 
4756
- return await save_contact(uid, doc);
4758
+ const save_ret = await save_contact(uid, doc);
4759
+ 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 });
4760
+ return save_ret;
4757
4761
  }
4758
4762
  return { code: -1, data: 'contact not found' };
4759
4763
  };
@@ -5663,6 +5667,16 @@ export const add_contact = async function (req, job_id, headers) {
5663
5667
 
5664
5668
  /////////////////
5665
5669
 
5670
+ // Minted up front rather than at save time, so every classification step
5671
+ // below can file its verdict under the contact it is deciding about. The
5672
+ // steps run BEFORE the doc exists, which is exactly why their reasoning
5673
+ // used to be unrecoverable.
5674
+ const contact_id = await _common.xuda_get_uuid('contact');
5675
+ const app_id = account_profile_info.app_id;
5676
+ const activity = (event, detail) => log_contact_activity(uid, contact_id, event, detail, app_id);
5677
+
5678
+ await activity('created', { source: source || 'unknown', email: email.toLowerCase(), name: name || '', from_account: !!contact_uid, subject: metadata.subject, profile_id: account_profile_obj?._id });
5679
+
5666
5680
  let is_spam = false;
5667
5681
  // let email_type_info;
5668
5682
  // let account_type_info;
@@ -5679,27 +5693,42 @@ export const add_contact = async function (req, job_id, headers) {
5679
5693
  }
5680
5694
 
5681
5695
  set_account_profile_picture(uid, contact_uid, metadata, job_id, headers, account_profile_info);
5696
+ await activity('linked_account', { contact_uid });
5682
5697
  } else {
5683
5698
  is_spam = await isLikelySpamEmail(email);
5699
+ 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
5700
 
5685
5701
  // contact without use account
5686
5702
  cached_contact = await get_xuda_cache(uid, 'contact', email, 'metadata');
5703
+ if (!_.isEmpty(cached_contact)) {
5704
+ // Every classification below is SKIPPED on a cache hit, which is the single
5705
+ // most confusing thing about the result: the contact inherits a verdict that
5706
+ // was reached for an earlier contact on the same address or domain.
5707
+ 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 });
5708
+ }
5687
5709
 
5688
5710
  if (_.isEmpty(cached_contact)) {
5689
5711
  if (!is_spam && metadata.subject && !metadata.is_sent && !metadata.is_answered && !metadata.not_junk) {
5690
5712
  // deep search for spam
5691
5713
  is_spam = await ai_ms.is_spam_email(uid, email, metadata.subject, account_profile_info);
5714
+ await activity('spam_check', { by: 'ai', is_spam: !!is_spam, subject: metadata.subject });
5692
5715
  }
5693
5716
  if (!is_spam) {
5694
5717
  const is_business = await ai_ms.is_business_contact(uid, email, name, metadata.subject, metadata.summarized_body, account_profile_info);
5695
5718
  account_type = is_business ? 'business' : 'personal';
5719
+ await activity('classified', { by: 'ai', account_type, inputs: { email: email.toLowerCase(), name: name || '', subject: metadata.subject || '' } });
5696
5720
  if (is_business) {
5697
5721
  business_info = await ai_ms.get_business_info(uid, '', email, account_profile_info, { light: true });
5698
5722
  if (business_info.business_name !== 'Not available') {
5723
+ 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
5724
  business_has_person = await ai_ms.is_business_contact_has_person(uid, email, name, metadata.subject, metadata.summarized_body, account_profile_info);
5725
+ await activity('business_has_person', { by: 'ai', has_person: !!business_has_person });
5700
5726
  } else {
5701
5727
  account_type = 'personal';
5702
5728
  business_info = undefined;
5729
+ // The reversal matters more than the original verdict: the card says
5730
+ // personal even though the business call answered yes.
5731
+ await activity('classified', { by: 'ai', account_type, reverted_from: 'business', reason: 'no business could be identified behind the address' });
5703
5732
  }
5704
5733
  }
5705
5734
  if (_.isEmpty(cached_contact) && account_type === 'business' && !business_has_person) {
@@ -5708,6 +5737,9 @@ export const add_contact = async function (req, job_id, headers) {
5708
5737
  } else {
5709
5738
  cached_contact = await get_xuda_cache(uid, 'contact', business_info.business_name, 'metadata');
5710
5739
  }
5740
+ if (!_.isEmpty(cached_contact)) {
5741
+ 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 });
5742
+ }
5711
5743
  }
5712
5744
  }
5713
5745
  }
@@ -5715,7 +5747,7 @@ export const add_contact = async function (req, job_id, headers) {
5715
5747
 
5716
5748
  const d = Date.now();
5717
5749
  const contact_obj = {
5718
- _id: await _common.xuda_get_uuid('contact'),
5750
+ _id: contact_id,
5719
5751
  email: email.toLowerCase(),
5720
5752
  name: cached_contact?.name || name,
5721
5753
  stat: stat || 3,
@@ -5742,13 +5774,16 @@ export const add_contact = async function (req, job_id, headers) {
5742
5774
  if (!is_spam) {
5743
5775
  if (account_type === 'personal' || contact_obj?.business_has_person) {
5744
5776
  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 });
5777
+ 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
5778
  if (!contact_obj.name) {
5746
5779
  contact_obj.name = await ai_ms.get_name_from_email_addr(uid, contact_obj.email, account_profile_info);
5780
+ await activity('name_resolved', { by: 'ai', from: 'email address', name: contact_obj.name });
5747
5781
  }
5748
5782
  }
5749
5783
 
5750
5784
  const conversation_obj = await ai_ms.create_openai_conversation();
5751
5785
  contact_obj.contact_reference_conversation_id = conversation_obj.id;
5786
+ await activity('ai_thread_opened', { conversation_id: conversation_obj.id });
5752
5787
  } else {
5753
5788
  if (!contact_obj.name) {
5754
5789
  contact_obj.name = '';
@@ -5758,6 +5793,10 @@ export const add_contact = async function (req, job_id, headers) {
5758
5793
  const save_ret = await save_contact(uid, contact_obj);
5759
5794
  save_xuda_cache(uid, 'contact', contact_obj.email, null, contact_obj);
5760
5795
 
5796
+ // The saved verdict, in one row, so the trail ends on the answer the card
5797
+ // actually shows rather than making the reader replay the steps above.
5798
+ 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 || '' });
5799
+
5761
5800
  if (!contact_obj.is_spam && contact_obj.name) {
5762
5801
  set_contact_profile_picture(uid, contact_obj._id, metadata, job_id, headers, account_profile_info, false);
5763
5802
  }
@@ -5821,6 +5860,7 @@ const set_contact_profile_picture = async function (uid, contact_id, metadata, j
5821
5860
  contact_obj.profile_picture_source = file_ret.profile_picture_source;
5822
5861
 
5823
5862
  const contact_save_ret = await db_module.save_app_couch_doc(account_profile_info.app_id, contact_obj);
5863
+ 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
5864
  // profile_picture = contact_obj.profile_picture;
5825
5865
  }
5826
5866
  }
@@ -5905,10 +5945,14 @@ const set_contact_profile_picture = async function (uid, contact_id, metadata, j
5905
5945
  contact_obj.profile_avatar = contact_obj?.profile_avatar_obj?.file_url;
5906
5946
  contact_obj.avatar_source = file_ret?.data?.avatar_source;
5907
5947
  const contact_save_ret = await db_module.save_app_couch_doc(account_profile_info.app_id, contact_obj);
5948
+ 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
5949
  }
5909
5950
  await update_contact_profile_picture_status(uid, contact_id, 3);
5910
5951
  } catch (err) {
5911
5952
  await update_contact_profile_picture_status(uid, contact_id, 1, err.message);
5953
+ // Worth a row of its own: a contact stuck on the shop / silhouette placeholder
5954
+ // looks like a classification result, and this is the line that says otherwise.
5955
+ log_contact_activity(uid, contact_id, 'picture_failed', { error: err?.message });
5912
5956
  delete_xuda_cache(contact_obj);
5913
5957
  }
5914
5958
  };
@@ -6141,6 +6185,7 @@ export const archive_contact = async function (req) {
6141
6185
  contact_doc.stat_reason = 'archived by the user';
6142
6186
 
6143
6187
  const contact_save_ret = await save_contact(uid, contact_doc);
6188
+ log_contact_activity(uid, contact_id, 'archived', { by: 'user', reason: contact_doc.stat_reason });
6144
6189
 
6145
6190
  return contact_save_ret;
6146
6191
  } catch (err) {
@@ -6163,6 +6208,7 @@ export const delete_contact = async function (req, job_id, headers) {
6163
6208
  contact_doc.stat_reason = 'deleted by the user';
6164
6209
 
6165
6210
  const contact_save_ret = await save_contact(uid, contact_doc);
6211
+ 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
6212
 
6167
6213
  ai_msa.delete_depended_chats(uid, contact_id);
6168
6214
  return contact_save_ret;
@@ -6188,6 +6234,7 @@ export const unarchive_contact = async function (req, job_id, headers) {
6188
6234
  contact_doc.stat_reason = 'unarchive by the user';
6189
6235
 
6190
6236
  const contact_save_ret = await save_contact(uid, contact_doc);
6237
+ log_contact_activity(uid, contact_id, 'unarchived', { by: 'user', reason: contact_doc.stat_reason });
6191
6238
 
6192
6239
  if (!contact_doc.is_spam) {
6193
6240
  not_spam_contact(req, job_id, headers);
@@ -6217,6 +6264,7 @@ export const unfriend_contact = async function (req) {
6217
6264
 
6218
6265
  contact_doc.team_req_id = null;
6219
6266
  const contact_save_ret = await save_contact(uid, contact_doc);
6267
+ log_contact_activity(uid, contact_id, 'unfriended', { by: 'user' });
6220
6268
 
6221
6269
  const req_save_ret = await db_module.save_couch_doc('xuda_team', req_doc);
6222
6270
 
@@ -6236,6 +6284,7 @@ export const pin_contact = async function (req) {
6236
6284
  contact_doc.pinned = true;
6237
6285
 
6238
6286
  const contact_save_ret = await save_contact(uid, contact_doc);
6287
+ log_contact_activity(uid, contact_id, 'pinned', { by: 'user' });
6239
6288
 
6240
6289
  ws_dashboard_msa.emit_message_to_dashboard({
6241
6290
  service: 'contact_pinned',
@@ -6280,6 +6329,7 @@ export const unpin_contact = async function (req) {
6280
6329
  contact_doc.pinned = false;
6281
6330
 
6282
6331
  const contact_save_ret = await save_contact(uid, contact_doc);
6332
+ log_contact_activity(uid, contact_id, 'unpinned', { by: 'user' });
6283
6333
 
6284
6334
  ws_dashboard_msa.emit_message_to_dashboard({
6285
6335
  service: 'contact_unpinned',
@@ -6311,22 +6361,30 @@ export const not_spam_contact = async function (req, job_id, headers) {
6311
6361
  stat: 3,
6312
6362
  });
6313
6363
  }
6364
+ const was_spam = !!contact_doc.is_spam;
6314
6365
  contact_doc.is_spam = false;
6366
+ 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
6367
 
6316
6368
  if (!contact_doc.contact_reference_conversation_id) {
6317
6369
  const conversation_obj = await ai_ms.create_openai_conversation();
6318
6370
  contact_doc.contact_reference_conversation_id = conversation_obj.id;
6371
+ await log_contact_activity(uid, contact_id, 'ai_thread_opened', { conversation_id: conversation_obj.id });
6319
6372
  }
6320
6373
 
6374
+ const previous_account_type = contact_doc.account_type;
6321
6375
  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);
6322
6376
  contact_doc.account_type = is_business ? 'business' : 'personal';
6377
+ await log_contact_activity(uid, contact_id, 'classified', { by: 'ai', trigger: 'not spam', account_type: contact_doc.account_type, previous_account_type: previous_account_type || 'unset' });
6323
6378
  if (is_business) {
6324
6379
  contact_doc.business_info = await ai_ms.get_business_info(uid, '', contact_doc.email, account_profile_info, { light: true });
6325
6380
  if (contact_doc.business_info.business_name !== 'Not available') {
6381
+ 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
6382
  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);
6383
+ await log_contact_activity(uid, contact_id, 'business_has_person', { by: 'ai', has_person: !!contact_doc.business_has_person });
6327
6384
  } else {
6328
6385
  contact_doc.account_type = 'personal';
6329
6386
  contact_doc.business_info = undefined;
6387
+ await log_contact_activity(uid, contact_id, 'classified', { by: 'ai', account_type: 'personal', reverted_from: 'business', reason: 'no business could be identified behind the address' });
6330
6388
  }
6331
6389
  }
6332
6390
 
@@ -6362,6 +6420,7 @@ export const not_spam_contact = async function (req, job_id, headers) {
6362
6420
  req.email_id = email._id;
6363
6421
  await email_ms.process_pending_email(req, job_id, headers);
6364
6422
  }
6423
+ 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
6424
 
6366
6425
  return contact_save_ret;
6367
6426
  } catch (err) {
@@ -6390,6 +6449,7 @@ export const generate_contact_avatar = async function (req, job_id, headers) {
6390
6449
 
6391
6450
  const contact_save_ret = await save_contact(uid, contact_doc);
6392
6451
  await delete_xuda_cache(contact_doc);
6452
+ log_contact_activity(uid, contact_id, 'avatar_regenerate_requested', { by: 'user', note: 'existing picture and avatar cleared, a new avatar is being generated' });
6393
6453
  set_contact_profile_picture(uid, contact_doc._id, {}, job_id, headers, account_profile_info, true);
6394
6454
 
6395
6455
  return contact_save_ret;
@@ -6407,21 +6467,27 @@ export const set_deep_research_contact = async function (req, job_id, headers) {
6407
6467
  try {
6408
6468
  var contact_doc = await get_contact(uid, contact_id);
6409
6469
  contact_doc.deep_research = true;
6470
+ await log_contact_activity(uid, contact_id, 'deep_research_started', { by: 'user', account_type: contact_doc.account_type || 'unset' });
6410
6471
 
6411
6472
  if (contact_doc.account_type === 'business') {
6412
6473
  contact_doc.business_info = await ai_ms.get_business_info(uid, '', contact_doc.email, account_profile_info);
6413
6474
  if (contact_doc.business_info.business_name !== 'Not available') {
6475
+ 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
6476
  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);
6477
+ await log_contact_activity(uid, contact_id, 'business_has_person', { by: 'ai', has_person: !!contact_doc.business_has_person });
6415
6478
  } else {
6416
6479
  contact_doc.account_type = 'personal';
6417
6480
  contact_doc.business_info = undefined;
6481
+ await log_contact_activity(uid, contact_id, 'classified', { by: 'ai', account_type: 'personal', reverted_from: 'business', reason: 'deep research found no business behind the address' });
6418
6482
  }
6419
6483
  }
6420
6484
 
6421
6485
  if (contact_doc.account_type === 'personal' || contact_doc?.business_has_person) {
6422
6486
  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);
6487
+ 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
6488
  if (!contact_doc.name) {
6424
6489
  contact_doc.name = await ai_ms.get_name_from_email_addr(uid, contact_doc.email, account_profile_info);
6490
+ await log_contact_activity(uid, contact_id, 'name_resolved', { by: 'ai', from: 'email address', name: contact_doc.name });
6425
6491
  }
6426
6492
  }
6427
6493
 
@@ -6456,6 +6522,8 @@ export const set_deep_research_contact = async function (req, job_id, headers) {
6456
6522
  throw err;
6457
6523
  }
6458
6524
  }
6525
+ if (conversation_items.docs.length) log_contact_activity(uid, contact_id, 'attachments_transcribed', { count: conversation_items.docs.length, trigger: 'deep research' });
6526
+ 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
6527
 
6460
6528
  return contact_save_ret;
6461
6529
  } catch (err) {
@@ -6475,6 +6543,7 @@ export const unset_deep_research_contact = async function (req, job_id, headers)
6475
6543
 
6476
6544
  const contact_save_ret = await save_contact(uid, contact_doc);
6477
6545
  await delete_xuda_cache(contact_doc);
6546
+ log_contact_activity(uid, contact_id, 'deep_research_turned_off', { by: 'user' });
6478
6547
 
6479
6548
  return contact_save_ret;
6480
6549
  } catch (err) {
@@ -6484,6 +6553,80 @@ export const unset_deep_research_contact = async function (req, job_id, headers)
6484
6553
  };
6485
6554
  }
6486
6555
  };
6556
+
6557
+ // The trail for one contact, newest first.
6558
+ //
6559
+ // Recorded rows are only half the answer: every contact that existed before the
6560
+ // trail did has none, and those are most of them. So the contact doc is read back
6561
+ // into the same shape first (derived: true). The doc keeps one timestamp per
6562
+ // FIELD, not per step, so a derived row is stamped with the closest timestamp it
6563
+ // honestly has (date_created for the creation and the classification that ran
6564
+ // inside it, profile_avatar_stat_ts for the picture, stat_ts for the archive) and
6565
+ // says derived so the UI can mark it as reconstructed rather than observed.
6566
+ export const get_contact_activity = async function (req) {
6567
+ const { uid, contact_id } = req;
6568
+ try {
6569
+ if (!contact_id) throw new Error('contact_id is missing');
6570
+ const account_profile_info = await get_active_account_profile_info(uid);
6571
+ const contact_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, contact_id);
6572
+ if (!contact_doc || contact_doc.docType !== 'contact') throw new Error(`contact ${contact_id} not found`);
6573
+ if (contact_doc.uid !== uid && contact_doc?.uid_created !== uid) throw new Error('Operation not allowed');
6574
+
6575
+ const recorded_ret = await db_module.find_app_couch_query(account_profile_info.app_id, {
6576
+ selector: { docType: 'contact_activity', contact_id },
6577
+ limit: 500,
6578
+ });
6579
+ const recorded = (recorded_ret?.docs || []).map((d) => ({ event: d.event, detail: d.detail || {}, ts: d.ts, derived: false }));
6580
+
6581
+ const derived = [];
6582
+ const add = (event, ts, detail) => {
6583
+ if (!ts) return;
6584
+ // A recorded row always wins: it has the real timestamp and the real inputs.
6585
+ if (recorded.some((r) => r.event === event)) return;
6586
+ derived.push({ event, detail, ts, derived: true });
6587
+ };
6588
+
6589
+ const created_ts = contact_doc.date_created || contact_doc.ts;
6590
+ 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 });
6591
+ if (contact_doc.is_spam) add('spam_check', created_ts, { is_spam: true });
6592
+ if (contact_doc.account_type) add('classified', created_ts, { account_type: contact_doc.account_type });
6593
+ 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 });
6594
+ 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 });
6595
+ 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 });
6596
+ if (contact_doc.contact_reference_conversation_id) add('ai_thread_opened', created_ts, { conversation_id: contact_doc.contact_reference_conversation_id });
6597
+ if (contact_doc.profile_picture) add('picture_found', contact_doc.profile_avatar_stat_ts || created_ts, { source: contact_doc.profile_picture_source });
6598
+ if (contact_doc.profile_avatar) add('avatar_ready', contact_doc.profile_avatar_stat_ts || created_ts, { source: contact_doc.avatar_source });
6599
+ 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 });
6600
+ if (contact_doc.deep_research) add('deep_research_started', contact_doc.stat_ts || created_ts, {});
6601
+ if (contact_doc.pinned) add('pinned', contact_doc.stat_ts || created_ts, {});
6602
+ // stat carries only the LAST transition, which is why an archive/unarchive
6603
+ // history is exactly what the recorded trail adds from here on.
6604
+ if (contact_doc.stat === 5) add('archived', contact_doc.stat_ts, { reason: contact_doc.stat_reason });
6605
+ if (contact_doc.stat === 4) add('deleted', contact_doc.stat_ts, { reason: contact_doc.stat_reason });
6606
+
6607
+ const rows = [...recorded, ...derived].sort((a, b) => (b.ts || 0) - (a.ts || 0));
6608
+
6609
+ return {
6610
+ code: 1,
6611
+ data: {
6612
+ contact_id,
6613
+ // What the card shows today, so the UI can head the trail with the outcome.
6614
+ current: {
6615
+ account_type: contact_doc.account_type || null,
6616
+ is_spam: !!contact_doc.is_spam,
6617
+ stat: contact_doc.stat,
6618
+ source: contact_doc.source || null,
6619
+ deep_research: !!contact_doc.deep_research,
6620
+ business_name: contact_doc?.business_info?.business_name || null,
6621
+ profile_avatar_stat: contact_doc.profile_avatar_stat,
6622
+ },
6623
+ rows,
6624
+ },
6625
+ };
6626
+ } catch (err) {
6627
+ return { code: -25, data: err.message };
6628
+ }
6629
+ };
6487
6630
  //////// PROFILES //////////////
6488
6631
 
6489
6632
  export const get_pending_share_profile_in = async function (uid, _id) {
@@ -7030,6 +7173,48 @@ export const get_contact = async function (uid, contact_id) {
7030
7173
  return contact_ret;
7031
7174
  };
7032
7175
 
7176
+ // ---------------------------------------------------------------------------
7177
+ // Contact activity trail
7178
+ //
7179
+ // A contact accumulates a lot of history that was previously only visible as
7180
+ // the END STATE on its own doc: it was created from somewhere, several AI steps
7181
+ // ran on it and each decided something (spam, business or personal, the business
7182
+ // profile, the person profile, the name, the avatar), and it may since have been
7183
+ // archived, unarchived, pinned, re-classified or sent to deep research. The doc
7184
+ // keeps the last answer of each and nothing about when, in what order, or what
7185
+ // the previous answer was, so "why is this contact a business" had no trace to
7186
+ // read back.
7187
+ //
7188
+ // One `contact_activity` doc per event, in the same app db as the contact.
7189
+ // Deliberately append-only and deliberately silent: a failure to WRITE the trail
7190
+ // must never fail the action being recorded, so every call is wrapped and logged
7191
+ // to the console instead of thrown. Callers do not await it for that reason
7192
+ // either, except where the next line already awaits something else anyway.
7193
+ // ---------------------------------------------------------------------------
7194
+ const log_contact_activity = async function (uid, contact_id, event, detail = {}, app_id) {
7195
+ try {
7196
+ if (!uid || !contact_id || !event) return null;
7197
+ const app = app_id || (await get_active_account_profile_info(uid))?.app_id;
7198
+ if (!app) return null;
7199
+
7200
+ return await db_module.save_app_couch_doc_native(app, {
7201
+ _id: await _common.xuda_get_uuid('contact_activity'),
7202
+ docType: 'contact_activity',
7203
+ contact_id,
7204
+ uid,
7205
+ event,
7206
+ // Values the UI prints back verbatim, so keep them short and human. Never
7207
+ // put a full AI response or an email body here: the trail is a summary.
7208
+ detail,
7209
+ ts: Date.now(),
7210
+ stat: 3,
7211
+ });
7212
+ } catch (err) {
7213
+ console.error('[account_module] contact activity not recorded:', event, contact_id, err?.message);
7214
+ return null;
7215
+ }
7216
+ };
7217
+
7033
7218
  export const save_contact = async function (uid, contact_doc) {
7034
7219
  const account_profile_info = await get_active_account_profile_info(uid);
7035
7220
  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.2310",
4
4
  "description": "Xuda Account Server Module",
5
5
  "main": "index.mjs",
6
6
  "dependencies": {