@xuda.io/account_module 1.2.2315 → 1.2.2316

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
@@ -6227,6 +6227,12 @@ export const get_contacts = async function (req, job_id, headers) {
6227
6227
  break;
6228
6228
  }
6229
6229
 
6230
+ // UI-221
6231
+ case 'favorites': {
6232
+ opt.selector.favorite = true;
6233
+ break;
6234
+ }
6235
+
6230
6236
  case 'online': {
6231
6237
  opt.limit = 9999;
6232
6238
  break;
@@ -6536,6 +6542,78 @@ export const unpin_contact = async function (req) {
6536
6542
  }
6537
6543
  };
6538
6544
 
6545
+ // ─── UI-221: favorites ────────────────────────────────────────────────────────────────
6546
+ // The softer half of the pair pin_contact / unpin_contact already covers: pin puts a
6547
+ // contact in the pinned bar (a working set of a handful), favorite is the long-lived "I
6548
+ // keep coming back to this one" mark and only ever answers the Favorites filter. Separate
6549
+ // flags on purpose, an item can carry both.
6550
+ //
6551
+ // Both contacts and account_profiles route here, as they do for pin: the pair reads and
6552
+ // writes a doc by id and the flag sits at the top level on both kinds.
6553
+ //
6554
+ // The two kinds do NOT live in the same db, though, and that is where pin's version of this
6555
+ // is wrong. Contacts are LISTED out of `get_active_account_profile_info(uid).app_id`, which
6556
+ // resolves to `shared_from_uid || uid` (see line ~3657), so under a profile shared FROM
6557
+ // another account it is the OWNER's project. Profiles are listed out of
6558
+ // `get_account_default_project_id(uid)`, always the account's own. Reading only the first
6559
+ // (what get_contact does) therefore misses your own profile whenever the active profile is a
6560
+ // shared one, and the write fails on a row the user is looking at.
6561
+ //
6562
+ // So: the contact db first, which keeps today's contact behaviour byte for byte, and the
6563
+ // account's own project as the fallback, which is where a profile doc actually is. They are
6564
+ // the same db in every case except the shared-profile one, so this is one read either way.
6565
+ const _set_contact_favorite = async function (uid, contact_id, favorite) {
6566
+ const contact_app_id = (await get_active_account_profile_info(uid))?.app_id;
6567
+ const own_app_id = await get_account_default_project_id(uid);
6568
+
6569
+ let app_id = contact_app_id;
6570
+ let doc = contact_app_id ? await _try_get_doc(contact_app_id, contact_id) : null;
6571
+ if (!doc && own_app_id && own_app_id !== contact_app_id) {
6572
+ app_id = own_app_id;
6573
+ doc = await _try_get_doc(own_app_id, contact_id);
6574
+ }
6575
+ if (!doc) throw new Error(`${contact_id} not found`);
6576
+
6577
+ doc.favorite = favorite;
6578
+ const save_ret = await db_module.save_app_couch_doc(app_id, doc);
6579
+ log_contact_activity(uid, contact_id, favorite ? 'favorited' : 'unfavorited', { by: 'user' }, app_id);
6580
+
6581
+ return save_ret;
6582
+ };
6583
+
6584
+ // A missing doc is an expected answer here, not an error: it is how the caller above learns
6585
+ // to look in the other db. Same soft-fetch shape as _try_get_profile_doc above, which is the
6586
+ // non-throwing form of get_app_couch_doc_native.
6587
+ const _try_get_doc = async function (app_id, doc_id) {
6588
+ if (!app_id || !doc_id) return null;
6589
+ const ret = await db_module.get_app_couch_doc(app_id, doc_id, true);
6590
+ return ret && ret.code >= 0 ? ret.data : null;
6591
+ };
6592
+
6593
+ export const favorite_contact = async function (req) {
6594
+ const { uid, contact_id } = req;
6595
+ try {
6596
+ return await _set_contact_favorite(uid, contact_id, true);
6597
+ } catch (err) {
6598
+ return {
6599
+ code: -24,
6600
+ data: err.message,
6601
+ };
6602
+ }
6603
+ };
6604
+
6605
+ export const unfavorite_contact = async function (req) {
6606
+ const { uid, contact_id } = req;
6607
+ try {
6608
+ return await _set_contact_favorite(uid, contact_id, false);
6609
+ } catch (err) {
6610
+ return {
6611
+ code: -24,
6612
+ data: err.message,
6613
+ };
6614
+ }
6615
+ };
6616
+
6539
6617
  export const not_spam_contact = async function (req, job_id, headers) {
6540
6618
  const { contact_id, uid } = req;
6541
6619
  const account_profile_info = await get_active_account_profile_info(uid);
@@ -6840,6 +6918,239 @@ export const get_contact_activity = async function (req) {
6840
6918
  return { code: -25, data: err.message };
6841
6919
  }
6842
6920
  };
6921
+
6922
+ //////// CONTACT ITEM LINKS (UI-216) //////////////
6923
+
6924
+ // What a contact's registered-app tabs are made of. A contact_item says "this invoice / this
6925
+ // ticket / this file belongs to this person", and it is the ONLY thing those tabs read.
6926
+ //
6927
+ // Why a link doc and not a query per module: a contact lives in the account's project db, a
6928
+ // finance document lives in xuda_accounts, a drive file and a ticket live somewhere else again.
6929
+ // Rendering one contact's tabs by asking each module for its own answer means a fan-out across
6930
+ // three databases and seven modules on every contact open, and most of those modules cannot
6931
+ // filter by contact at all yet. So the link carries a SNAPSHOT of what it points at (title,
6932
+ // status, amount, a href), which is what the panel renders. The snapshot is a cache and is
6933
+ // documented as one: Open follows the href to the real record, which is always authoritative.
6934
+ // Refreshing snapshots, and reading native per-module answers alongside them, is phase 2.
6935
+ const MAX_CONTACT_ITEM_ROWS = 2000;
6936
+
6937
+ // Fields of `snapshot` that survive the round trip. Anything else a caller sends is dropped,
6938
+ // so a module cannot quietly turn the link doc into its own storage.
6939
+ const CONTACT_ITEM_SNAPSHOT_FIELDS = ['title', 'subtitle', 'status', 'amount', 'currency', 'ts', 'href', 'kind', 'icon'];
6940
+
6941
+ const clean_contact_item_snapshot = function (snapshot) {
6942
+ const out = {};
6943
+ if (!snapshot || typeof snapshot !== 'object') return out;
6944
+ for (const key of CONTACT_ITEM_SNAPSHOT_FIELDS) {
6945
+ let val = snapshot[key];
6946
+ if (typeof val === 'undefined' || val === null) continue;
6947
+ if (typeof val === 'string') val = val.slice(0, 500);
6948
+ out[key] = val;
6949
+ }
6950
+ return out;
6951
+ };
6952
+
6953
+ // A contact id that came off the wire, checked before anything is written against it. Same
6954
+ // ownership test get_contact_activity uses: a contact created by this account or belonging to
6955
+ // it. get_app_couch_doc_native throws a bare couch 'missing' for an id from another account,
6956
+ // so that has to be caught rather than allowed to surface to the caller as "missing".
6957
+ const load_owned_contact = async function (app_id, uid, contact_id) {
6958
+ let contact_doc;
6959
+ try {
6960
+ contact_doc = await db_module.get_app_couch_doc_native(app_id, contact_id);
6961
+ } catch (_) {
6962
+ contact_doc = null;
6963
+ }
6964
+ if (!contact_doc || contact_doc.docType !== 'contact') throw new Error(`contact ${contact_id} not found`);
6965
+ if (contact_doc.uid !== uid && contact_doc?.uid_created !== uid) throw new Error('Operation not allowed');
6966
+ return contact_doc;
6967
+ };
6968
+
6969
+ export const contact_item_attach = async function (req, job_id, headers) {
6970
+ const { uid, contact_ids, contact_id, app_key, item_id, item_ref, snapshot, note, account_profile_id } = req;
6971
+ try {
6972
+ // "attach items to contact/s": one call, any number of contacts. A single contact_id is
6973
+ // accepted too so a caller holding one does not have to wrap it.
6974
+ const targets = [...new Set((Array.isArray(contact_ids) ? contact_ids : contact_id ? [contact_id] : []).filter(Boolean))];
6975
+ if (!targets.length) throw new Error('contact_ids is missing');
6976
+ if (!app_key) throw new Error('app_key is missing');
6977
+ if (!item_id) throw new Error('item_id is missing');
6978
+
6979
+ const app_id = await get_account_default_project_id(uid);
6980
+
6981
+ // One query answers "already attached?" for every target at once, on the by_item index.
6982
+ const existing_ret = await db_module.find_app_couch_query(app_id, {
6983
+ selector: { docType: 'contact_item', app_key, item_id },
6984
+ limit: MAX_CONTACT_ITEM_ROWS,
6985
+ });
6986
+ const live = (existing_ret?.docs || []).filter((d) => d.stat < 4);
6987
+
6988
+ const created = [];
6989
+ const already = [];
6990
+
6991
+ for (const target of targets) {
6992
+ await load_owned_contact(app_id, uid, target);
6993
+
6994
+ const hit = live.find((d) => d.contact_id === target);
6995
+ if (hit) {
6996
+ already.push({ link_id: hit._id, contact_id: target });
6997
+ continue;
6998
+ }
6999
+
7000
+ const d = Date.now();
7001
+ const doc = {
7002
+ _id: await _common.xuda_get_uuid('contact_item'),
7003
+ docType: 'contact_item',
7004
+ stat: 3,
7005
+ uid,
7006
+ app_key,
7007
+ contact_id: target,
7008
+ account_profile_id: account_profile_id || null,
7009
+ item_id,
7010
+ item_ref: item_ref && typeof item_ref === 'object' ? item_ref : {},
7011
+ snapshot: clean_contact_item_snapshot(snapshot),
7012
+ note: typeof note === 'string' ? note.slice(0, 1000) : '',
7013
+ created_by_uid: uid,
7014
+ date_created_ts: d,
7015
+ ts: d,
7016
+ };
7017
+
7018
+ const save_ret = await db_module.save_app_couch_doc(app_id, doc);
7019
+ if (save_ret.code < 0) throw new Error(`could not attach to ${target}`);
7020
+ created.push({ link_id: doc._id, contact_id: target });
7021
+
7022
+ log_contact_activity(uid, target, 'item_attached', { by: 'user', app_key, item_id, title: doc.snapshot.title }, app_id);
7023
+ }
7024
+
7025
+ return { code: 1, data: { created, existing: already } };
7026
+ } catch (err) {
7027
+ return { code: -26, data: err.message };
7028
+ }
7029
+ };
7030
+
7031
+ export const contact_item_detach = async function (req, job_id, headers) {
7032
+ const { uid, link_id, contact_id, app_key, item_id } = req;
7033
+ try {
7034
+ const app_id = await get_account_default_project_id(uid);
7035
+
7036
+ let doc = null;
7037
+ if (link_id) {
7038
+ try {
7039
+ doc = await db_module.get_app_couch_doc_native(app_id, link_id);
7040
+ } catch (_) {
7041
+ doc = null;
7042
+ }
7043
+ } else {
7044
+ if (!contact_id || !app_key || !item_id) throw new Error('link_id, or contact_id + app_key + item_id, is missing');
7045
+ const ret = await db_module.find_app_couch_query(app_id, {
7046
+ selector: { docType: 'contact_item', app_key, item_id },
7047
+ limit: MAX_CONTACT_ITEM_ROWS,
7048
+ });
7049
+ doc = (ret?.docs || []).find((d) => d.contact_id === contact_id && d.stat < 4) || null;
7050
+ }
7051
+
7052
+ if (!doc || doc.docType !== 'contact_item') throw new Error('link not found');
7053
+ if (doc.uid !== uid) throw new Error('Operation not allowed');
7054
+ // Already detached is the state the caller asked for, so say so rather than failing a
7055
+ // double click.
7056
+ if (doc.stat >= 4) return { code: 1, data: { link_id: doc._id, already_detached: true } };
7057
+
7058
+ doc.stat = 4;
7059
+ doc.stat_ts = Date.now();
7060
+ doc.ts = doc.stat_ts;
7061
+
7062
+ const save_ret = await db_module.save_app_couch_doc(app_id, doc);
7063
+ if (save_ret.code < 0) throw new Error('could not detach');
7064
+
7065
+ log_contact_activity(uid, doc.contact_id, 'item_detached', { by: 'user', app_key: doc.app_key, item_id: doc.item_id, title: doc?.snapshot?.title }, app_id);
7066
+
7067
+ return { code: 1, data: { link_id: doc._id } };
7068
+ } catch (err) {
7069
+ return { code: -26, data: err.message };
7070
+ }
7071
+ };
7072
+
7073
+ export const contact_item_list = async function (req, job_id, headers) {
7074
+ const { uid, contact_id, app_key } = req;
7075
+ try {
7076
+ if (!contact_id) throw new Error('contact_id is missing');
7077
+ const app_id = await get_account_default_project_id(uid);
7078
+ await load_owned_contact(app_id, uid, contact_id);
7079
+
7080
+ // Deliberately NOT paged by app_key. The tab strip needs a count for every app at once, so
7081
+ // one query answers the whole strip and the panel filters out what it needs client side.
7082
+ // Opening a contact therefore costs one call, not one per tab.
7083
+ //
7084
+ // `ts: { $gt: null }` is not a filter, it is what makes Couch use the by_contact index for
7085
+ // the sort: the sort field has to appear in the selector for the index to be a candidate.
7086
+ const ret = await db_module.find_app_couch_query(app_id, {
7087
+ selector: { docType: 'contact_item', contact_id, ts: { $gt: null } },
7088
+ limit: MAX_CONTACT_ITEM_ROWS,
7089
+ sort: [{ ts: 'desc' }],
7090
+ });
7091
+
7092
+ const rows = (ret?.docs || []).filter((d) => d.stat < 4);
7093
+
7094
+ const counts = {};
7095
+ for (const row of rows) counts[row.app_key] = (counts[row.app_key] || 0) + 1;
7096
+
7097
+ return {
7098
+ code: 1,
7099
+ data: {
7100
+ contact_id,
7101
+ rows: app_key ? rows.filter((r) => r.app_key === app_key) : rows,
7102
+ counts,
7103
+ total_docs: rows.length,
7104
+ // The cap is a backstop, not a page. Say when it bit, so a short count is never read
7105
+ // as the truth.
7106
+ truncated: (ret?.docs || []).length >= MAX_CONTACT_ITEM_ROWS,
7107
+ },
7108
+ };
7109
+ } catch (err) {
7110
+ return { code: -26, data: err.message };
7111
+ }
7112
+ };
7113
+
7114
+ // Which contacts an item is attached to, for the attach control that lives on the item.
7115
+ export const contact_item_list_for_item = async function (req, job_id, headers) {
7116
+ const { uid, app_key, item_id } = req;
7117
+ try {
7118
+ if (!app_key) throw new Error('app_key is missing');
7119
+ if (!item_id) throw new Error('item_id is missing');
7120
+ const app_id = await get_account_default_project_id(uid);
7121
+
7122
+ const ret = await db_module.find_app_couch_query(app_id, {
7123
+ selector: { docType: 'contact_item', app_key, item_id },
7124
+ limit: MAX_CONTACT_ITEM_ROWS,
7125
+ });
7126
+
7127
+ const rows = (ret?.docs || []).filter((d) => d.stat < 4 && d.uid === uid);
7128
+
7129
+ // The names come from the contacts themselves, so a renamed contact reads correctly here
7130
+ // even though the link's own snapshot describes the ITEM and never the person.
7131
+ const contacts = [];
7132
+ for (const row of rows) {
7133
+ let contact_doc = null;
7134
+ try {
7135
+ contact_doc = await db_module.get_app_couch_doc_native(app_id, row.contact_id);
7136
+ } catch (_) {
7137
+ contact_doc = null;
7138
+ }
7139
+ contacts.push({
7140
+ link_id: row._id,
7141
+ contact_id: row.contact_id,
7142
+ name: contact_doc?.name || contact_doc?.email || '',
7143
+ email: contact_doc?.email || '',
7144
+ profile_avatar: contact_doc?.profile_avatar || contact_doc?.profile_picture || '',
7145
+ });
7146
+ }
7147
+
7148
+ return { code: 1, data: { app_key, item_id, contacts } };
7149
+ } catch (err) {
7150
+ return { code: -26, data: err.message };
7151
+ }
7152
+ };
7153
+
6843
7154
  //////// PROFILES //////////////
6844
7155
 
6845
7156
  export const get_pending_share_profile_in = async function (uid, _id) {
@@ -7038,6 +7349,12 @@ export const get_account_profiles = async function (req) {
7038
7349
  break;
7039
7350
  }
7040
7351
 
7352
+ // UI-221
7353
+ case 'favorites': {
7354
+ opt.selector.favorite = true;
7355
+ break;
7356
+ }
7357
+
7041
7358
  default:
7042
7359
  break;
7043
7360
  }
@@ -7249,6 +7566,14 @@ export const unarchive_account_profile = async function (req) {
7249
7566
  }
7250
7567
  };
7251
7568
 
7569
+ // UI-216: what a brand new profile works with before anyone opens the Registered apps tab.
7570
+ // The three communication channels, because they are the ones a contact already has data for
7571
+ // (the timeline is full of them) and a CRM with no email tab on a contact is not a CRM. The
7572
+ // dashboard falls back to the same list for the profiles that predate the field, so this is a
7573
+ // default in two places on purpose and the two must agree: see DEFAULT_REGISTERED_APP_KEYS in
7574
+ // dashboard/src/lib/registerableApps.js.
7575
+ const DEFAULT_REGISTERED_APPS = ['email', 'phone', 'sms'];
7576
+
7252
7577
  export const create_account_profile = async function (req, job_id, headers) {
7253
7578
  const { uid, profile_name, profile_signature, email_account_id, profile_picture, profile_avatar, profile_picture_obj, profile_avatar_obj, main, account_type } = req;
7254
7579
  try {
@@ -7272,6 +7597,7 @@ export const create_account_profile = async function (req, job_id, headers) {
7272
7597
  profile_picture_obj,
7273
7598
  profile_avatar_obj,
7274
7599
  main,
7600
+ registered_apps: [...DEFAULT_REGISTERED_APPS],
7275
7601
  };
7276
7602
  const save_ret = await db_module.save_app_couch_doc(app_id, doc);
7277
7603
  // acc_obj.active_profile_id = save_ret.data.id;
@@ -7306,6 +7632,12 @@ export const update_account_profile = async function (req, job_id, headers) {
7306
7632
  'active_agents',
7307
7633
  // Email Template tab: { style: 'plain' | 'card' | 'accent' | 'minimal' }
7308
7634
  'email_template',
7635
+ // UI-216, Registered apps tab: which apps this profile works with, and therefore which
7636
+ // tabs every contact in it gets. Catalog keys (dashboard/src/lib/registerableApps.js),
7637
+ // ordered, because the order is the order of the icon strip under the composer.
7638
+ // Mini apps are studio doc ids and live in their own field so the two can never collide.
7639
+ 'registered_apps',
7640
+ 'registered_mini_apps',
7309
7641
  ];
7310
7642
  try {
7311
7643
  let { data: account_profile_doc } = await db_module.get_app_couch_doc(app_id, _id);
@@ -7323,7 +7655,13 @@ export const update_account_profile = async function (req, job_id, headers) {
7323
7655
  for (const key of account_profile_properties) {
7324
7656
  let val = req[key];
7325
7657
  if (typeof val === 'undefined') continue;
7326
- if (account_profile_doc[key] !== val) {
7658
+ // UI-216: `!==` on an array or an object is ALWAYS true (identity, not value), so every
7659
+ // save of a profile carrying ai_models / auto_respond_agents wrote a revision and an
7660
+ // "updated" activity row whether or not anything moved. registered_apps is a third such
7661
+ // field and it is toggled a lot, so the trail would have become mostly noise. Compare
7662
+ // non-primitives by value; primitives keep the cheap check.
7663
+ const same = val !== null && typeof val === 'object' ? JSON.stringify(account_profile_doc[key]) === JSON.stringify(val) : account_profile_doc[key] === val;
7664
+ if (!same) {
7327
7665
  changes_arr.push(key);
7328
7666
  previous_values[key] = account_profile_doc[key];
7329
7667
  account_profile_doc[key] = val;
package/index_ms.mjs CHANGED
@@ -433,6 +433,14 @@ export const unpin_contact = async function (...args) {
433
433
  return await broker.send_to_queue("unpin_contact", ...args);
434
434
  };
435
435
 
436
+ export const favorite_contact = async function (...args) {
437
+ return await broker.send_to_queue("favorite_contact", ...args);
438
+ };
439
+
440
+ export const unfavorite_contact = async function (...args) {
441
+ return await broker.send_to_queue("unfavorite_contact", ...args);
442
+ };
443
+
436
444
  export const not_spam_contact = async function (...args) {
437
445
  return await broker.send_to_queue("not_spam_contact", ...args);
438
446
  };
@@ -453,6 +461,22 @@ export const get_contact_activity = async function (...args) {
453
461
  return await broker.send_to_queue("get_contact_activity", ...args);
454
462
  };
455
463
 
464
+ export const contact_item_attach = async function (...args) {
465
+ return await broker.send_to_queue("contact_item_attach", ...args);
466
+ };
467
+
468
+ export const contact_item_detach = async function (...args) {
469
+ return await broker.send_to_queue("contact_item_detach", ...args);
470
+ };
471
+
472
+ export const contact_item_list = async function (...args) {
473
+ return await broker.send_to_queue("contact_item_list", ...args);
474
+ };
475
+
476
+ export const contact_item_list_for_item = async function (...args) {
477
+ return await broker.send_to_queue("contact_item_list_for_item", ...args);
478
+ };
479
+
456
480
  export const get_pending_share_profile_in = async function (...args) {
457
481
  return await broker.send_to_queue("get_pending_share_profile_in", ...args);
458
482
  };
package/index_msa.mjs CHANGED
@@ -433,6 +433,14 @@ export const unpin_contact = function (...args) {
433
433
  broker.send_to_queue_async("unpin_contact", ...args);
434
434
  };
435
435
 
436
+ export const favorite_contact = function (...args) {
437
+ broker.send_to_queue_async("favorite_contact", ...args);
438
+ };
439
+
440
+ export const unfavorite_contact = function (...args) {
441
+ broker.send_to_queue_async("unfavorite_contact", ...args);
442
+ };
443
+
436
444
  export const not_spam_contact = function (...args) {
437
445
  broker.send_to_queue_async("not_spam_contact", ...args);
438
446
  };
@@ -453,6 +461,22 @@ export const get_contact_activity = function (...args) {
453
461
  broker.send_to_queue_async("get_contact_activity", ...args);
454
462
  };
455
463
 
464
+ export const contact_item_attach = function (...args) {
465
+ broker.send_to_queue_async("contact_item_attach", ...args);
466
+ };
467
+
468
+ export const contact_item_detach = function (...args) {
469
+ broker.send_to_queue_async("contact_item_detach", ...args);
470
+ };
471
+
472
+ export const contact_item_list = function (...args) {
473
+ broker.send_to_queue_async("contact_item_list", ...args);
474
+ };
475
+
476
+ export const contact_item_list_for_item = function (...args) {
477
+ broker.send_to_queue_async("contact_item_list_for_item", ...args);
478
+ };
479
+
456
480
  export const get_pending_share_profile_in = function (...args) {
457
481
  broker.send_to_queue_async("get_pending_share_profile_in", ...args);
458
482
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xuda.io/account_module",
3
- "version": "1.2.2315",
3
+ "version": "1.2.2316",
4
4
  "description": "Xuda Account Server Module",
5
5
  "main": "index.mjs",
6
6
  "dependencies": {