@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 +330 -24
- package/index_ms.mjs +4 -0
- package/index_msa.mjs +4 -0
- package/package.json +1 -1
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
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
//
|
|
549
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
618
|
-
//
|
|
619
|
-
|
|
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
|
-
|
|
790
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
};
|