@xuda.io/account_module 1.2.2312 → 1.2.2314

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
@@ -144,9 +144,40 @@ export const _sync_account_project_name = async function (account_obj, ref_info)
144
144
  // stat 1 is the unverified state (stat 2 is the password-reset temp state,
145
145
  // stat 3 verified), matching the write-gate in http_module — which now lets
146
146
  // update_account_info and upload_drive_file_user through at stat 1 precisely so
147
- // the picture can be saved. verify_account starts any deferred generation the
148
- // moment the account flips to verified, so nothing is lost by waiting here.
149
- const can_generate_avatar = (account_obj) => account_obj?.stat !== 1;
147
+ // the picture can be saved.
148
+ //
149
+ // The bar is LEVEL 2, not a confirmed email. A Xuda avatar is a portrait shown
150
+ // beside a name across the platform, and level 2 is the first level that checks
151
+ // a face against a photo ID — so it is the first point at which the portrait can
152
+ // be said to be of the person it claims to be. Below it the account keeps its
153
+ // uploaded picture and simply has no generated avatar. verify_module fires
154
+ // ensure_profile_avatar the moment an account crosses into level 2, so nothing
155
+ // is lost by waiting here.
156
+ //
157
+ // verify_level is the projection _recompute writes onto the account doc, so this
158
+ // reads the same number every other surface reads and needs no round trip.
159
+ const AVATAR_MIN_VERIFY_LEVEL = 2;
160
+ const can_generate_avatar = (account_obj) => account_obj?.stat !== 1 && Number(account_obj?.account_info?.verify_level || 0) >= AVATAR_MIN_VERIFY_LEVEL;
161
+
162
+ // Is an avatar owed for the picture currently on the account?
163
+ //
164
+ // The test is against the SOURCE, not against profile_avatar. Several client
165
+ // paths mirror the uploaded photo into profile_avatar so that every surface
166
+ // shows the new face immediately instead of a placeholder while generation
167
+ // runs, which makes "has an avatar" useless as a question — the field is
168
+ // populated either way. profile_avatar_from records the picture the generated
169
+ // avatar was actually made from, and it is written by the generator alone (it is
170
+ // deliberately absent from account_info_properties, so no client can set it).
171
+ //
172
+ // That is what makes this safe to evaluate on every save: replacing the photo
173
+ // makes an avatar due exactly once, and nothing the client writes into
174
+ // profile_avatar can make it due a second time.
175
+ const avatar_is_due = (account_info) => {
176
+ const info = account_info || {};
177
+ if (!info.profile_picture) return false;
178
+ if (info.profile_avatar_stat === 2) return false; // one is already generating
179
+ return info.profile_avatar_from !== info.profile_picture;
180
+ };
150
181
 
151
182
  export const update_account_info = async function (req, job_id, headers) {
152
183
  const { uid } = req;
@@ -184,6 +215,19 @@ export const update_account_info = async function (req, job_id, headers) {
184
215
  var account_info_changes_arr = [];
185
216
  var error = {};
186
217
 
218
+ // The client sends active_account_profile_id straight from its own store, and that store can
219
+ // hold a profile belonging to a DIFFERENT account (e.g. after switching accounts in the
220
+ // dashboard). Writing a foreign id here used to brick the account: every later read of the
221
+ // profile looked it up in THIS account's project db, got "missing", and threw. Only accept an
222
+ // id that actually resolves to an account_profile in this account's own project.
223
+ if (typeof data.active_account_profile_id !== 'undefined' && data.active_account_profile_id !== account_obj.account_info?.active_account_profile_id) {
224
+ const candidate_profile = await _try_get_profile_doc(account_obj.account_project_id, data.active_account_profile_id);
225
+ if (!candidate_profile) {
226
+ console.warn(`[update_account_info] acc ${uid}: rejected active_account_profile_id ${data.active_account_profile_id}, not an account_profile in project ${account_obj.account_project_id}`);
227
+ delete data.active_account_profile_id;
228
+ }
229
+ }
230
+
187
231
  await marketplace_ms.marketplace_save_user({ uid });
188
232
 
189
233
  for (const key of account_info_properties) {
@@ -287,12 +331,31 @@ export const update_account_info = async function (req, job_id, headers) {
287
331
  return { code: -1310, data: error };
288
332
  }
289
333
  if (!change) {
290
- if (can_generate_avatar(account_obj) && account_obj.account_info?.profile_picture && !account_obj.account_info?.profile_avatar && account_obj.account_info.profile_avatar_stat !== 2) {
334
+ // A no-op save still gets to start a generation that is owed. This is the
335
+ // path a verified account takes when it reopens the picture window and
336
+ // presses through without changing anything, and it is how an account that
337
+ // reached level 2 before this rule existed picks up its avatar.
338
+ if (can_generate_avatar(account_obj) && avatar_is_due(account_obj.account_info)) {
291
339
  set_account_profile_picture(uid, uid, account_obj.account_info, job_id, headers, account_profile_info);
292
340
  }
293
341
  return { code: 1300, data: 'no change' };
294
342
  }
295
343
 
344
+ // A client that sets profile_avatar to something OTHER than the photo itself
345
+ // is recording a GENERATED avatar — the create_avatar route the picture window
346
+ // and the onboarding step drive, which hands the file back to the browser and
347
+ // saves nothing itself. Stamping the source here is what stops that avatar
348
+ // reading as still owed, which would have the server generate a second one
349
+ // over the top of it on the next save, at the cost of the AI credits twice.
350
+ //
351
+ // Mirroring the photo INTO profile_avatar is the opposite case: a placeholder
352
+ // so surfaces show the new face immediately, with a real avatar still owed.
353
+ // The two are told apart by exactly that — whether the avatar is the photo.
354
+ if (account_info_changes_arr.includes('profile_avatar') && account_obj.account_info.profile_avatar && account_obj.account_info.profile_avatar !== account_obj.account_info.profile_picture) {
355
+ account_obj.account_info.profile_avatar_from = account_obj.account_info.profile_picture;
356
+ account_obj.account_info.avatar_source = account_obj.account_info.profile_avatar_obj?.avatar_source || account_obj.account_info.avatar_source;
357
+ }
358
+
296
359
  account_obj.account_info.full_name = `${account_obj.account_info.first_name} ${account_obj.account_info.last_name}`;
297
360
 
298
361
  //clean up received from req
@@ -326,10 +389,11 @@ export const update_account_info = async function (req, job_id, headers) {
326
389
  await _sync_account_project_name(account_obj, _old_name_info);
327
390
  }
328
391
 
329
- if (can_generate_avatar(account_obj) && account_obj.account_info?.profile_picture) {
330
- if (!account_obj.account_info?.profile_avatar && account_obj.account_info.profile_avatar_stat !== 2) {
331
- set_account_profile_picture(uid, uid, account_obj.account_info, job_id, headers, account_profile_info);
332
- }
392
+ // A new photo makes the avatar on file stale, so this covers the ordinary
393
+ // "changed my picture" case as well as the first-ever generation: both are
394
+ // just "the avatar was not made from the picture that is there now".
395
+ if (can_generate_avatar(account_obj) && avatar_is_due(account_obj.account_info)) {
396
+ set_account_profile_picture(uid, uid, account_obj.account_info, job_id, headers, account_profile_info);
333
397
  }
334
398
 
335
399
  // Opportunistic Stripe consolidation. Any time the user updates
@@ -606,8 +670,20 @@ export const get_effective_entitlements = function (account_doc) {
606
670
  // to where it is set.
607
671
  // scope 'metered' → no plan ladder at all, usage is metered per call. Listed
608
672
  // so the customer can see it costs no subscription.
673
+ //
674
+ // `is_active` → the module is being PAID for. Defaults to price > 0.
675
+ // `is_activated`→ the customer switched it ON, Free included. Defaults to
676
+ // `is_active` or a `<field>_changed` stamp, and a module that
677
+ // keeps its own answer to that question says so here rather
678
+ // than leaving the UI to guess from a price of zero.
609
679
  const _MODULE_SUBSCRIPTIONS = [
610
- { key: 'commerce', scope: 'account', field: 'commerce_plan', default_plan: 'commerce_free' },
680
+ // UI-192: `commerce_plan` being SET at all is Commerce's own on/off switch
681
+ // (`chosen` in commerce_module _cm_plan), which is why activation is read off
682
+ // the field rather than off the stamp: commerce_set_plan wrote the plan and
683
+ // stamped nothing until this same change, so every account that switched
684
+ // Commerce on at its free tier reported "not switched on" here while its own
685
+ // screen showed the stores.
686
+ { key: 'commerce', scope: 'account', field: 'commerce_plan', default_plan: 'commerce_free', is_activated: (a) => !!a.commerce_plan },
611
687
  { key: 'shipping', scope: 'account', field: 'shipping_plan', default_plan: 'shipping_free' },
612
688
  { key: 'finance', scope: 'account', field: 'finance_plan', default_plan: 'finance_free' },
613
689
  // The desk screen has its own copy of this ladder (UI-82), because Tickets is
@@ -617,12 +693,17 @@ const _MODULE_SUBSCRIPTIONS = [
617
693
  // UI-104: email is its own product, billed per mailbox tier. Its plan lives on the
618
694
  // account as an OBJECT (`email_plan.tier`), which is why it resolves rather than
619
695
  // reading a field, and `set_email_plan` is what moves it.
696
+ // UI-192: and its "the customer chose this" marker is `email_plan.activated_ts`, not
697
+ // an `email_plan_changed` field, which is email_policy's own `chosen`. The generic
698
+ // stamp lookup never found anything, so an account on a deliberately free mailbox
699
+ // tier read as switched off.
620
700
  {
621
701
  key: 'email',
622
702
  scope: 'account',
623
703
  field: 'email_plan',
624
704
  default_plan: 'email_free',
625
705
  resolve: (a) => (a && a.email_plan && a.email_plan.tier ? `email_${a.email_plan.tier}` : ''),
706
+ changed_of: (a) => (a && a.email_plan && a.email_plan.activated_ts) || null,
626
707
  },
627
708
  // Auto response is the single gate for whether the AI answers, on every channel
628
709
  // (chat, the public chat widget, email and the phone). It is account scoped and
@@ -888,9 +969,14 @@ export const get_module_subscriptions = async function (req = {}) {
888
969
  // `plan_of` keeps reporting no plan, exactly as before.
889
970
  const plan_id = m.plan_of ? m.plan_of(account) : '';
890
971
  const plan = plans[plan_id] || {};
972
+ const active = m.is_active ? !!m.is_active(account, base.items) : m.scope === 'metered' || base.items.length > 0;
891
973
  return {
892
974
  ...base,
893
- active: m.is_active ? !!m.is_active(account, base.items) : m.scope === 'metered' || base.items.length > 0,
975
+ active,
976
+ // UI-192: a per-resource module has no free tier to be switched on at, so
977
+ // paying for one of the things IS being switched on. Sent all the same, so
978
+ // every surface can ask the one question rather than two.
979
+ activated: m.is_activated ? !!m.is_activated(account, base.items) : active,
894
980
  included: m.scope === 'metered',
895
981
  plan_id,
896
982
  plan_name: plan.name || '',
@@ -913,6 +999,11 @@ export const get_module_subscriptions = async function (req = {}) {
913
999
  ? { to_plan: pc.to_plan, to_name: plans[pc.to_plan]?.name || pc.to_plan, effective: pc.effective || null }
914
1000
  : null;
915
1001
  const price = Number(plan.price) || 0;
1002
+ // Every *_set_plan method stamps `<field>_changed` when the customer picks a
1003
+ // tier, Free included, so this is the general answer to "have they been through
1004
+ // the picker". A module whose marker is somewhere else brings its own resolver.
1005
+ const changed_ts = (m.changed_of ? m.changed_of(account) : account[`${m.field}_changed`]) || null;
1006
+ const active = m.is_active ? !!m.is_active(account, base.items) : price > 0;
916
1007
  return {
917
1008
  ...base,
918
1009
  plan_id,
@@ -921,10 +1012,16 @@ export const get_module_subscriptions = async function (req = {}) {
921
1012
  // UI-110: paying is the default proof that a module is on, but a module that
922
1013
  // records the customer CHOOSING a tier says so itself, because a deliberate
923
1014
  // 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,
1015
+ active,
1016
+ // UI-192 (Boaz, on the create sheet's switches): "switched on" as its own
1017
+ // answer, so no surface has to reassemble it out of `active` and `changed_ts`
1018
+ // and get it differently from the next one. Picking a FREE tier is switching
1019
+ // the module on: it is a real tier, it opens the module's screen and it puts
1020
+ // the module in the menu, and the switch that did it has to stay flipped.
1021
+ activated: m.is_activated ? !!m.is_activated(account, base.items) : !!(active || changed_ts),
925
1022
  included: false,
926
1023
  pending,
927
- changed_ts: account[`${m.field}_changed`] || null,
1024
+ changed_ts,
928
1025
  };
929
1026
  });
930
1027
  return { code: 1, data: { modules, membership_plan: account.membership_plan || 'free' } };
@@ -3410,6 +3507,20 @@ export const did_you_know_tips = async function (req, job_id, headers) {
3410
3507
 
3411
3508
  const _warned_no_account_project_id = new Set();
3412
3509
  const _warned_no_profile_id = new Set();
3510
+ const _warned_dangling_profile_id = new Set();
3511
+
3512
+ // Resolve a profile doc that is supposed to live in this account's own project db, without
3513
+ // throwing when it does not. A dangling id is not an exceptional condition here: it happens
3514
+ // whenever a profile was deleted, or when an id belonging to ANOTHER account leaked into
3515
+ // account_info.active_account_profile_id (update_account_info used to accept any id the client
3516
+ // sent). Returning null lets the caller fall back instead of hard-failing every read.
3517
+ const _try_get_profile_doc = async function (app_id, doc_id) {
3518
+ if (!app_id || !doc_id) return null;
3519
+ const ret = await db_module.get_app_couch_doc(app_id, doc_id, true);
3520
+ if (ret.code < 0) return null;
3521
+ const doc = ret.data;
3522
+ return doc && doc.docType === 'account_profile' ? doc : null;
3523
+ };
3413
3524
 
3414
3525
  export const get_active_account_profile_info = async function (uid, profile_id) {
3415
3526
  try {
@@ -3468,7 +3579,65 @@ export const get_active_account_profile_info = async function (uid, profile_id)
3468
3579
  return { uid, account_profile_id: null, app_id: acc_obj.account_project_id, is_main: false, account_profile_obj: null };
3469
3580
  }
3470
3581
 
3471
- const account_profile_obj = await db_module.get_app_couch_doc_native(acc_obj.account_project_id, active_account_profile_id);
3582
+ let account_profile_obj = await _try_get_profile_doc(acc_obj.account_project_id, active_account_profile_id);
3583
+
3584
+ if (!account_profile_obj) {
3585
+ // The stored id points at nothing in this account's project db (deleted profile, or a
3586
+ // foreign id written by an older, unvalidated update_account_info). Previously this threw
3587
+ // "missing" and every caller downstream, i.e. the whole AI workspace, died with it. Walk the
3588
+ // rest of the fallback chain instead and repair the account doc so the next read is clean.
3589
+ const candidates = [acc_obj.account_info?.active_account_profile_id, acc_obj.account_profile_id].filter((id) => id && id !== active_account_profile_id);
3590
+
3591
+ for (const candidate of candidates) {
3592
+ account_profile_obj = await _try_get_profile_doc(acc_obj.account_project_id, candidate);
3593
+ if (account_profile_obj) {
3594
+ active_account_profile_id = candidate;
3595
+ break;
3596
+ }
3597
+ }
3598
+
3599
+ if (!account_profile_obj) {
3600
+ // Nothing on the account doc resolves. Adopt this account's own main (or newest) profile.
3601
+ try {
3602
+ const existing = await db_module.find_app_couch_query(acc_obj.account_project_id, {
3603
+ selector: { docType: 'account_profile', uid },
3604
+ limit: 50,
3605
+ });
3606
+ const docs = (existing && existing.docs) || [];
3607
+ const chosen = docs.find((d) => d.main) || docs.slice().sort((a, b) => (b.date_created_ts || 0) - (a.date_created_ts || 0))[0];
3608
+ if (chosen) {
3609
+ account_profile_obj = chosen;
3610
+ active_account_profile_id = chosen._id;
3611
+ }
3612
+ } catch (find_err) {
3613
+ /* fall through to the soft-fail below */
3614
+ }
3615
+ }
3616
+
3617
+ if (!_warned_dangling_profile_id.has(acc_obj._id)) {
3618
+ _warned_dangling_profile_id.add(acc_obj._id);
3619
+ console.warn(`[get_active_account_profile_info] acc ${acc_obj._id}: profile not found in project ${acc_obj.account_project_id}; fell back to ${active_account_profile_id || 'null'}`);
3620
+ }
3621
+
3622
+ if (!account_profile_obj) {
3623
+ return { uid, account_profile_id: null, app_id: acc_obj.account_project_id, is_main: false, account_profile_obj: null };
3624
+ }
3625
+
3626
+ // Persist the repair only when the bad id came from the account's own stored state. A bad
3627
+ // explicit profile_id argument is the caller's problem, not something to write back.
3628
+ if (!profile_id && acc_obj.account_info?.active_account_profile_id !== active_account_profile_id) {
3629
+ try {
3630
+ acc_obj.account_info = acc_obj.account_info || {};
3631
+ acc_obj.account_info.active_account_profile_id = active_account_profile_id;
3632
+ if (!acc_obj.account_profile_id) acc_obj.account_profile_id = active_account_profile_id;
3633
+ await db_module.save_couch_doc('xuda_accounts', acc_obj); // conflict-safe (retry loop)
3634
+ console.log(`[get_active_account_profile_info] self-healed acc ${acc_obj._id}: active profile reset to ${active_account_profile_id}`);
3635
+ } catch (heal_err) {
3636
+ /* the in-memory fallback above still serves this request */
3637
+ }
3638
+ }
3639
+ }
3640
+
3472
3641
  if (account_profile_obj.share_item_id) {
3473
3642
  // set the original profile id if shared
3474
3643
  active_account_profile_id = account_profile_obj.share_item_id;
@@ -3645,13 +3814,14 @@ export const verify_account = async function (req) {
3645
3814
  console.warn('[verify_account] confirm_email banner cleanup failed:', e.message);
3646
3815
  }
3647
3816
 
3648
- // A picture uploaded before verification deliberately did NOT generate an
3649
- // avatar (see can_generate_avatar). The account is verified as of the save
3650
- // above, so start the generation that was deferred. Fire-and-forget:
3651
- // generation is heavy (vision + image ops) and must not hold up the verify
3652
- // response, and ensure_profile_avatar re-reads the doc and does its own
3653
- // guarding picture present, no avatar yet, not already generating so a
3654
- // user who never uploaded one is a no-op.
3817
+ // Confirming an email no longer earns an avatar on its own — level 2 does,
3818
+ // and verify_module starts the generation when the account crosses it. This
3819
+ // call stays because it costs nothing and closes the one ordering it would
3820
+ // otherwise miss: an account that already holds level 2 while still sitting at
3821
+ // stat 1, where can_generate_avatar refused up to the save above. For everyone
3822
+ // else ensure_profile_avatar re-reads the doc, finds the level bar unmet, and
3823
+ // returns. Fire-and-forget either way: generation is heavy (vision + image
3824
+ // ops) and must not hold up the verify response.
3655
3825
  try {
3656
3826
  ensure_profile_avatar({ uid: account_id }).catch((e) => console.warn('[verify_account] deferred avatar start failed:', e.message));
3657
3827
  } catch (e) {
@@ -4311,15 +4481,23 @@ const level_pattern = function (doc) {
4311
4481
  return `level-${level}-pattern.png`;
4312
4482
  };
4313
4483
 
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.
4484
+ // Where a contact comes from decides which language its tile speaks, and the two
4485
+ // are not interchangeable.
4486
+ //
4487
+ // A contact with a `contact_uid` is a real Xuda account, so its tile is that
4488
+ // account's verification level. This used to be computed and then thrown away by
4489
+ // a switch on `avatar_source` that returned the generic xu tile, which is why a
4490
+ // card for a verified person showed no level at all.
4491
+ //
4492
+ // A contact with no account behind it has no level and must never be given one,
4493
+ // not even L0: nobody scored that person, and a card that says otherwise is a
4494
+ // claim we cannot back. Those keep the tile for where they walked in from, the
4495
+ // mailbox, the web, or an incoming call.
4496
+ //
4497
+ // Ahead of both, the states that mean the card cannot show either: a shared card
4498
+ // wears its owner's avatar, an avatar mid-processing says so, spam says spam.
4321
4499
  const get_contact_pattern = async function (doc) {
4322
- let ret = level_pattern(doc);
4500
+ let ret = `default-pattern.png`;
4323
4501
 
4324
4502
  if (doc?.shared_from_uid) {
4325
4503
  const shared_from_uid_ret = await get_account_name({ uid_query: doc.shared_from_uid });
@@ -4338,12 +4516,34 @@ const get_contact_pattern = async function (doc) {
4338
4516
  }
4339
4517
  } else if (doc.is_spam) {
4340
4518
  ret = `spam-pattern.png`;
4519
+ } else if (doc.contact_uid) {
4520
+ ret = level_pattern(doc);
4521
+ } else {
4522
+ switch (doc.source) {
4523
+ case 'read emails': {
4524
+ ret = `email-pattern.png`;
4525
+ break;
4526
+ }
4527
+ // Every way a stranger reaches us through a web surface reads as web: the
4528
+ // chat widget, a contact form, and the older plain 'web'. They are the
4529
+ // same story to whoever is looking at the card.
4530
+ case 'web':
4531
+ case 'widget':
4532
+ case 'contact_form': {
4533
+ ret = `web-pattern.png`;
4534
+ break;
4535
+ }
4536
+ // A contact the phone system created for an unknown caller (voice_module
4537
+ // writes this when an inbound call comes from a number we do not hold).
4538
+ case 'inbound_call': {
4539
+ ret = `phone-pattern.png`;
4540
+ break;
4541
+ }
4542
+
4543
+ default:
4544
+ break;
4545
+ }
4341
4546
  }
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.
4347
4547
  return ret;
4348
4548
  };
4349
4549
 
@@ -5421,6 +5621,16 @@ export const ts_contact = async function (uid, contact_id) {
5421
5621
  };
5422
5622
 
5423
5623
  const set_account_profile_picture = async function (uid, account_uid, metadata, job_id, headers, account_profile_info) {
5624
+ // Normalised HERE rather than at each call site because every background
5625
+ // trigger — verify_account, the L2 crossing, the widget signup — has no
5626
+ // request to take headers from, and an omitted argument arrives as null over
5627
+ // the queue rather than undefined, so a default parameter would not catch it.
5628
+ // drive_module reads headers['cf-connecting-ip'] unguarded when it records an
5629
+ // upload, so a null threw there and the whole generation was lost. It failed
5630
+ // quietly: the throw was caught below and written to profile_avatar_error,
5631
+ // which nothing displays, leaving accounts sitting at stat 1 with no avatar
5632
+ // and no visible reason.
5633
+ headers = headers || {};
5424
5634
  await update_account_profile_picture_status(account_uid, 1);
5425
5635
  try {
5426
5636
  let profile_picture;
@@ -5451,7 +5661,7 @@ const set_account_profile_picture = async function (uid, account_uid, metadata,
5451
5661
  profile_picture = account_info.profile_picture;
5452
5662
  }
5453
5663
 
5454
- if (!account_info.profile_avatar) {
5664
+ if (avatar_is_due(account_info)) {
5455
5665
  let business_size;
5456
5666
 
5457
5667
  switch (account_obj.membership_plan) {
@@ -5499,6 +5709,28 @@ const set_account_profile_picture = async function (uid, account_uid, metadata,
5499
5709
  if (!_.isObject(file_ret.data)) throw new Error('file_ret not an object');
5500
5710
  account_info.profile_avatar_obj = file_ret.data;
5501
5711
  account_info.profile_avatar = account_info.profile_avatar_obj.file_url;
5712
+ // Which photo this avatar was made from. Saved in the same write as the
5713
+ // avatar so the two can never disagree, and read back by avatar_is_due —
5714
+ // it is what stops a generation repeating and what makes the next photo
5715
+ // change ask for a new one.
5716
+ account_info.profile_avatar_from = account_info.profile_picture;
5717
+ // WHICH of the two things ai_module produced. get_profile_avatar makes an
5718
+ // 'authentic profile' — the person's own face, restored if needed,
5719
+ // background removed, centred on the detected face box — when the photo
5720
+ // can carry one, and silently falls back to a 'fictional' likeness
5721
+ // generated from the account's metadata when it cannot.
5722
+ //
5723
+ // That distinction is the whole point of holding this behind level 2: the
5724
+ // avatar is meant to BE the verified person, and an invented face
5725
+ // presented as theirs would say something untrue about an account that
5726
+ // has just proved who it belongs to. The picture window reads this and
5727
+ // says so rather than letting the substitution pass unremarked.
5728
+ //
5729
+ // Nothing had ever written avatar_source on the ACCOUNT path, though the
5730
+ // contact and profile builders set it and every card builder copies
5731
+ // account_info.avatar_source onto the doc it makes — so they were all
5732
+ // copying undefined.
5733
+ account_info.avatar_source = file_ret.data.avatar_source;
5502
5734
 
5503
5735
  const account_save_ret = await db_module.save_couch_doc('xuda_accounts', account_obj);
5504
5736
  await update_account_profile_picture_status(account_uid, 3);
@@ -5511,12 +5743,18 @@ const set_account_profile_picture = async function (uid, account_uid, metadata,
5511
5743
  }
5512
5744
  };
5513
5745
 
5514
- // Internal trigger used by the widget Google-signup flow: when a freshly-created
5515
- // visitor account already has a profile_picture (their Google photo) but no
5516
- // generated avatar yet, kick off avatar generation. set_account_profile_picture
5517
- // maintains profile_avatar_stat (1 2 3); the caller polls that. Mirrors the
5518
- // opportunistic trigger in update_account_info (profile_avatar_stat !== 2 guards
5519
- // against re-triggering while a generation is mid-flight).
5746
+ // The one internal entry point for "start this account's avatar if one is
5747
+ // owed". Called by the widget Google-signup flow (a fresh visitor arriving with
5748
+ // their Google photo) and by verify_module the moment an account crosses into
5749
+ // level 2, which is the trigger that matters: reaching level 2 is what earns the
5750
+ // avatar, and it can be reached without anyone touching this account's profile.
5751
+ //
5752
+ // Every rule lives in the two helpers rather than here, so a caller cannot get
5753
+ // an avatar for an account that has not earned one by picking this door:
5754
+ // can_generate_avatar holds the level bar, avatar_is_due holds "not already
5755
+ // made from this photo, and not already running". A caller with nothing owing
5756
+ // is a cheap no-op. set_account_profile_picture maintains profile_avatar_stat
5757
+ // (1 → 2 → 3), which is what the dashboard polls.
5520
5758
  export const ensure_profile_avatar = async function (req, job_id, headers) {
5521
5759
  try {
5522
5760
  const { uid } = req || {};
@@ -5524,7 +5762,7 @@ export const ensure_profile_avatar = async function (req, job_id, headers) {
5524
5762
  const { code, data: account_obj } = await db_module.get_couch_doc('xuda_accounts', uid);
5525
5763
  if (code < 0 || !account_obj) return { code: -1, data: 'account not found' };
5526
5764
  const info = account_obj.account_info || {};
5527
- if (info.profile_picture && !info.profile_avatar && info.profile_avatar_stat !== 2) {
5765
+ if (can_generate_avatar(account_obj) && avatar_is_due(info)) {
5528
5766
  // Best-effort profile context for AI-usage attribution. A freshly-created
5529
5767
  // widget visitor may have no profile/project yet — tolerate that.
5530
5768
  let account_profile_info;
@@ -6919,6 +7157,9 @@ export const archive_account_profile = async function (req) {
6919
7157
  await db_module.save_couch_doc('xuda_accounts', account_doc);
6920
7158
  }
6921
7159
 
7160
+ // UI-202
7161
+ log_profile_activity(uid, profile_id, 'archived', { by: 'user', reason: account_profile_doc.stat_reason }, app_id);
7162
+
6922
7163
  return account_profile_save_ret;
6923
7164
  } catch (err) {
6924
7165
  return {
@@ -6952,6 +7193,10 @@ export const delete_account_profile = async function (req, job_id, headers) {
6952
7193
  }
6953
7194
 
6954
7195
  ai_msa.delete_depended_chats(uid, profile_id);
7196
+
7197
+ // UI-202: the last row this profile gets.
7198
+ log_profile_activity(uid, profile_id, 'deleted', { by: 'user', reason: account_profile_doc.stat_reason, note: 'the chats that hung off this profile were deleted with it' }, app_id);
7199
+
6955
7200
  return account_profile_save_ret;
6956
7201
  } catch (err) {
6957
7202
  return {
@@ -6979,6 +7224,9 @@ export const unarchive_account_profile = async function (req) {
6979
7224
 
6980
7225
  const account_profile_save_ret = await db_module.save_app_couch_doc(app_id, account_profile_doc);
6981
7226
 
7227
+ // UI-202
7228
+ log_profile_activity(uid, profile_id, 'unarchived', { by: 'user' }, app_id);
7229
+
6982
7230
  return account_profile_save_ret;
6983
7231
  }
6984
7232
  } catch (err) {
@@ -7016,6 +7264,9 @@ export const create_account_profile = async function (req, job_id, headers) {
7016
7264
  const save_ret = await db_module.save_app_couch_doc(app_id, doc);
7017
7265
  // acc_obj.active_profile_id = save_ret.data.id;
7018
7266
 
7267
+ // UI-202: the first row of this profile's trail.
7268
+ log_profile_activity(uid, doc._id, 'created', { by: 'user', name: profile_name, type: account_type, main: !!main, mailbox: !!email_account_id }, app_id);
7269
+
7019
7270
  // return { code: 55, data: save_ret };
7020
7271
  return save_ret;
7021
7272
  } catch (err) {
@@ -7052,12 +7303,17 @@ export const update_account_profile = async function (req, job_id, headers) {
7052
7303
  }
7053
7304
 
7054
7305
  let changes_arr = [];
7306
+ // UI-202: the previous values of the fields this edit touches, so the trail can say what
7307
+ // it changed FROM as well as to. Taken inside the same loop that detects the change, so
7308
+ // it costs nothing and cannot drift from changes_arr.
7309
+ const previous_values = {};
7055
7310
 
7056
7311
  for (const key of account_profile_properties) {
7057
7312
  let val = req[key];
7058
7313
  if (typeof val === 'undefined') continue;
7059
7314
  if (account_profile_doc[key] !== val) {
7060
7315
  changes_arr.push(key);
7316
+ previous_values[key] = account_profile_doc[key];
7061
7317
  account_profile_doc[key] = val;
7062
7318
  }
7063
7319
  }
@@ -7071,6 +7327,27 @@ export const update_account_profile = async function (req, job_id, headers) {
7071
7327
 
7072
7328
  const save_ret = await db_module.save_app_couch_doc(app_id, account_profile_doc);
7073
7329
 
7330
+ // UI-202: one row naming the fields that moved, with the values of the ones that read
7331
+ // as values (a signature and an avatar url do not). Auto response gets its OWN row on
7332
+ // top: it is the switch that decides whether this profile answers mail by itself, so
7333
+ // "auto response" buried in a list of six field names is not good enough.
7334
+ const changed = changes_arr.map((key) => PROFILE_FIELD_LABELS[key] || key.replace(/_/g, ' '));
7335
+ const values = {};
7336
+ for (const key of changes_arr) {
7337
+ if (PROFILE_FIELDS_WITH_VALUES.has(key)) values[PROFILE_FIELD_LABELS[key] || key] = String(account_profile_doc[key] ?? '').slice(0, 120);
7338
+ }
7339
+ log_profile_activity(uid, _id, 'updated', { by: 'user', changed: [...new Set(changed)], values, previous_name: changes_arr.includes('profile_name') ? previous_values.profile_name : undefined }, app_id);
7340
+
7341
+ if (changes_arr.includes('auto_respond')) {
7342
+ log_profile_activity(
7343
+ uid,
7344
+ _id,
7345
+ account_profile_doc.auto_respond ? 'auto_respond_on' : 'auto_respond_off',
7346
+ { by: 'user', mode: account_profile_doc.auto_respond_mode, agents: (account_profile_doc.auto_respond_agents || []).length },
7347
+ app_id
7348
+ );
7349
+ }
7350
+
7074
7351
  return { code: 56, data: save_ret };
7075
7352
  } catch (err) {
7076
7353
  return { code: -56, data: err.message };
@@ -7166,6 +7443,135 @@ const log_contact_activity = async function (uid, contact_id, event, detail = {}
7166
7443
  }
7167
7444
  };
7168
7445
 
7446
+ // ─── UI-202: the account profile activity trail ───────────────────────────────────────
7447
+ // The fourth of the same family (contacts UI-134, agents and chats UI-194/195), same shape
7448
+ // because one panel renders all of them. A profile doc keeps the current answer to every
7449
+ // question and nothing about when it changed, and a profile is the thing that answers mail
7450
+ // on its own: "when was auto respond switched on", "which agents were attached", "who
7451
+ // changed the signature" had no trace at all.
7452
+ //
7453
+ // Append-only, never awaited, and a failure to write it can never fail the action it
7454
+ // records. Lives in the same app db as the profile.
7455
+ const log_profile_activity = async function (uid, profile_id, event, detail = {}, app_id) {
7456
+ try {
7457
+ if (!uid || !profile_id || !event) return null;
7458
+ const app = app_id || (await get_account_default_project_id(uid));
7459
+ if (!app) return null;
7460
+
7461
+ return await db_module.save_app_couch_doc_native(app, {
7462
+ _id: await _common.xuda_get_uuid('profile_activity'),
7463
+ docType: 'profile_activity',
7464
+ profile_id,
7465
+ uid,
7466
+ event,
7467
+ detail,
7468
+ ts: Date.now(),
7469
+ stat: 3,
7470
+ });
7471
+ } catch (err) {
7472
+ console.error('[account_module] profile activity not recorded:', event, profile_id, err?.message || err);
7473
+ return null;
7474
+ }
7475
+ };
7476
+
7477
+ // The cross-module door: ai_module regenerates a profile's avatar, team_module shares one.
7478
+ export const log_account_profile_activity = async function (req) {
7479
+ const { uid, profile_id, event, detail, app_id } = req || {};
7480
+ const ret = await log_profile_activity(uid, profile_id, event, detail || {}, app_id);
7481
+ return { code: ret ? 1 : 0, data: ret ? { profile_id, event } : 'not recorded' };
7482
+ };
7483
+
7484
+ // Field names the edit row prints, and the ones whose VALUE is safe and useful to print
7485
+ // with them. A signature is free text and an avatar is a url, so those travel as the fact
7486
+ // that they changed; a name, a mode or a model is the whole point of the row.
7487
+ const PROFILE_FIELD_LABELS = {
7488
+ profile_name: 'name',
7489
+ profile_signature: 'signature',
7490
+ email_account_id: 'mailbox',
7491
+ profile_picture: 'picture',
7492
+ profile_avatar: 'avatar',
7493
+ profile_picture_obj: 'picture',
7494
+ profile_avatar_obj: 'avatar',
7495
+ auto_respond: 'auto response',
7496
+ auto_respond_mode: 'auto response mode',
7497
+ auto_respond_agents: 'auto response agents',
7498
+ account_type: 'type',
7499
+ active_ai_model: 'model',
7500
+ ai_models: 'models',
7501
+ active_agents: 'agents',
7502
+ email_template: 'email template',
7503
+ };
7504
+ const PROFILE_FIELDS_WITH_VALUES = new Set(['profile_name', 'auto_respond_mode', 'account_type', 'active_ai_model']);
7505
+
7506
+ export const get_account_profile_activity = async function (req) {
7507
+ const { uid, profile_id } = req;
7508
+ try {
7509
+ if (!profile_id) throw new Error('profile_id is missing');
7510
+ const app_id = await get_account_default_project_id(uid);
7511
+
7512
+ let profile_doc;
7513
+ try {
7514
+ profile_doc = await db_module.get_app_couch_doc_native(app_id, profile_id);
7515
+ } catch (_) {
7516
+ profile_doc = null;
7517
+ }
7518
+ if (!profile_doc || profile_doc.docType !== 'account_profile') throw new Error(`profile ${profile_id} not found`);
7519
+ if (profile_doc.uid !== uid) throw new Error('Operation not allowed');
7520
+
7521
+ const recorded_ret = await db_module.find_app_couch_query(app_id, {
7522
+ selector: { docType: 'profile_activity', profile_id },
7523
+ limit: 500,
7524
+ });
7525
+ const recorded = (recorded_ret?.docs || []).map((d) => ({ event: d.event, detail: d.detail || {}, ts: d.ts, derived: false }));
7526
+
7527
+ const derived = [];
7528
+ const add = (event, ts, detail) => {
7529
+ if (!ts) return;
7530
+ if (recorded.some((r) => r.event === event)) return;
7531
+ derived.push({ event, detail, ts, derived: true });
7532
+ };
7533
+
7534
+ const created_ts = profile_doc.date_created_ts || profile_doc.ts;
7535
+ add('created', created_ts, { name: profile_doc.profile_name, type: profile_doc.account_type, main: !!profile_doc.main });
7536
+ if (profile_doc.email_account_id) add('mailbox_bound', profile_doc.ts || created_ts, {});
7537
+ if (profile_doc.auto_respond) add('auto_respond_on', profile_doc.ts || created_ts, { mode: profile_doc.auto_respond_mode, agents: (profile_doc.auto_respond_agents || []).length });
7538
+ if (profile_doc.profile_avatar) add('avatar_ready', profile_doc.profile_avatar_stat_ts || profile_doc.ts || created_ts, {});
7539
+ // UI-204: the two public surfaces, reconstructed from the flags they are stored as, so a
7540
+ // profile that has been taking messages from strangers for months says so today rather
7541
+ // than only from the next time somebody toggles it.
7542
+ if (profile_doc.widget_enabled) add('widget_on', profile_doc.ts || created_ts, {});
7543
+ if (profile_doc.contact_form_enabled) add('contact_form_on', profile_doc.ts || created_ts, {});
7544
+ if (profile_doc.shared_from_uid) add('shared_with_you', profile_doc.shared_ts || created_ts, { from_uid: profile_doc.shared_from_uid });
7545
+ if (profile_doc.stat === 5) add('archived', profile_doc.stat_ts, { reason: profile_doc.stat_reason });
7546
+ if (profile_doc.stat === 4) add('deleted', profile_doc.stat_ts, { reason: profile_doc.stat_reason });
7547
+
7548
+ const rows = [...recorded, ...derived].sort((a, b) => (b.ts || 0) - (a.ts || 0));
7549
+
7550
+ return {
7551
+ code: 1,
7552
+ data: {
7553
+ profile_id,
7554
+ current: {
7555
+ name: profile_doc.profile_name || null,
7556
+ stat: profile_doc.stat,
7557
+ main: !!profile_doc.main,
7558
+ type: profile_doc.account_type || null,
7559
+ auto_respond: !!profile_doc.auto_respond,
7560
+ auto_respond_mode: profile_doc.auto_respond_mode || null,
7561
+ mailbox: !!profile_doc.email_account_id,
7562
+ model: profile_doc.active_ai_model || null,
7563
+ agents: (profile_doc.active_agents || []).length,
7564
+ widget: !!profile_doc.widget_enabled,
7565
+ contact_form: !!profile_doc.contact_form_enabled,
7566
+ },
7567
+ rows,
7568
+ },
7569
+ };
7570
+ } catch (err) {
7571
+ return { code: -25, data: err.message };
7572
+ }
7573
+ };
7574
+
7169
7575
  export const save_contact = async function (uid, contact_doc) {
7170
7576
  const account_profile_info = await get_active_account_profile_info(uid);
7171
7577
  const contact_ret = await db_module.save_app_couch_doc(account_profile_info.app_id, contact_doc);
@@ -7503,6 +7909,96 @@ const _current_credit_period = function (account_doc) {
7503
7909
 
7504
7910
  // Single source of truth for "how many credits does this account have left".
7505
7911
  // Everything that gates, meters or displays credits must go through here.
7912
+ // The membership plans SELL a monthly AI allowance, but the only thing that has
7913
+ // ever minted it into the ledger is the Stripe invoice.paid webhook
7914
+ // (stripe_module `_grant_period_credits_for_invoice`). Miss that webhook once
7915
+ // and there is no second chance: `archive_expire_ai_credits` retires the last
7916
+ // grant at its 30-day mark, the balance silently falls back to the free daily
7917
+ // drip, and the account is hard-blocked by the gate while the plans page and
7918
+ // `get_effective_entitlements` both still promise the full allowance.
7919
+ //
7920
+ // That is not hypothetical. A Team account on dev sat at 1,600 granted until
7921
+ // 2026-08-11 09:00 UTC, when the cron expired a grant written on 2026-07-12.
7922
+ // Nothing replaced it, so the account dropped to 31 credits (30 daily drips
7923
+ // plus a friendship bonus) against 219 already spent, and every AI call was
7924
+ // refused. The old safety net for this, add_ai_credits_to_active_accounts, is
7925
+ // commented out (see the call site above).
7926
+ //
7927
+ // So the allowance heals itself: a paid membership with no live membership
7928
+ // grant gets THIS period's allowance minted here. Minting twice is what would
7929
+ // actually cost money, so three independent guards stand in the way: a live
7930
+ // grant from any membership plan wins, the account carries a per-period marker,
7931
+ // and record_ai_credit refuses a duplicate (credited_uid, details) outright.
7932
+ // The grant carries period_end_ts, so the expiry cron leaves it alone until the
7933
+ // period it belongs to is genuinely over.
7934
+ const _membership_plan_of = function (account_doc) {
7935
+ const plan = _conf.PLAN_OBJ?.[account_doc?.membership_plan];
7936
+ if (!plan || plan.category !== 'membership') return null;
7937
+ // The free tier's allowance is the daily drip, which already writes its own
7938
+ // ledger docs. Minting on top of it would hand every free account a second
7939
+ // month's worth.
7940
+ if (!(Number(plan.price) > 0)) return null;
7941
+ const credits = Number(plan.features?.ai_credits);
7942
+ if (!Number.isFinite(credits) || credits <= 0) return null;
7943
+ return { plan, credits };
7944
+ };
7945
+
7946
+ const _self_heal_period_grant = async function (uid, doc, period, by_source) {
7947
+ try {
7948
+ if (doc?.stat !== 3) return 0;
7949
+ // Past due is exactly when an allowance should NOT be minted: the webhook
7950
+ // only ever granted on a PAID invoice.
7951
+ if (doc?.account_billing_hold_status) return 0;
7952
+
7953
+ const found = _membership_plan_of(doc);
7954
+ if (!found) return 0;
7955
+
7956
+ // A live grant from any membership plan means the normal path worked (or a
7957
+ // downgrade left the old one running). Either way this period is covered,
7958
+ // and a plan change must not be a way to mint a second allowance.
7959
+ for (const [source, amount] of Object.entries(by_source || {})) {
7960
+ if (!(amount > 0)) continue;
7961
+ const p = _conf.PLAN_OBJ?.[source];
7962
+ if (p && p.category === 'membership') return 0;
7963
+ }
7964
+
7965
+ // Already healed this period. A grant retired mid-period (an expiry, a
7966
+ // roll) must not come back, so this is checked before minting, not after.
7967
+ if (Number(doc.credits_selfheal_period_ts) === period.start) return 0;
7968
+
7969
+ const day = new Date(period.start).toISOString().slice(0, 10);
7970
+ const ret = await record_ai_credit('system', found.credits, found.plan.id, `plan allowance ${found.plan.id} ${day}`, uid, {
7971
+ kind: CREDIT_KIND.PERIOD,
7972
+ period_start_ts: period.start,
7973
+ period_end_ts: period.end,
7974
+ });
7975
+ if (!(ret?.code > -1)) return 0;
7976
+
7977
+ // Fresh read for the write: the caller's copy of the account doc may be
7978
+ // held across a long request, and this marker must not ride on top of
7979
+ // whatever else it has since changed.
7980
+ try {
7981
+ const fresh = await db_module.get_couch_doc_native('xuda_accounts', uid);
7982
+ if (fresh) {
7983
+ fresh.credits_selfheal_period_ts = period.start;
7984
+ await db_module.save_couch_doc('xuda_accounts', fresh);
7985
+ }
7986
+ } catch (e) {
7987
+ // The marker is an optimisation, not the guard: record_ai_credit's own
7988
+ // duplicate check still makes a second mint impossible.
7989
+ console.error('[credit selfheal marker]', e?.message || e);
7990
+ }
7991
+ if (doc && typeof doc === 'object') doc.credits_selfheal_period_ts = period.start;
7992
+
7993
+ console.log(`[credit selfheal] ${uid} +${found.credits} (${found.plan.id}) for period starting ${day}`);
7994
+ return found.credits;
7995
+ } catch (err) {
7996
+ // Never let this break a balance read: the gate calls it on every AI call.
7997
+ console.error('[credit selfheal]', err?.message || err);
7998
+ return 0;
7999
+ }
8000
+ };
8001
+
7506
8002
  export const get_credit_balance = async function (uid, account_doc) {
7507
8003
  const doc = account_doc || (await db_module.get_couch_doc_native('xuda_accounts', uid));
7508
8004
  const period = _current_credit_period(doc);
@@ -7523,6 +8019,15 @@ export const get_credit_balance = async function (uid, account_doc) {
7523
8019
  else period_grant += credits;
7524
8020
  }
7525
8021
 
8022
+ // Mint this period's plan allowance if nothing else did (see above). Costs
8023
+ // nothing on the hot path when there is a live grant: every guard but the
8024
+ // last reads state that is already in hand.
8025
+ const healed = await _self_heal_period_grant(uid, doc, period, by_source);
8026
+ if (healed > 0) {
8027
+ period_grant += healed;
8028
+ by_source[doc.membership_plan] = (by_source[doc.membership_plan] || 0) + healed;
8029
+ }
8030
+
7526
8031
  const usage_ret = await get_account_ai_usage({ uid, ts_from: period.start, ts_to: Date.now() });
7527
8032
  const used = _usage_total(usage_ret?.data?.usage);
7528
8033
 
@@ -8401,6 +8906,41 @@ export const archive_expire_ai_credits = async function () {
8401
8906
  }
8402
8907
  };
8403
8908
 
8909
+ // Of the three ai_credit queries in this file, two were already indexed and one
8910
+ // was not:
8911
+ //
8912
+ // record_ai_credit's duplicate check { docType, credited_uid, details }
8913
+ // -> idx_billing_credit_lookup, fine
8914
+ // archive_expire_ai_credits { docType, stat, date_created_ts: { $lt } }
8915
+ // -> stat_docType_date_created_ts-json-index, fine
8916
+ // roll_credit_period's grant retirement { docType, credited_uid, stat }, limit 9999
8917
+ // -> _all_docs, i.e. a FULL SCAN of the whole ledger on every period roll
8918
+ //
8919
+ // master's xuda_billing holds ~27.5k docs, so that last one read all of them to
8920
+ // return one account's handful of grants, and Couch logged "No matching index
8921
+ // found ... the number of documents examined is high in proportion to the number
8922
+ // of results returned" every time.
8923
+ //
8924
+ // credited_uid leads because it is by far the most selective field; all three
8925
+ // clauses are equalities, so there is no range field to keep last.
8926
+ //
8927
+ // Not host-gated: xuda_billing is reachable wherever this module runs, and the PUT
8928
+ // no-ops once the design doc is already there (including when it arrived by
8929
+ // replication rather than from this node's own run).
8930
+ (async () => {
8931
+ try {
8932
+ const ret = await db_module.create_couch_index('xuda_billing', {
8933
+ index: { fields: ['credited_uid', 'docType', 'stat'] },
8934
+ name: 'idx_credit_uid_type_stat',
8935
+ ddoc: 'idx_credit_uid_type_stat',
8936
+ type: 'json',
8937
+ });
8938
+ if (ret?.error) console.error('[credits] xuda_billing credited_uid/docType/stat index create failed:', ret.error);
8939
+ } catch (err) {
8940
+ console.error('[credits] xuda_billing credited_uid/docType/stat index init failed:', err?.message || err);
8941
+ }
8942
+ })();
8943
+
8404
8944
  export const read_accounts_emails = async function () {
8405
8945
  const active_accounts = await db_module.find_couch_query('xuda_accounts', { selector: { stat: 3, docType: 'account' }, limit: 99999 });
8406
8946
  for await (let account_doc of active_accounts.docs) {
package/index_ms.mjs CHANGED
@@ -493,6 +493,14 @@ export const get_contact = async function (...args) {
493
493
  return await broker.send_to_queue("get_contact", ...args);
494
494
  };
495
495
 
496
+ export const log_account_profile_activity = async function (...args) {
497
+ return await broker.send_to_queue("log_account_profile_activity", ...args);
498
+ };
499
+
500
+ export const get_account_profile_activity = async function (...args) {
501
+ return await broker.send_to_queue("get_account_profile_activity", ...args);
502
+ };
503
+
496
504
  export const save_contact = async function (...args) {
497
505
  return await broker.send_to_queue("save_contact", ...args);
498
506
  };
package/index_msa.mjs CHANGED
@@ -493,6 +493,14 @@ export const get_contact = function (...args) {
493
493
  broker.send_to_queue_async("get_contact", ...args);
494
494
  };
495
495
 
496
+ export const log_account_profile_activity = function (...args) {
497
+ broker.send_to_queue_async("log_account_profile_activity", ...args);
498
+ };
499
+
500
+ export const get_account_profile_activity = function (...args) {
501
+ broker.send_to_queue_async("get_account_profile_activity", ...args);
502
+ };
503
+
496
504
  export const save_contact = function (...args) {
497
505
  broker.send_to_queue_async("save_contact", ...args);
498
506
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xuda.io/account_module",
3
- "version": "1.2.2312",
3
+ "version": "1.2.2314",
4
4
  "description": "Xuda Account Server Module",
5
5
  "main": "index.mjs",
6
6
  "dependencies": {