@xuda.io/account_module 1.2.2310 → 1.2.2312

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.mjs CHANGED
@@ -3535,6 +3535,7 @@ export const get_account_name = async function (req) {
3535
3535
  active_account_profile_id,
3536
3536
  network_country_code,
3537
3537
  public_profile_disabled,
3538
+ verify_level,
3538
3539
  } = data.data.account_info;
3539
3540
 
3540
3541
  obj = {
@@ -3570,6 +3571,11 @@ export const get_account_name = async function (req) {
3570
3571
  // email,// email?.[0] || '',
3571
3572
  avatar_source,
3572
3573
  active_account_profile_id,
3574
+ // The verification level of the account behind a card. Every caller that
3575
+ // builds a contact card off another account copies this onto the card doc
3576
+ // so the background tile can say what the level is; without it a card for
3577
+ // a verified person read as L0, which is a false statement about them.
3578
+ verify_level,
3573
3579
  };
3574
3580
  }
3575
3581
  return { code: 1, data: obj }; //is_online: data?.data?.socket_id ? true : false
@@ -4305,6 +4311,13 @@ const level_pattern = function (doc) {
4305
4311
  return `level-${level}-pattern.png`;
4306
4312
  };
4307
4313
 
4314
+ // The level tile is the DEFAULT for every contact card, not a fallback. It used
4315
+ // to be computed first and then overwritten by a tile keyed off where the
4316
+ // contact came from (xu / authentic / fictional / email / web), so a card for a
4317
+ // verified person showed the generic XU tile and the level never appeared on any
4318
+ // card that had a contact_uid, which is every card for a real account. Only the
4319
+ // states that say the card cannot show a level at all still win: a shared card
4320
+ // wears its owner's avatar, an avatar being processed says so, and spam says so.
4308
4321
  const get_contact_pattern = async function (doc) {
4309
4322
  let ret = level_pattern(doc);
4310
4323
 
@@ -4325,84 +4338,12 @@ const get_contact_pattern = async function (doc) {
4325
4338
  }
4326
4339
  } else if (doc.is_spam) {
4327
4340
  ret = `spam-pattern.png`;
4328
- } else {
4329
- // if (doc.account_type === 'business') {
4330
- // // if (doc?.is_real_person) {
4331
- // ret = doc.profile_picture;
4332
- // // }
4333
- // } else {
4334
- // if (doc.my_contact) {
4335
- // if (doc.account_type === 'business') {
4336
- // ret = doc.profile_picture;
4337
- // } else {
4338
- // switch (doc.avatar_source) {
4339
- // case 'fictional':
4340
- // ret = `fictional-avatar-pattern.png`;
4341
- // break;
4342
-
4343
- // case 'ai profile':
4344
- // ret = `fictional-avatar-pattern.png`;
4345
- // break;
4346
-
4347
- // case 'authentic profile':
4348
- // ret = `authentic-avatar-pattern.png`;
4349
- // break;
4350
-
4351
- // default:
4352
- // ret = `xu-pattern.png`;
4353
- // break;
4354
- // }
4355
- // }
4356
- // } else
4357
-
4358
- // if (doc?.shared_from_uid) {
4359
- // //&& (doc.pending || (doc.team_req_id && doc.contact_share_copy))) {
4360
- // // pending or active share request
4361
- // const shared_from_uid_ret = await get_account_name({ uid_query: doc.shared_from_uid });
4362
- // ret = shared_from_uid_ret.data.profile_avatar;
4363
- // }
4364
-
4365
- // else if (doc?.reference_doc?.shared_from_uid) {
4366
- // //active share
4367
- // const shared_from_uid_ret = await get_account_name({ uid_query: doc.reference_doc.shared_from_uid });
4368
- // ret = shared_from_uid_ret.data.profile_avatar;
4369
- // }
4370
- // else
4371
- if (doc.contact_uid) {
4372
- switch (doc.avatar_source) {
4373
- case 'fictional':
4374
- ret = `fictional-avatar-pattern.png`;
4375
- break;
4376
-
4377
- case 'ai profile':
4378
- ret = `fictional-avatar-pattern.png`;
4379
- break;
4380
-
4381
- case 'authentic profile':
4382
- ret = `authentic-avatar-pattern.png`;
4383
- break;
4384
-
4385
- default:
4386
- ret = `xu-pattern.png`;
4387
- break;
4388
- }
4389
- } else {
4390
- switch (doc.source) {
4391
- case 'read emails': {
4392
- ret = `email-pattern.png`;
4393
- break;
4394
- }
4395
- case 'web': {
4396
- ret = `web-pattern.png`;
4397
- break;
4398
- }
4399
-
4400
- default:
4401
- break;
4402
- }
4403
- }
4404
- // }
4405
4341
  }
4342
+ // Everything else keeps the level tile. The old where-it-came-from tiles
4343
+ // (xu / authentic / fictional for an account, email / web for a scraped
4344
+ // contact) are gone on purpose: they occupied the one surface that now
4345
+ // carries the verification level, and a contact with no account behind it
4346
+ // has no level to show, which is exactly what L0 says.
4406
4347
  return ret;
4407
4348
  };
4408
4349
 
@@ -4570,6 +4511,10 @@ export const get_contact_info = async function (uid, contact_doc, _id) {
4570
4511
  }
4571
4512
 
4572
4513
  doc.avatar_source = account_info_ret.data.avatar_source;
4514
+ // The card's background tile is the contact's verification level, and the
4515
+ // level lives on the account, not on my copy of them. Carry it over here,
4516
+ // where the account is already in hand, so the tile costs no extra read.
4517
+ doc.verify_level = account_info_ret.data.verify_level;
4573
4518
  // doc.icon_pattern = await get_contact_pattern(doc);
4574
4519
  doc.username = account_info_ret.data.username;
4575
4520
  doc.business_name = account_info_ret.data.business_name;
@@ -4634,6 +4579,7 @@ export const get_pending_contact_out = async function (uid, _id) {
4634
4579
  doc.account_type = account_info_ret.data.account_type;
4635
4580
  doc.avatar_source = account_info_ret.data.avatar_source;
4636
4581
  doc.profile_avatar = account_info_ret.data.profile_avatar;
4582
+ doc.verify_level = account_info_ret.data.verify_level;
4637
4583
  doc.name = `${account_info_ret.data.first_name} ${account_info_ret.data.last_name}`;
4638
4584
  }
4639
4585
  doc.icon_pattern = await get_contact_pattern(doc);
@@ -4665,6 +4611,7 @@ export const get_pending_contact_in = async function (uid, _id) {
4665
4611
  doc.account_type = account_info_ret.data.account_type;
4666
4612
  doc.avatar_source = account_info_ret.data.avatar_source;
4667
4613
  doc.profile_avatar = account_info_ret.data.profile_avatar;
4614
+ doc.verify_level = account_info_ret.data.verify_level;
4668
4615
  doc.name = `${account_info_ret.data.first_name} ${account_info_ret.data.last_name}`;
4669
4616
  }
4670
4617
  doc.card_background = get_contact_background({ pending: true });
@@ -4704,6 +4651,7 @@ const get_user_contact = async function (uid, uid_query) {
4704
4651
  doc.profile_avatar = account_info.profile_avatar;
4705
4652
  doc.profile_picture = account_info.profile_picture;
4706
4653
  doc.avatar_source = account_info.avatar_source;
4654
+ doc.verify_level = account_info.verify_level;
4707
4655
  doc.card_background = get_contact_background(doc);
4708
4656
  doc.icon_pattern = await get_contact_pattern(doc);
4709
4657
  doc.border = get_contact_border(doc);
@@ -5618,28 +5566,18 @@ setTimeout(async () => {
5618
5566
 
5619
5567
  //////// CONTACTS //////////////
5620
5568
 
5621
- function identifyPersonalProvider(domain) {
5622
- const providers = {
5623
- 'gmail.com': 'Google Gmail',
5624
- 'googlemail.com': 'Google Gmail',
5625
- 'outlook.com': 'Microsoft Outlook',
5626
- 'hotmail.com': 'Microsoft Hotmail',
5627
- 'live.com': 'Microsoft Live',
5628
- 'yahoo.com': 'Yahoo Mail',
5629
- 'ymail.com': 'Yahoo Mail',
5630
- 'aol.com': 'AOL Mail',
5631
- 'icloud.com': 'Apple iCloud',
5632
- 'me.com': 'Apple iCloud',
5633
- 'mac.com': 'Apple iCloud',
5634
- 'protonmail.com': 'ProtonMail',
5635
- 'proton.me': 'ProtonMail',
5636
- 'zoho.com': 'Zoho Mail',
5637
- 'yandex.com': 'Yandex Mail',
5638
- 'mail.com': 'Mail.com',
5639
- 'gmx.com': 'GMX Mail',
5640
- };
5569
+ // A "business" that resolves to the address's own mail provider is not the contact's
5570
+ // employer, it is Gmail. get_business_info, handed a bare gmail.com address, answers
5571
+ // "Gmail / gmail.com / Technology" rather than "Not available", so the only escape hatch the
5572
+ // classification had (business_name === 'Not available') never fired and every personal
5573
+ // Gmail contact came out as a business, storefront placeholder and all. This is the second
5574
+ // escape hatch, built on the provider list that already exists directly below.
5575
+ const businessIsJustTheMailProvider = (business_info) => !!identifyPersonalProvider(business_info?.business_domain || '');
5641
5576
 
5642
- return providers[domain.toLowerCase()];
5577
+ function identifyPersonalProvider(domain) {
5578
+ // The list moved to common so ai_module's contact classifier reads the same one. It used to
5579
+ // live here alone, which is exactly why the classifier could call gmail.com a business.
5580
+ return _common.personal_email_provider(domain);
5643
5581
  }
5644
5582
 
5645
5583
  export const add_contact = async function (req, job_id, headers) {
@@ -5714,21 +5652,24 @@ export const add_contact = async function (req, job_id, headers) {
5714
5652
  await activity('spam_check', { by: 'ai', is_spam: !!is_spam, subject: metadata.subject });
5715
5653
  }
5716
5654
  if (!is_spam) {
5717
- const is_business = await ai_ms.is_business_contact(uid, email, name, metadata.subject, metadata.summarized_body, account_profile_info);
5655
+ const verdict = await ai_ms.is_business_contact(uid, email, name, metadata.subject, metadata.summarized_body, account_profile_info);
5656
+ // Tolerates the old bare boolean, in case a stale ai_module answers this call.
5657
+ const is_business = typeof verdict === 'object' && verdict !== null ? verdict.is_business : !!verdict;
5718
5658
  account_type = is_business ? 'business' : 'personal';
5719
- await activity('classified', { by: 'ai', account_type, inputs: { email: email.toLowerCase(), name: name || '', subject: metadata.subject || '' } });
5659
+ await activity('classified', { by: 'ai', account_type, confidence: verdict?.confidence, why: verdict?.reason, inputs: { email: email.toLowerCase(), name: name || '', subject: metadata.subject || '' } });
5720
5660
  if (is_business) {
5721
5661
  business_info = await ai_ms.get_business_info(uid, '', email, account_profile_info, { light: true });
5722
- if (business_info.business_name !== 'Not available') {
5662
+ if (business_info.business_name !== 'Not available' && !businessIsJustTheMailProvider(business_info)) {
5723
5663
  await activity('business_info', { by: 'ai', depth: 'light', business_name: business_info.business_name, business_domain: business_info.business_domain, business_category: business_info.business_category });
5724
5664
  business_has_person = await ai_ms.is_business_contact_has_person(uid, email, name, metadata.subject, metadata.summarized_body, account_profile_info);
5725
5665
  await activity('business_has_person', { by: 'ai', has_person: !!business_has_person });
5726
5666
  } else {
5667
+ const provider = identifyPersonalProvider(business_info?.business_domain || '');
5727
5668
  account_type = 'personal';
5728
5669
  business_info = undefined;
5729
5670
  // The reversal matters more than the original verdict: the card says
5730
5671
  // 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' });
5672
+ await activity('classified', { by: 'ai', account_type, reverted_from: 'business', reason: provider ? `the only business behind the address is its mail provider (${provider})` : 'no business could be identified behind the address' });
5732
5673
  }
5733
5674
  }
5734
5675
  if (_.isEmpty(cached_contact) && account_type === 'business' && !business_has_person) {
@@ -6372,19 +6313,31 @@ export const not_spam_contact = async function (req, job_id, headers) {
6372
6313
  }
6373
6314
 
6374
6315
  const previous_account_type = contact_doc.account_type;
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);
6316
+ const verdict = await ai_ms.is_business_contact(uid, contact_doc.email, contact_doc.name, contact_doc?.metadata?.subject, contact_doc?.metadata?.summarized_body, account_profile_info);
6317
+ const is_business = typeof verdict === 'object' && verdict !== null ? verdict.is_business : !!verdict;
6376
6318
  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' });
6319
+ await log_contact_activity(uid, contact_id, 'classified', { by: 'ai', trigger: 'not spam', account_type: contact_doc.account_type, confidence: verdict?.confidence, why: verdict?.reason, previous_account_type: previous_account_type || 'unset' });
6320
+ if (!is_business && (contact_doc.business_info || contact_doc.business_has_person)) {
6321
+ // A contact that reclassifies from business to person used to keep the company profile
6322
+ // the old verdict attached to it, because business_info is only ever written inside the
6323
+ // is_business branch and nothing clears it on the way out. The card then showed a person
6324
+ // with a company still on file.
6325
+ const dropped = contact_doc?.business_info?.business_name;
6326
+ contact_doc.business_info = undefined;
6327
+ contact_doc.business_has_person = undefined;
6328
+ await log_contact_activity(uid, contact_id, 'business_info_cleared', { reason: 'contact reclassified as a person', dropped });
6329
+ }
6378
6330
  if (is_business) {
6379
6331
  contact_doc.business_info = await ai_ms.get_business_info(uid, '', contact_doc.email, account_profile_info, { light: true });
6380
- if (contact_doc.business_info.business_name !== 'Not available') {
6332
+ if (contact_doc.business_info.business_name !== 'Not available' && !businessIsJustTheMailProvider(contact_doc.business_info)) {
6381
6333
  await log_contact_activity(uid, contact_id, 'business_info', { by: 'ai', depth: 'light', business_name: contact_doc.business_info.business_name, business_domain: contact_doc.business_info.business_domain, business_category: contact_doc.business_info.business_category });
6382
6334
  contact_doc.business_has_person = await ai_ms.is_business_contact_has_person(uid, contact_doc.email, contact_doc.name, contact_doc?.metadata?.subject, contact_doc?.metadata?.summarized_body, account_profile_info);
6383
6335
  await log_contact_activity(uid, contact_id, 'business_has_person', { by: 'ai', has_person: !!contact_doc.business_has_person });
6384
6336
  } else {
6337
+ const provider = identifyPersonalProvider(contact_doc.business_info?.business_domain || '');
6385
6338
  contact_doc.account_type = 'personal';
6386
6339
  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' });
6340
+ await log_contact_activity(uid, contact_id, 'classified', { by: 'ai', account_type: 'personal', reverted_from: 'business', reason: provider ? `the only business behind the address is its mail provider (${provider})` : 'no business could be identified behind the address' });
6388
6341
  }
6389
6342
  }
6390
6343
 
@@ -6471,14 +6424,15 @@ export const set_deep_research_contact = async function (req, job_id, headers) {
6471
6424
 
6472
6425
  if (contact_doc.account_type === 'business') {
6473
6426
  contact_doc.business_info = await ai_ms.get_business_info(uid, '', contact_doc.email, account_profile_info);
6474
- if (contact_doc.business_info.business_name !== 'Not available') {
6427
+ if (contact_doc.business_info.business_name !== 'Not available' && !businessIsJustTheMailProvider(contact_doc.business_info)) {
6475
6428
  await log_contact_activity(uid, contact_id, 'business_info', { by: 'ai', depth: 'deep', business_name: contact_doc.business_info.business_name, business_domain: contact_doc.business_info.business_domain, business_category: contact_doc.business_info.business_category });
6476
6429
  contact_doc.business_has_person = await ai_ms.is_business_contact_has_person(uid, contact_doc.email, contact_doc.name, contact_doc?.metadata?.subject, contact_doc?.metadata?.summarized_body, account_profile_info);
6477
6430
  await log_contact_activity(uid, contact_id, 'business_has_person', { by: 'ai', has_person: !!contact_doc.business_has_person });
6478
6431
  } else {
6432
+ const provider = identifyPersonalProvider(contact_doc.business_info?.business_domain || '');
6479
6433
  contact_doc.account_type = 'personal';
6480
6434
  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' });
6435
+ await log_contact_activity(uid, contact_id, 'classified', { by: 'ai', account_type: 'personal', reverted_from: 'business', reason: provider ? `the only business behind the address is its mail provider (${provider})` : 'deep research found no business behind the address' });
6482
6436
  }
6483
6437
  }
6484
6438
 
@@ -6568,7 +6522,16 @@ export const get_contact_activity = async function (req) {
6568
6522
  try {
6569
6523
  if (!contact_id) throw new Error('contact_id is missing');
6570
6524
  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);
6525
+ // get_app_couch_doc_native THROWS a bare couch 'missing' for an id that is not in
6526
+ // this account's app db (an id from another account is the common way to get here),
6527
+ // so the not-found check below never ran and the caller saw "missing" with nothing
6528
+ // to act on. Catch it and answer the question that was actually asked.
6529
+ let contact_doc;
6530
+ try {
6531
+ contact_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, contact_id);
6532
+ } catch (_) {
6533
+ contact_doc = null;
6534
+ }
6572
6535
  if (!contact_doc || contact_doc.docType !== 'contact') throw new Error(`contact ${contact_id} not found`);
6573
6536
  if (contact_doc.uid !== uid && contact_doc?.uid_created !== uid) throw new Error('Operation not allowed');
6574
6537
 
@@ -6688,7 +6651,11 @@ export const get_account_profile_info = async function (uid, contact_profile_doc
6688
6651
 
6689
6652
  const get_pattern = async function (doc) {
6690
6653
  // Same level-based default as get_contact_pattern, so a card shows the same
6691
- // verification tile whichever path built it.
6654
+ // verification tile whichever path built it, and the same two states that
6655
+ // outrank a level: a shared profile wears its owner's avatar, spam says
6656
+ // spam. A business profile used to paint its own logo across the card face,
6657
+ // which is why a verified business showed no level at all; the logo is
6658
+ // already the avatar on the same card, so the tile is the level here too.
6692
6659
  let ret = level_pattern(doc);
6693
6660
 
6694
6661
  if (doc.shared_from_uid) {
@@ -6697,26 +6664,6 @@ export const get_account_profile_info = async function (uid, contact_profile_doc
6697
6664
  ret = shared_from_uid_ret.data.profile_avatar;
6698
6665
  } else if (doc.is_spam) {
6699
6666
  ret = `spam-pattern.png`;
6700
- } else if (doc.account_type === 'business') {
6701
- ret = doc.profile_picture;
6702
- } else {
6703
- switch (doc.avatar_source) {
6704
- case 'fictional':
6705
- ret = `fictional-avatar-pattern.png`;
6706
- break;
6707
-
6708
- case 'ai profile':
6709
- ret = `fictional-avatar-pattern.png`;
6710
- break;
6711
-
6712
- case 'authentic profile':
6713
- ret = `authentic-avatar-pattern.png`;
6714
- break;
6715
-
6716
- default:
6717
- ret = `xu-pattern.png`;
6718
- break;
6719
- }
6720
6667
  }
6721
6668
 
6722
6669
  return ret;
@@ -6774,6 +6721,10 @@ export const get_account_profile_info = async function (uid, contact_profile_doc
6774
6721
  // console.log(stringToColour('Project Z')); // -> Consistent color for "Project Z"
6775
6722
 
6776
6723
  doc.avatar_source = account_info_ret.data.avatar_source;
6724
+ // A profile inherits the verification level of the account that owns it (a
6725
+ // shared profile, the account that shared it), so its card reads the same
6726
+ // level as its owner's contact card instead of falling back to L0.
6727
+ doc.verify_level = account_info_ret.data.verify_level;
6777
6728
  doc.icon_pattern = await get_pattern(doc);
6778
6729
  doc.name = doc.profile_name;
6779
6730
  doc.interactions = 0;
@@ -7888,9 +7839,11 @@ const _list_owner_members = async function (owner_uid, profiles) {
7888
7839
  const acc = await db_module.get_couch_doc_native('xuda_accounts', u);
7889
7840
  const ai = acc?.account_info || {};
7890
7841
  const name = ai.account_type === 'business' ? ai.business_name || 'Member' : `${ai.first_name || ''} ${ai.last_name || ''}`.trim() || ai.email || 'Member';
7891
- out.push({ uid: u, name });
7842
+ // owner is reported (UI-163) so the member card can say so without the
7843
+ // dashboard having to infer it from the order of this list.
7844
+ out.push({ uid: u, name, owner: u === owner_uid });
7892
7845
  } catch (e) {
7893
- out.push({ uid: u, name: 'Member' });
7846
+ out.push({ uid: u, name: 'Member', owner: u === owner_uid });
7894
7847
  }
7895
7848
  }
7896
7849
  return out;
@@ -7931,7 +7884,139 @@ export const get_credit_management = async function (req) {
7931
7884
  const profiles = await _list_owner_profiles(uid, app_id, account_doc?.account_profile_id);
7932
7885
  const members = await _list_owner_members(uid, profiles);
7933
7886
 
7934
- return { code: 1, data: { rules, can_edit, plan, entitlements, usage_breakdown, model_catalog, profiles, members } };
7887
+ // main_profile_id is reported (UI-163) so the spend chart can name the
7888
+ // owner's own profile: it is deliberately absent from `profiles` (no member
7889
+ // to scope a rule to), and without the id its spend can only be labelled
7890
+ // "some other profile".
7891
+ return { code: 1, data: { rules, can_edit, plan, entitlements, usage_breakdown, model_catalog, profiles, members, main_profile_id: account_doc?.account_profile_id || '' } };
7892
+ } catch (err) {
7893
+ return { code: -1, data: err.message };
7894
+ }
7895
+ };
7896
+
7897
+ /////////////////////////////////////////////////////////////////////////
7898
+ // USAGE HISTORY (UI-163)
7899
+ //
7900
+ // The drill-down behind the "N credits used" line on a profile or member card:
7901
+ // day by day spend, which models it went on, and the calls themselves. It reads
7902
+ // the same raw ai_usage docs _get_scoped_usage aggregates, and calibrates them
7903
+ // the same way, so a card's total and its history cannot tell two stories.
7904
+ //
7905
+ // Calibration: raw token cost is not the credit unit the ledger reports, so the
7906
+ // scan also covers the CURRENT PERIOD and the two totals give the conversion
7907
+ // factor (authoritative period spend / raw period cost). That is a unit
7908
+ // conversion, so it holds for any window inside the scan.
7909
+ /////////////////////////////////////////////////////////////////////////
7910
+ const _CREDIT_HISTORY_MAX_DOCS = 20000;
7911
+ const _CREDIT_HISTORY_MAX_DAYS = 180;
7912
+ const _CREDIT_HISTORY_SCAN_DAYS = 120;
7913
+ const _DAY_MS = 86400000;
7914
+ const _utc_day = function (ts) {
7915
+ return new Date(ts).toISOString().slice(0, 10);
7916
+ };
7917
+ const _raw_usage_cost = function (doc) {
7918
+ const c = doc?.cost || { input: 1, output: 1 };
7919
+ return ((Number(doc?.input_tokens) || 0) * (Number(c.input) || 0) + (Number(doc?.output_tokens) || 0) * (Number(c.output) || 0)) / 1e6;
7920
+ };
7921
+
7922
+ export const get_credit_usage_history = async function (req) {
7923
+ try {
7924
+ const uid = req?.uid || req?.token_ret?.data?.uid;
7925
+ if (!uid) return { code: -1, data: 'not authorized' };
7926
+
7927
+ const scope = ['profile', 'user'].includes(req?.scope) ? req.scope : 'account';
7928
+ const id = typeof req?.id === 'string' ? req.id : '';
7929
+ if (scope !== 'account' && !id) return { code: -1, data: 'id is required for a profile or member history' };
7930
+
7931
+ let days = Math.round(Number(req?.days) || 30);
7932
+ if (!(days >= 1)) days = 30;
7933
+ days = Math.min(_CREDIT_HISTORY_MAX_DAYS, days);
7934
+
7935
+ const account_doc = await db_module.get_couch_doc_native('xuda_accounts', uid);
7936
+ const period = _current_credit_period(account_doc);
7937
+
7938
+ // UTC day buckets, so the series is the same series wherever it is read.
7939
+ const today_start = Math.floor(Date.now() / _DAY_MS) * _DAY_MS;
7940
+ const window_from = today_start - (days - 1) * _DAY_MS;
7941
+ // Reach back to the period start even when the window is shorter (the factor
7942
+ // needs it), but never further than the scan bound: a yearly period must not
7943
+ // turn one drill-down into a full-history scan.
7944
+ const scan_from = Math.max(Math.min(window_from, period.start), today_start - _CREDIT_HISTORY_SCAN_DAYS * _DAY_MS);
7945
+
7946
+ // ignore_warning=true: same nested selector as _get_scoped_usage, and the
7947
+ // same index note applies (an index on account_profile_info.uid before any
7948
+ // prod rollout).
7949
+ const raw = await db_module.find_couch_query(
7950
+ 'xuda_usage',
7951
+ {
7952
+ selector: { docType: 'ai_usage', 'account_profile_info.uid': uid, date_created_ts: { $gte: scan_from } },
7953
+ fields: ['date_created_ts', 'uid', 'model', 'source', 'prompt', 'input_tokens', 'output_tokens', 'cost', 'account_profile_info.account_profile_id'],
7954
+ limit: _CREDIT_HISTORY_MAX_DOCS,
7955
+ },
7956
+ true,
7957
+ );
7958
+ const docs = raw?.docs || [];
7959
+
7960
+ let raw_period_total = 0;
7961
+ for (const d of docs) if ((Number(d.date_created_ts) || 0) >= period.start) raw_period_total += _raw_usage_cost(d);
7962
+ const su = await _get_scoped_usage(uid, false);
7963
+ const factor = raw_period_total > 0 && su.total > 0 ? su.total / raw_period_total : 1;
7964
+
7965
+ const in_scope = function (d) {
7966
+ if (scope === 'profile') return d?.account_profile_info?.account_profile_id === id;
7967
+ if (scope === 'user') return d?.uid === id;
7968
+ return true;
7969
+ };
7970
+
7971
+ const series = {};
7972
+ for (let t = window_from; t <= today_start; t += _DAY_MS) series[_utc_day(t)] = 0;
7973
+
7974
+ const by_model = {};
7975
+ const events = [];
7976
+ let total = 0;
7977
+ for (const d of docs) {
7978
+ const ts = Number(d.date_created_ts) || 0;
7979
+ if (ts < window_from || !in_scope(d)) continue;
7980
+ const credits = _raw_usage_cost(d) * factor;
7981
+ total += credits;
7982
+ const day = _utc_day(ts);
7983
+ if (day in series) series[day] += credits;
7984
+ const code = _conf.ai_model_aliases?.[d.model] || d.model || 'unknown';
7985
+ by_model[code] = (by_model[code] || 0) + credits;
7986
+ events.push({
7987
+ ts,
7988
+ credits,
7989
+ model: code,
7990
+ model_name: _conf.ai_models?.[code]?.name || d.model || code,
7991
+ source: d.source || 'other',
7992
+ uid: d.uid || '',
7993
+ account_profile_id: d?.account_profile_info?.account_profile_id || '',
7994
+ prompt: typeof d.prompt === 'string' ? d.prompt : '',
7995
+ });
7996
+ }
7997
+ events.sort((a, b) => b.ts - a.ts);
7998
+
7999
+ const r2 = (n) => Math.round(n * 100) / 100;
8000
+ return {
8001
+ code: 1,
8002
+ data: {
8003
+ scope,
8004
+ id,
8005
+ days,
8006
+ total: r2(total),
8007
+ calls: events.length,
8008
+ // The scan is capped, so say so rather than quietly reporting a partial
8009
+ // history as the whole of it.
8010
+ truncated: docs.length >= _CREDIT_HISTORY_MAX_DOCS,
8011
+ series: Object.keys(series)
8012
+ .sort()
8013
+ .map((day) => ({ day, credits: r2(series[day]) })),
8014
+ by_model: Object.entries(by_model)
8015
+ .map(([code, credits]) => ({ code, name: _conf.ai_models?.[code]?.name || code, credits: r2(credits) }))
8016
+ .sort((a, b) => b.credits - a.credits),
8017
+ events: events.slice(0, 60).map((e) => ({ ...e, credits: Math.round(e.credits * 10000) / 10000 })),
8018
+ },
8019
+ };
7935
8020
  } catch (err) {
7936
8021
  return { code: -1, data: err.message };
7937
8022
  }
@@ -9205,7 +9290,10 @@ const _newsletter_blast = async (issue, by_uid) => {
9205
9290
  } catch (e) {
9206
9291
  console.error('[newsletter] finalize failed:', e.message);
9207
9292
  }
9208
- _ops_notify_ops(`Newsletter #${issue.issue_number} published`, [['Issue', '#' + issue.issue_number], ['Sent', sent], ['Failed', failed], ['Recipients', total]], 'NL-DONE-' + issue.issue_number);
9293
+ // No ops notification on completion: the "Publishing newsletter #N" alert
9294
+ // already announces the blast, and the result lands on the issue doc
9295
+ // (sent.count / sent.failed) plus the log line below.
9296
+ console.log(`[newsletter] #${issue.issue_number} published: sent=${sent} failed=${failed} recipients=${total}`);
9209
9297
  };
9210
9298
 
9211
9299
  export const newsletter_publish = async function (req = {}) {
package/index_ms.mjs CHANGED
@@ -541,6 +541,10 @@ export const get_credit_management = async function (...args) {
541
541
  return await broker.send_to_queue("get_credit_management", ...args);
542
542
  };
543
543
 
544
+ export const get_credit_usage_history = async function (...args) {
545
+ return await broker.send_to_queue("get_credit_usage_history", ...args);
546
+ };
547
+
544
548
  export const set_credit_rules = async function (...args) {
545
549
  return await broker.send_to_queue("set_credit_rules", ...args);
546
550
  };
package/index_msa.mjs CHANGED
@@ -541,6 +541,10 @@ export const get_credit_management = function (...args) {
541
541
  broker.send_to_queue_async("get_credit_management", ...args);
542
542
  };
543
543
 
544
+ export const get_credit_usage_history = function (...args) {
545
+ broker.send_to_queue_async("get_credit_usage_history", ...args);
546
+ };
547
+
544
548
  export const set_credit_rules = function (...args) {
545
549
  broker.send_to_queue_async("set_credit_rules", ...args);
546
550
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xuda.io/account_module",
3
- "version": "1.2.2310",
3
+ "version": "1.2.2312",
4
4
  "description": "Xuda Account Server Module",
5
5
  "main": "index.mjs",
6
6
  "dependencies": {