@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 +581 -41
- package/index_ms.mjs +8 -0
- package/index_msa.mjs +8 -0
- package/package.json +1 -1
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.
|
|
148
|
-
//
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
3649
|
-
//
|
|
3650
|
-
//
|
|
3651
|
-
//
|
|
3652
|
-
//
|
|
3653
|
-
//
|
|
3654
|
-
//
|
|
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
|
-
//
|
|
4315
|
-
//
|
|
4316
|
-
//
|
|
4317
|
-
//
|
|
4318
|
-
//
|
|
4319
|
-
//
|
|
4320
|
-
//
|
|
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 =
|
|
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 (
|
|
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
|
-
//
|
|
5515
|
-
//
|
|
5516
|
-
//
|
|
5517
|
-
//
|
|
5518
|
-
//
|
|
5519
|
-
//
|
|
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 (
|
|
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
|
};
|