@xuda.io/account_module 1.2.2316 → 1.2.2317

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
@@ -36,6 +36,11 @@ const account_info_properties = [
36
36
  'active_account_profile_id',
37
37
  'profile_avatar',
38
38
  'profile_avatar_obj',
39
+ // How large the avatar DRAWS, as a multiplier. A display setting rather than
40
+ // anything about the file: the picture window's Smaller / Larger buttons write
41
+ // it and every surface that renders the avatar reads it, so one adjustment
42
+ // moves the face in the card, the menu and the sidebar at once.
43
+ 'profile_avatar_zoom',
39
44
  'is_xuda_network_ambassador',
40
45
  'network_profile_url',
41
46
  'network_lang',
@@ -172,10 +177,24 @@ const can_generate_avatar = (account_obj) => account_obj?.stat !== 1 && Number(a
172
177
  // That is what makes this safe to evaluate on every save: replacing the photo
173
178
  // makes an avatar due exactly once, and nothing the client writes into
174
179
  // profile_avatar can make it due a second time.
180
+ // How long a generation may claim to be running before the claim is treated as
181
+ // abandoned. A real one takes seconds, and the worker cannot clear its own stat
182
+ // when the process dies under it (a deploy, an OOM, a restart), so without an
183
+ // expiry the flag is permanent. The same five minutes the contact avatar states
184
+ // already use for exactly this.
185
+ const AVATAR_STAT_STALE_MS = 1000 * 60 * 5;
186
+ const avatar_is_running = (info) => info?.profile_avatar_stat === 2 && !!info.profile_avatar_stat_ts && Date.now() - info.profile_avatar_stat_ts < AVATAR_STAT_STALE_MS;
187
+
175
188
  const avatar_is_due = (account_info) => {
176
189
  const info = account_info || {};
177
190
  if (!info.profile_picture) return false;
178
- if (info.profile_avatar_stat === 2) return false; // one is already generating
191
+ // One is already generating. Only counts while it is plausibly still alive:
192
+ // a stat 2 that has not moved in five minutes is a run that died, and
193
+ // refusing on its behalf wedges the account permanently. Found on a real
194
+ // account 2026-08-13, stuck at stat 2 for three days, where every request for
195
+ // an avatar (the server's own trigger AND the picture window) was declined by
196
+ // this line on behalf of a run that ended days earlier.
197
+ if (avatar_is_running(info)) return false;
179
198
  return info.profile_avatar_from !== info.profile_picture;
180
199
  };
181
200
 
@@ -455,14 +474,39 @@ export const update_account_info = async function (req, job_id, headers) {
455
474
  return save_ret;
456
475
  };
457
476
  export const update_account_preferences = async function (req) {
458
- const { uid, hide_create_button, default_dashboard, sidebar_style, enable_thumbnail_avatar_generation, sidenav_mode, newsletter_opt_in, dashboard_sort } = req;
477
+ const { uid, hide_create_button, default_dashboard, sidebar_style, enable_thumbnail_avatar_generation, sidenav_mode, newsletter_opt_in, dashboard_sort, locale } = req;
459
478
  try {
460
479
  let account_doc = await db_module.get_couch_doc_native('xuda_accounts', uid);
461
480
  account_doc.ts = Date.now();
462
481
 
463
482
  // Merge over the existing prefs so a save from a screen that doesn't
464
483
  // know about newer keys (e.g. sidenav_mode) can't silently wipe them.
465
- account_doc.preferences = { ...(account_doc.preferences || {}), hide_create_button, default_dashboard, sidebar_style, enable_thumbnail_avatar_generation };
484
+ //
485
+ // UI-234: these four USED to be written unconditionally, which made the merge above
486
+ // a lie for exactly the keys it was supposed to protect: a caller sending
487
+ // one key (the newsletter switch, the onboarding checkbox, the dashboard
488
+ // sort) also sent `undefined` for the other four, and undefined does not
489
+ // survive JSON, so the keys were dropped from the saved doc. Toggling the
490
+ // newsletter really did reset hide_create_button, default_dashboard,
491
+ // sidebar_style and enable_thumbnail_avatar_generation. Every key is
492
+ // surgical now, on the same `!== undefined` rule as the ones below.
493
+ account_doc.preferences = { ...(account_doc.preferences || {}) };
494
+ if (hide_create_button !== undefined) account_doc.preferences.hide_create_button = hide_create_button;
495
+ if (default_dashboard !== undefined) account_doc.preferences.default_dashboard = default_dashboard;
496
+ if (sidebar_style !== undefined) account_doc.preferences.sidebar_style = sidebar_style;
497
+ if (enable_thumbnail_avatar_generation !== undefined) account_doc.preferences.enable_thumbnail_avatar_generation = enable_thumbnail_avatar_generation;
498
+
499
+ // Display language, as a BCP-47 style tag ('en', 'he', 'ar', 'pt-BR').
500
+ // Validated on SHAPE only, deliberately: the list of languages the product
501
+ // actually speaks lives in shared/i18n/locales.js and the client resolves an
502
+ // unknown tag down to English there. Checking the list here as well would
503
+ // mean redeploying this module every time a language is added, to reject a
504
+ // value the client already handles. Sending null clears it back to the
505
+ // browser's own choice.
506
+ if (locale !== undefined) {
507
+ if (locale === null || locale === '') delete account_doc.preferences.locale;
508
+ else if (typeof locale === 'string' && /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})?$/.test(locale)) account_doc.preferences.locale = locale;
509
+ }
466
510
  // 'static' (curated groups, today's behavior) | 'recent' (auto-reorder
467
511
  // by last activity). Only touched when the caller sends it.
468
512
  if (sidenav_mode !== undefined) account_doc.preferences.sidenav_mode = sidenav_mode;
@@ -791,6 +835,13 @@ const _MODULE_SUBSCRIPTIONS = [
791
835
  // today and owes nothing new. Judged on price, so Free reads as not active, because
792
836
  // here that is the truth: Free adds nothing and holds no line on the invoice.
793
837
  { key: 'drive', scope: 'account', field: 'drive_plan', default_plan: 'drive_free' },
838
+ // UI-228: Xudex is sold on its own xudex_* ladder, and unlike every other row here it
839
+ // is not available at all below a membership floor (Pro), because a product that runs
840
+ // arbitrary customer code is the classic mining target and identity is the cheapest
841
+ // defence against that. The floor is NOT enforced here on purpose: this array answers
842
+ // "what is on the invoice", and the entitlement question belongs in one place, next to
843
+ // the thing that hands out a machine (deploy_module `_xudex_entitlement`).
844
+ { key: 'xudex', scope: 'account', field: 'xudex_plan', default_plan: 'xudex_free', is_activated: (a) => !!a.xudex_plan },
794
845
  ];
795
846
 
796
847
  // app_type -> the row it belongs under. An app type that is not here is not a
@@ -5998,6 +6049,77 @@ export const add_contact = async function (req, job_id, headers) {
5998
6049
  }
5999
6050
  };
6000
6051
 
6052
+ /**
6053
+ * File a contact that was TYPED rather than received.
6054
+ *
6055
+ * Every other path into the contact book starts from an envelope — a mail
6056
+ * sync, the profile contact form, an accepted friend request — and reads the
6057
+ * person off it. This one starts from a person, so it takes the four fields a
6058
+ * contact actually has and nothing else. A caller holding more than that (the
6059
+ * shipping form holds a whole street address, coordinates included) drops the
6060
+ * rest here: a delivery address belongs to a shipment, not to a person.
6061
+ *
6062
+ * Deliberately a narrow front door onto add_contact rather than exposing that
6063
+ * method to the browser. http_module validates the declared fields but forwards
6064
+ * the request body WHOLE, so an exposed add_contact would also hand a browser
6065
+ * caller contact_uid (links the contact to any account, and fetches that
6066
+ * account's doc), stat (including 4, deleted) and team_req_id. None of those
6067
+ * are reachable through here.
6068
+ */
6069
+ export const create_contact = async function (req, job_id, headers) {
6070
+ try {
6071
+ const { uid, profile_id } = req;
6072
+ if (!uid) return { code: -1, data: 'uid missing' };
6073
+
6074
+ // Same limits as the profile contact form, so a contact looks the same
6075
+ // whether it walked in through a website or was typed into the dashboard.
6076
+ const name = typeof req.name === 'string' ? req.name.trim().slice(0, 120) : '';
6077
+ const email = typeof req.email === 'string' ? req.email.trim().toLowerCase().slice(0, 200) : '';
6078
+ const phone = typeof req.phone === 'string' ? req.phone.trim().slice(0, 40) : '';
6079
+ const company = typeof req.company === 'string' ? req.company.trim().slice(0, 120) : '';
6080
+
6081
+ if (!name) return { code: -1, data: 'A name is needed to create a contact.' };
6082
+ // The book is keyed by email: it is the duplicate check, and the only thing
6083
+ // that can match a later message back to this person. Without one there is
6084
+ // nothing to file.
6085
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return { code: -1, data: 'A valid email address is needed to create a contact.' };
6086
+
6087
+ const ret = await add_contact(
6088
+ {
6089
+ uid,
6090
+ profile_id,
6091
+ name,
6092
+ email,
6093
+ source: typeof req.source === 'string' ? req.source.trim().slice(0, 60) || 'manual' : 'manual',
6094
+ context: typeof req.context === 'string' ? req.context.trim().slice(0, 200) : 'Added by hand',
6095
+ metadata: {
6096
+ // The two fields a contact doc has no column of its own for. This is
6097
+ // where contact_form_submit puts them too, so the contact card and
6098
+ // every reader downstream already know to look here.
6099
+ phone,
6100
+ company,
6101
+ // Skips the spam classifier: the account holder typed this person in
6102
+ // themselves, which is the strongest not-junk signal there is.
6103
+ not_junk: true,
6104
+ },
6105
+ },
6106
+ job_id,
6107
+ headers,
6108
+ );
6109
+
6110
+ // add_contact answers a duplicate with code -1 and the id of the contact
6111
+ // that already exists. To the caller that is not a failure — the person is
6112
+ // in the book, which is what they asked for — so report it as a success
6113
+ // that says which one it was.
6114
+ if (ret?.contact_id) return { code: 1, data: { contact_id: ret.contact_id, name, email, existed: true } };
6115
+ if (!(ret?.code > -1)) return { code: -1, data: typeof ret?.data === 'string' ? ret.data : 'Could not create the contact.' };
6116
+
6117
+ return { code: 1, data: { contact_id: ret.data?.id || ret.data?._id || null, name, email, existed: false } };
6118
+ } catch (err) {
6119
+ return { code: -1, data: err.message };
6120
+ }
6121
+ };
6122
+
6001
6123
  const set_contact_profile_picture = async function (uid, contact_id, metadata, job_id, headers, account_profile_info, create_avatar) {
6002
6124
  let contact_obj;
6003
6125
  await update_contact_profile_picture_status(uid, contact_id, 1);
package/index_ms.mjs CHANGED
@@ -401,6 +401,10 @@ export const add_contact = async function (...args) {
401
401
  return await broker.send_to_queue("add_contact", ...args);
402
402
  };
403
403
 
404
+ export const create_contact = async function (...args) {
405
+ return await broker.send_to_queue("create_contact", ...args);
406
+ };
407
+
404
408
  export const get_contacts = async function (...args) {
405
409
  return await broker.send_to_queue("get_contacts", ...args);
406
410
  };
package/index_msa.mjs CHANGED
@@ -401,6 +401,10 @@ export const add_contact = function (...args) {
401
401
  broker.send_to_queue_async("add_contact", ...args);
402
402
  };
403
403
 
404
+ export const create_contact = function (...args) {
405
+ broker.send_to_queue_async("create_contact", ...args);
406
+ };
407
+
404
408
  export const get_contacts = function (...args) {
405
409
  broker.send_to_queue_async("get_contacts", ...args);
406
410
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xuda.io/account_module",
3
- "version": "1.2.2316",
3
+ "version": "1.2.2317",
4
4
  "description": "Xuda Account Server Module",
5
5
  "main": "index.mjs",
6
6
  "dependencies": {