@xuda.io/account_module 1.2.2308 → 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
@@ -542,11 +542,18 @@ export const save_admin_presets = async function (req) {
542
542
  };
543
543
 
544
544
  // Effective entitlements = the membership plan's base quota PLUS the AI-workspace
545
- // add-on's credits + storage stacked on top. The Free membership tier does NOT
546
- // stack (it keeps its base only); every paid tier adds whatever ai_workspace plan
547
- // the account carries. String quotas ("unlimited", e.g. Enterprise T3) short-circuit
548
- // to Infinity so downstream numeric comparisons never cap. Single source of truth —
549
- // use for quota checks and any effective-quota display/API.
545
+ // add-on's credits + storage stacked on top, PLUS the Drive plan's own storage.
546
+ // The Free membership tier does NOT stack the workspace add-on (it keeps its base
547
+ // only); every paid tier adds whatever ai_workspace plan the account carries.
548
+ // String quotas ("unlimited", e.g. Enterprise T3) short-circuit to Infinity so
549
+ // downstream numeric comparisons never cap. Single source of truth, use it for
550
+ // quota checks and any effective-quota display or API.
551
+ //
552
+ // UI-113: the Drive plan (drive_* category) is a storage add-on the customer buys
553
+ // on its own, so unlike the workspace it stacks on EVERY membership tier including
554
+ // free. Withholding space that has its own line on the invoice would be selling
555
+ // nothing. The breakdown comes back with it, because a customer looking at a full
556
+ // bar needs to see which of the three plans to move to get more room.
550
557
  export const get_effective_entitlements = function (account_doc) {
551
558
  const membership = _conf.PLAN_OBJ?.[account_doc?.membership_plan];
552
559
  const mf = membership?.features || {};
@@ -554,15 +561,34 @@ export const get_effective_entitlements = function (account_doc) {
554
561
  const numOrInf = (v) => (typeof v === 'string' ? Infinity : Number(v) || 0);
555
562
  let ai_credits = numOrInf(mf.ai_credits);
556
563
  let drive_gb = numOrInf(mf.drive);
564
+ const membership_drive_gb = drive_gb;
565
+ let workspace_drive_gb = 0;
566
+ let plan_drive_gb = 0;
557
567
 
558
568
  if (account_doc?.membership_plan && account_doc.membership_plan !== 'free') {
559
569
  const ws = _conf.PLAN_OBJ?.[account_doc?.ai_workspace_plan];
560
570
  if (ws && ws.category === 'ai_workspace') {
561
571
  if (Number.isFinite(ai_credits)) ai_credits += Number(ws.ai_credits) || 0;
562
- if (Number.isFinite(drive_gb)) drive_gb += (Number(ws.size) || 0) / 1073741824;
572
+ workspace_drive_gb = (Number(ws.size) || 0) / 1073741824;
573
+ if (Number.isFinite(drive_gb)) drive_gb += workspace_drive_gb;
563
574
  }
564
575
  }
565
- return { ai_credits, drive_gb };
576
+
577
+ const drive_plan = _conf.PLAN_OBJ?.[account_doc?.drive_plan];
578
+ if (drive_plan && drive_plan.category === 'drive') {
579
+ plan_drive_gb = Number(drive_plan.flags?.extra_gb) || 0;
580
+ if (Number.isFinite(drive_gb)) drive_gb += plan_drive_gb;
581
+ }
582
+
583
+ return {
584
+ ai_credits,
585
+ drive_gb,
586
+ drive_breakdown: {
587
+ membership_gb: membership_drive_gb,
588
+ workspace_gb: workspace_drive_gb,
589
+ drive_plan_gb: plan_drive_gb,
590
+ },
591
+ };
566
592
  };
567
593
 
568
594
  // ===================================================================
@@ -588,19 +614,58 @@ const _MODULE_SUBSCRIPTIONS = [
588
614
  // switched on there as well as here. Both call tickets_set_plan, so the plan
589
615
  // is one line item on the consolidated subscription either way.
590
616
  { key: 'tickets', scope: 'account', field: 'tickets_plan', default_plan: 'tickets_free' },
617
+ // UI-104: email is its own product, billed per mailbox tier. Its plan lives on the
618
+ // account as an OBJECT (`email_plan.tier`), which is why it resolves rather than
619
+ // reading a field, and `set_email_plan` is what moves it.
620
+ {
621
+ key: 'email',
622
+ scope: 'account',
623
+ field: 'email_plan',
624
+ default_plan: 'email_free',
625
+ resolve: (a) => (a && a.email_plan && a.email_plan.tier ? `email_${a.email_plan.tier}` : ''),
626
+ },
591
627
  // Auto response is the single gate for whether the AI answers, on every channel
592
628
  // (chat, the public chat widget, email and the phone). It is account scoped and
593
629
  // billed once however many scenarios are written, so it is a plain line item
594
630
  // here rather than a per-resource charge like phone or email.
595
- { key: 'auto_response', scope: 'account', field: 'auto_response_plan', default_plan: 'auto_response_free' },
631
+ //
632
+ // UI-110: "nothing auto-responds before a plan is picked" is the module's own rule
633
+ // (auto_response_module assert_active), so PICKED is what active means here, Free
634
+ // included. Judging it on price alone would report an account that deliberately runs
635
+ // the free tier as switched off, and the menu gate would then hide the screen it uses.
636
+ {
637
+ key: 'auto_response',
638
+ scope: 'account',
639
+ field: 'auto_response_plan',
640
+ default_plan: 'auto_response_free',
641
+ is_active: (a) => !!a.auto_response_plan,
642
+ },
596
643
  // Four-tier ladder since 2026-08-06 (free / external / api / custom rules),
597
644
  // chosen on the Bot Protection Manager's Plans tab, which calls
598
645
  // bot_protection_set_plan. Free holds no line item at all.
599
646
  // No default_plan on purpose: Free is a tier the customer activates, so an
600
647
  // account that never chose one reads as not active rather than as Free.
601
- { key: 'bot_protection', scope: 'account', field: 'bot_protection_plan', default_plan: '' },
648
+ { key: 'bot_protection', scope: 'account', field: 'bot_protection_plan', default_plan: '', is_active: (a) => !!a.bot_protection_plan },
602
649
  { key: 'static_website', scope: 'resource' },
603
- { key: 'profile_phone', scope: 'resource' },
650
+ // Phone is the one module that is BOTH: an account-wide tier (set_voice_plan, held in
651
+ // communications.voice_plan) and a per-number charge, because the plan fee rides the
652
+ // oldest live number's own Stripe subscription rather than the consolidated one. So the
653
+ // row carries the tier for the switch AND one item per number for the billing card.
654
+ //
655
+ // billable is forced true for the same reason: the generic test looks for a Stripe
656
+ // price_id on a paid tier, and the phone ladder has none by design, its money is on the
657
+ // number. Left to the generic test the module would read "coming soon" while customers
658
+ // are being billed for numbers.
659
+ {
660
+ key: 'profile_phone',
661
+ scope: 'resource',
662
+ billable: true,
663
+ plan_of: (a) => `profile_phone_${a?.communications?.voice_plan?.tier || 'free'}`,
664
+ // get_voice_plans' own definition, kept identical on purpose: a number bought before
665
+ // UI-84 counts as active, so an existing customer is not asked to activate a plan
666
+ // they already pay for.
667
+ is_active: (a, items) => !!a?.communications?.voice_plan?.activated_ts || items.length > 0,
668
+ },
604
669
  // UI-96: the hosted resources. Not PLAN_OBJ categories, they are billed per resource
605
670
  // from `app_cost` on the app doc, but they are money on the same invoice, so the card
606
671
  // that answers "what am I paying for" has to show them.
@@ -614,9 +679,26 @@ const _MODULE_SUBSCRIPTIONS = [
614
679
  // so both carry their own `cycle` on each item rather than being assumed monthly.
615
680
  { key: 'domain', scope: 'resource' },
616
681
  { key: 'ipv4', scope: 'resource' },
617
- // Xuda Verify (verify_module) has no PLAN_OBJ category: every check is metered
618
- // per call into verify_meter and nothing is charged to the account today.
619
- { key: 'trust_center', scope: 'metered' },
682
+ // Trust Center (verify_module) is sold on the trust_center_* ladder: the tier
683
+ // buys a capability ceiling and an included volume of live verifications for
684
+ // the customer's OWN users, and traffic past the allowance still meters per
685
+ // check into verify_meter. Account scoped since 2026-08-06; it was 'metered'
686
+ // while the service had no ladder behind it. Xuda's own verification is never
687
+ // priced by this plan, deliberately: see the note in verify_module.
688
+ { key: 'trust_center', scope: 'account', field: 'trust_center_plan', default_plan: 'trust_center_free' },
689
+ // The Marketplace seller ladder: the tier buys live listings, the listing types the
690
+ // account may sell, and above all the cut Xuda keeps on each sale (20% down to 5%).
691
+ // is_active on the FIELD rather than on price, like auto response: Free is a tier a
692
+ // seller picks, and it is the tier that decides their commission, so an account that
693
+ // deliberately runs Free is switched on and must not read as off.
694
+ { key: 'marketplace', scope: 'account', field: 'marketplace_plan', default_plan: 'marketplace_free', is_active: (a) => !!a.marketplace_plan },
695
+ // UI-113: Drive is sold on its own drive_* ladder, storage only. The tier buys space
696
+ // STACKED on top of what the membership and the AI workspace plan already include
697
+ // (get_effective_entitlements), which is why the free tier is 0 extra GB rather than
698
+ // a number: an account that never touched this ladder keeps exactly the space it has
699
+ // today and owes nothing new. Judged on price, so Free reads as not active, because
700
+ // here that is the truth: Free adds nothing and holds no line on the invoice.
701
+ { key: 'drive', scope: 'account', field: 'drive_plan', default_plan: 'drive_free' },
620
702
  ];
621
703
 
622
704
  // app_type -> the row it belongs under. An app type that is not here is not a
@@ -785,30 +867,42 @@ export const get_module_subscriptions = async function (req = {}) {
785
867
  const account = ar.data;
786
868
  const plans = _conf.PLAN_OBJ || {};
787
869
  // A category is only offerable once its paid tiers carry a real Stripe
788
- // price, otherwise "activate" would set a plan nobody is billed for.
789
- const billable = (key) =>
790
- Object.values(plans).some((p) => p.category === key && Number(p.price) > 0 && p.price_id && !/TODO/.test(p.price_id));
870
+ // price, otherwise "activate" would set a plan nobody is billed for. A module
871
+ // whose money is NOT a plan line item says so itself (phone), because for those
872
+ // an absent price_id is the design, not a gap.
873
+ const billable = (m) =>
874
+ m.billable === true ||
875
+ Object.values(plans).some((p) => p.category === m.key && Number(p.price) > 0 && p.price_id && !/TODO/.test(p.price_id));
791
876
 
792
877
  const resource_items = await _module_resource_items(uid);
793
878
 
794
879
  const modules = _MODULE_SUBSCRIPTIONS.map((m) => {
795
- const base = { key: m.key, scope: m.scope, billable: billable(m.key), items: resource_items[m.key] || [] };
880
+ const base = { key: m.key, scope: m.scope, billable: billable(m), items: resource_items[m.key] || [] };
796
881
  if (m.scope === 'resource' || m.scope === 'metered') {
797
882
  const price = base.items.reduce((sum, i) => sum + Number(i.price || 0), 0);
798
883
  // UI-97: a row is only yearly when everything in it is (domains). Anything
799
884
  // else stays monthly, so a total is never a sum of two different cycles.
800
885
  const cycle = base.items.length && base.items.every((i) => i.cycle === 'yr') ? 'yr' : 'mo';
886
+ // UI-110: a resource row may still sit under an account-wide tier (phone), and
887
+ // the switch needs that tier, not the sum of the numbers. Everything without a
888
+ // `plan_of` keeps reporting no plan, exactly as before.
889
+ const plan_id = m.plan_of ? m.plan_of(account) : '';
890
+ const plan = plans[plan_id] || {};
801
891
  return {
802
892
  ...base,
803
- active: m.scope === 'metered' || base.items.length > 0,
893
+ active: m.is_active ? !!m.is_active(account, base.items) : m.scope === 'metered' || base.items.length > 0,
804
894
  included: m.scope === 'metered',
805
- plan_id: '',
806
- plan_name: '',
895
+ plan_id,
896
+ plan_name: plan.name || '',
897
+ plan_price: Number(plan.price) || 0,
807
898
  price,
808
899
  cycle,
809
900
  };
810
901
  }
811
- const plan_id = account[m.field] || m.default_plan;
902
+ // UI-104: most modules keep their plan id directly on the account, but email
903
+ // keeps `{ tier, activated_ts }`, so a module may bring its own resolver rather
904
+ // than forcing every field into the same shape.
905
+ const plan_id = (m.resolve ? m.resolve(account) : account[m.field]) || m.default_plan;
812
906
  const plan = plans[plan_id] || {};
813
907
  // UI-103: a switch-off is a DOWNGRADE, so Stripe schedules it for the end of the
814
908
  // period the customer already paid for and the plan field does not move until the
@@ -824,7 +918,10 @@ export const get_module_subscriptions = async function (req = {}) {
824
918
  plan_id,
825
919
  plan_name: plan.name || plan_id,
826
920
  price,
827
- active: price > 0,
921
+ // UI-110: paying is the default proof that a module is on, but a module that
922
+ // records the customer CHOOSING a tier says so itself, because a deliberate
923
+ // free tier is switched on and a price of zero cannot tell the two apart.
924
+ active: m.is_active ? !!m.is_active(account, base.items) : price > 0,
828
925
  included: false,
829
926
  pending,
830
927
  changed_ts: account[`${m.field}_changed`] || null,
@@ -1783,7 +1880,31 @@ export const get_account_data = async function (req) {
1783
1880
  builds_drive_size: acc_obj.builds_drive_size || 0,
1784
1881
  project_data_size: acc_obj.project_data_size || 0,
1785
1882
  total_drive_size: acc_obj.total_drive_size || 0,
1883
+ // UI-113: the QUOTA travels with the usage now. Every surface that drew a
1884
+ // storage bar (the sidenav popover, UsageBar, the Drive plans screen) was
1885
+ // deriving the ceiling from the ai_workspace plan's `size` alone, which
1886
+ // ignored the membership base and would have ignored the Drive plan too, so
1887
+ // a bar could read full while the account had room. This is the same
1888
+ // get_effective_entitlements the overage metering charges on, in bytes.
1889
+ ...(() => {
1890
+ const ent = get_effective_entitlements(acc_obj);
1891
+ const gb = 1073741824;
1892
+ const b = ent.drive_breakdown || {};
1893
+ return {
1894
+ quota_bytes: Number.isFinite(ent.drive_gb) ? Math.round(ent.drive_gb * gb) : 0,
1895
+ quota_unlimited: !Number.isFinite(ent.drive_gb),
1896
+ quota_from: {
1897
+ membership_bytes: Math.round((b.membership_gb === Infinity ? 0 : b.membership_gb || 0) * gb),
1898
+ workspace_bytes: Math.round((b.workspace_gb || 0) * gb),
1899
+ drive_plan_bytes: Math.round((b.drive_plan_gb || 0) * gb),
1900
+ },
1901
+ };
1902
+ })(),
1786
1903
  },
1904
+ // UI-113: the Drive storage ladder the account sits on, for the plans screen
1905
+ // and the Modules card. Absent on an account that never picked one, which the
1906
+ // registry reads as drive_free.
1907
+ drive_plan: acc_obj.drive_plan || 'drive_free',
1787
1908
 
1788
1909
  stripe_connect_account_id: acc_obj?.stripe_connect_account_obj?.id,
1789
1910
  stripe_connect_account_status: acc_obj?.stripe_connect_account_status,
@@ -4126,6 +4247,7 @@ export const merge_contact = async function (req) {
4126
4247
  }
4127
4248
 
4128
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' });
4129
4251
 
4130
4252
  for (let contact_id of contact_to_delete) {
4131
4253
  try {
@@ -4134,6 +4256,7 @@ export const merge_contact = async function (req) {
4134
4256
  contact_doc.stat_ts = Date.now();
4135
4257
  contact_doc.stat_reason = 'merge';
4136
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' });
4137
4260
  } catch (error) {}
4138
4261
  }
4139
4262
 
@@ -4632,7 +4755,9 @@ export const update_contact = async function (req) {
4632
4755
  doc.profile_avatar = account_obj.account_info.profile_avatar;
4633
4756
  }
4634
4757
 
4635
- 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;
4636
4761
  }
4637
4762
  return { code: -1, data: 'contact not found' };
4638
4763
  };
@@ -5542,6 +5667,16 @@ export const add_contact = async function (req, job_id, headers) {
5542
5667
 
5543
5668
  /////////////////
5544
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
+
5545
5680
  let is_spam = false;
5546
5681
  // let email_type_info;
5547
5682
  // let account_type_info;
@@ -5558,27 +5693,42 @@ export const add_contact = async function (req, job_id, headers) {
5558
5693
  }
5559
5694
 
5560
5695
  set_account_profile_picture(uid, contact_uid, metadata, job_id, headers, account_profile_info);
5696
+ await activity('linked_account', { contact_uid });
5561
5697
  } else {
5562
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' });
5563
5700
 
5564
5701
  // contact without use account
5565
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
+ }
5566
5709
 
5567
5710
  if (_.isEmpty(cached_contact)) {
5568
5711
  if (!is_spam && metadata.subject && !metadata.is_sent && !metadata.is_answered && !metadata.not_junk) {
5569
5712
  // deep search for spam
5570
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 });
5571
5715
  }
5572
5716
  if (!is_spam) {
5573
5717
  const is_business = await ai_ms.is_business_contact(uid, email, name, metadata.subject, metadata.summarized_body, account_profile_info);
5574
5718
  account_type = is_business ? 'business' : 'personal';
5719
+ await activity('classified', { by: 'ai', account_type, inputs: { email: email.toLowerCase(), name: name || '', subject: metadata.subject || '' } });
5575
5720
  if (is_business) {
5576
5721
  business_info = await ai_ms.get_business_info(uid, '', email, account_profile_info, { light: true });
5577
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 });
5578
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 });
5579
5726
  } else {
5580
5727
  account_type = 'personal';
5581
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' });
5582
5732
  }
5583
5733
  }
5584
5734
  if (_.isEmpty(cached_contact) && account_type === 'business' && !business_has_person) {
@@ -5587,6 +5737,9 @@ export const add_contact = async function (req, job_id, headers) {
5587
5737
  } else {
5588
5738
  cached_contact = await get_xuda_cache(uid, 'contact', business_info.business_name, 'metadata');
5589
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
+ }
5590
5743
  }
5591
5744
  }
5592
5745
  }
@@ -5594,7 +5747,7 @@ export const add_contact = async function (req, job_id, headers) {
5594
5747
 
5595
5748
  const d = Date.now();
5596
5749
  const contact_obj = {
5597
- _id: await _common.xuda_get_uuid('contact'),
5750
+ _id: contact_id,
5598
5751
  email: email.toLowerCase(),
5599
5752
  name: cached_contact?.name || name,
5600
5753
  stat: stat || 3,
@@ -5621,13 +5774,16 @@ export const add_contact = async function (req, job_id, headers) {
5621
5774
  if (!is_spam) {
5622
5775
  if (account_type === 'personal' || contact_obj?.business_has_person) {
5623
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 });
5624
5778
  if (!contact_obj.name) {
5625
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 });
5626
5781
  }
5627
5782
  }
5628
5783
 
5629
5784
  const conversation_obj = await ai_ms.create_openai_conversation();
5630
5785
  contact_obj.contact_reference_conversation_id = conversation_obj.id;
5786
+ await activity('ai_thread_opened', { conversation_id: conversation_obj.id });
5631
5787
  } else {
5632
5788
  if (!contact_obj.name) {
5633
5789
  contact_obj.name = '';
@@ -5637,6 +5793,10 @@ export const add_contact = async function (req, job_id, headers) {
5637
5793
  const save_ret = await save_contact(uid, contact_obj);
5638
5794
  save_xuda_cache(uid, 'contact', contact_obj.email, null, contact_obj);
5639
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
+
5640
5800
  if (!contact_obj.is_spam && contact_obj.name) {
5641
5801
  set_contact_profile_picture(uid, contact_obj._id, metadata, job_id, headers, account_profile_info, false);
5642
5802
  }
@@ -5700,6 +5860,7 @@ const set_contact_profile_picture = async function (uid, contact_id, metadata, j
5700
5860
  contact_obj.profile_picture_source = file_ret.profile_picture_source;
5701
5861
 
5702
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' });
5703
5864
  // profile_picture = contact_obj.profile_picture;
5704
5865
  }
5705
5866
  }
@@ -5784,10 +5945,14 @@ const set_contact_profile_picture = async function (uid, contact_id, metadata, j
5784
5945
  contact_obj.profile_avatar = contact_obj?.profile_avatar_obj?.file_url;
5785
5946
  contact_obj.avatar_source = file_ret?.data?.avatar_source;
5786
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' });
5787
5949
  }
5788
5950
  await update_contact_profile_picture_status(uid, contact_id, 3);
5789
5951
  } catch (err) {
5790
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 });
5791
5956
  delete_xuda_cache(contact_obj);
5792
5957
  }
5793
5958
  };
@@ -6020,6 +6185,7 @@ export const archive_contact = async function (req) {
6020
6185
  contact_doc.stat_reason = 'archived by the user';
6021
6186
 
6022
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 });
6023
6189
 
6024
6190
  return contact_save_ret;
6025
6191
  } catch (err) {
@@ -6042,6 +6208,7 @@ export const delete_contact = async function (req, job_id, headers) {
6042
6208
  contact_doc.stat_reason = 'deleted by the user';
6043
6209
 
6044
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' });
6045
6212
 
6046
6213
  ai_msa.delete_depended_chats(uid, contact_id);
6047
6214
  return contact_save_ret;
@@ -6067,6 +6234,7 @@ export const unarchive_contact = async function (req, job_id, headers) {
6067
6234
  contact_doc.stat_reason = 'unarchive by the user';
6068
6235
 
6069
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 });
6070
6238
 
6071
6239
  if (!contact_doc.is_spam) {
6072
6240
  not_spam_contact(req, job_id, headers);
@@ -6096,6 +6264,7 @@ export const unfriend_contact = async function (req) {
6096
6264
 
6097
6265
  contact_doc.team_req_id = null;
6098
6266
  const contact_save_ret = await save_contact(uid, contact_doc);
6267
+ log_contact_activity(uid, contact_id, 'unfriended', { by: 'user' });
6099
6268
 
6100
6269
  const req_save_ret = await db_module.save_couch_doc('xuda_team', req_doc);
6101
6270
 
@@ -6115,6 +6284,7 @@ export const pin_contact = async function (req) {
6115
6284
  contact_doc.pinned = true;
6116
6285
 
6117
6286
  const contact_save_ret = await save_contact(uid, contact_doc);
6287
+ log_contact_activity(uid, contact_id, 'pinned', { by: 'user' });
6118
6288
 
6119
6289
  ws_dashboard_msa.emit_message_to_dashboard({
6120
6290
  service: 'contact_pinned',
@@ -6159,6 +6329,7 @@ export const unpin_contact = async function (req) {
6159
6329
  contact_doc.pinned = false;
6160
6330
 
6161
6331
  const contact_save_ret = await save_contact(uid, contact_doc);
6332
+ log_contact_activity(uid, contact_id, 'unpinned', { by: 'user' });
6162
6333
 
6163
6334
  ws_dashboard_msa.emit_message_to_dashboard({
6164
6335
  service: 'contact_unpinned',
@@ -6190,22 +6361,30 @@ export const not_spam_contact = async function (req, job_id, headers) {
6190
6361
  stat: 3,
6191
6362
  });
6192
6363
  }
6364
+ const was_spam = !!contact_doc.is_spam;
6193
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' });
6194
6367
 
6195
6368
  if (!contact_doc.contact_reference_conversation_id) {
6196
6369
  const conversation_obj = await ai_ms.create_openai_conversation();
6197
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 });
6198
6372
  }
6199
6373
 
6374
+ const previous_account_type = contact_doc.account_type;
6200
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);
6201
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' });
6202
6378
  if (is_business) {
6203
6379
  contact_doc.business_info = await ai_ms.get_business_info(uid, '', contact_doc.email, account_profile_info, { light: true });
6204
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 });
6205
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 });
6206
6384
  } else {
6207
6385
  contact_doc.account_type = 'personal';
6208
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' });
6209
6388
  }
6210
6389
  }
6211
6390
 
@@ -6241,6 +6420,7 @@ export const not_spam_contact = async function (req, job_id, headers) {
6241
6420
  req.email_id = email._id;
6242
6421
  await email_ms.process_pending_email(req, job_id, headers);
6243
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' });
6244
6424
 
6245
6425
  return contact_save_ret;
6246
6426
  } catch (err) {
@@ -6269,6 +6449,7 @@ export const generate_contact_avatar = async function (req, job_id, headers) {
6269
6449
 
6270
6450
  const contact_save_ret = await save_contact(uid, contact_doc);
6271
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' });
6272
6453
  set_contact_profile_picture(uid, contact_doc._id, {}, job_id, headers, account_profile_info, true);
6273
6454
 
6274
6455
  return contact_save_ret;
@@ -6286,21 +6467,27 @@ export const set_deep_research_contact = async function (req, job_id, headers) {
6286
6467
  try {
6287
6468
  var contact_doc = await get_contact(uid, contact_id);
6288
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' });
6289
6471
 
6290
6472
  if (contact_doc.account_type === 'business') {
6291
6473
  contact_doc.business_info = await ai_ms.get_business_info(uid, '', contact_doc.email, account_profile_info);
6292
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 });
6293
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 });
6294
6478
  } else {
6295
6479
  contact_doc.account_type = 'personal';
6296
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' });
6297
6482
  }
6298
6483
  }
6299
6484
 
6300
6485
  if (contact_doc.account_type === 'personal' || contact_doc?.business_has_person) {
6301
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 });
6302
6488
  if (!contact_doc.name) {
6303
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 });
6304
6491
  }
6305
6492
  }
6306
6493
 
@@ -6335,6 +6522,8 @@ export const set_deep_research_contact = async function (req, job_id, headers) {
6335
6522
  throw err;
6336
6523
  }
6337
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 });
6338
6527
 
6339
6528
  return contact_save_ret;
6340
6529
  } catch (err) {
@@ -6354,6 +6543,7 @@ export const unset_deep_research_contact = async function (req, job_id, headers)
6354
6543
 
6355
6544
  const contact_save_ret = await save_contact(uid, contact_doc);
6356
6545
  await delete_xuda_cache(contact_doc);
6546
+ log_contact_activity(uid, contact_id, 'deep_research_turned_off', { by: 'user' });
6357
6547
 
6358
6548
  return contact_save_ret;
6359
6549
  } catch (err) {
@@ -6363,6 +6553,80 @@ export const unset_deep_research_contact = async function (req, job_id, headers)
6363
6553
  };
6364
6554
  }
6365
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
+ };
6366
6630
  //////// PROFILES //////////////
6367
6631
 
6368
6632
  export const get_pending_share_profile_in = async function (uid, _id) {
@@ -6909,6 +7173,48 @@ export const get_contact = async function (uid, contact_id) {
6909
7173
  return contact_ret;
6910
7174
  };
6911
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
+
6912
7218
  export const save_contact = async function (uid, contact_doc) {
6913
7219
  const account_profile_info = await get_active_account_profile_info(uid);
6914
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.2308",
3
+ "version": "1.2.2310",
4
4
  "description": "Xuda Account Server Module",
5
5
  "main": "index.mjs",
6
6
  "dependencies": {