@xuda.io/account_module 1.2.2316 → 1.2.2318
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 +125 -3
- package/index_ms.mjs +4 -0
- package/index_msa.mjs +4 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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: Xucode is sold on its own xucode_* 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 `_xucode_entitlement`).
|
|
844
|
+
{ key: 'xucode', scope: 'account', field: 'xucode_plan', default_plan: 'xucode_free', is_activated: (a) => !!a.xucode_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
|
};
|