@xuda.io/account_module 1.2.2312 → 1.2.2313
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 +376 -37
- 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
|
|
@@ -3410,6 +3474,20 @@ export const did_you_know_tips = async function (req, job_id, headers) {
|
|
|
3410
3474
|
|
|
3411
3475
|
const _warned_no_account_project_id = new Set();
|
|
3412
3476
|
const _warned_no_profile_id = new Set();
|
|
3477
|
+
const _warned_dangling_profile_id = new Set();
|
|
3478
|
+
|
|
3479
|
+
// Resolve a profile doc that is supposed to live in this account's own project db, without
|
|
3480
|
+
// throwing when it does not. A dangling id is not an exceptional condition here: it happens
|
|
3481
|
+
// whenever a profile was deleted, or when an id belonging to ANOTHER account leaked into
|
|
3482
|
+
// account_info.active_account_profile_id (update_account_info used to accept any id the client
|
|
3483
|
+
// sent). Returning null lets the caller fall back instead of hard-failing every read.
|
|
3484
|
+
const _try_get_profile_doc = async function (app_id, doc_id) {
|
|
3485
|
+
if (!app_id || !doc_id) return null;
|
|
3486
|
+
const ret = await db_module.get_app_couch_doc(app_id, doc_id, true);
|
|
3487
|
+
if (ret.code < 0) return null;
|
|
3488
|
+
const doc = ret.data;
|
|
3489
|
+
return doc && doc.docType === 'account_profile' ? doc : null;
|
|
3490
|
+
};
|
|
3413
3491
|
|
|
3414
3492
|
export const get_active_account_profile_info = async function (uid, profile_id) {
|
|
3415
3493
|
try {
|
|
@@ -3468,7 +3546,65 @@ export const get_active_account_profile_info = async function (uid, profile_id)
|
|
|
3468
3546
|
return { uid, account_profile_id: null, app_id: acc_obj.account_project_id, is_main: false, account_profile_obj: null };
|
|
3469
3547
|
}
|
|
3470
3548
|
|
|
3471
|
-
|
|
3549
|
+
let account_profile_obj = await _try_get_profile_doc(acc_obj.account_project_id, active_account_profile_id);
|
|
3550
|
+
|
|
3551
|
+
if (!account_profile_obj) {
|
|
3552
|
+
// The stored id points at nothing in this account's project db (deleted profile, or a
|
|
3553
|
+
// foreign id written by an older, unvalidated update_account_info). Previously this threw
|
|
3554
|
+
// "missing" and every caller downstream, i.e. the whole AI workspace, died with it. Walk the
|
|
3555
|
+
// rest of the fallback chain instead and repair the account doc so the next read is clean.
|
|
3556
|
+
const candidates = [acc_obj.account_info?.active_account_profile_id, acc_obj.account_profile_id].filter((id) => id && id !== active_account_profile_id);
|
|
3557
|
+
|
|
3558
|
+
for (const candidate of candidates) {
|
|
3559
|
+
account_profile_obj = await _try_get_profile_doc(acc_obj.account_project_id, candidate);
|
|
3560
|
+
if (account_profile_obj) {
|
|
3561
|
+
active_account_profile_id = candidate;
|
|
3562
|
+
break;
|
|
3563
|
+
}
|
|
3564
|
+
}
|
|
3565
|
+
|
|
3566
|
+
if (!account_profile_obj) {
|
|
3567
|
+
// Nothing on the account doc resolves. Adopt this account's own main (or newest) profile.
|
|
3568
|
+
try {
|
|
3569
|
+
const existing = await db_module.find_app_couch_query(acc_obj.account_project_id, {
|
|
3570
|
+
selector: { docType: 'account_profile', uid },
|
|
3571
|
+
limit: 50,
|
|
3572
|
+
});
|
|
3573
|
+
const docs = (existing && existing.docs) || [];
|
|
3574
|
+
const chosen = docs.find((d) => d.main) || docs.slice().sort((a, b) => (b.date_created_ts || 0) - (a.date_created_ts || 0))[0];
|
|
3575
|
+
if (chosen) {
|
|
3576
|
+
account_profile_obj = chosen;
|
|
3577
|
+
active_account_profile_id = chosen._id;
|
|
3578
|
+
}
|
|
3579
|
+
} catch (find_err) {
|
|
3580
|
+
/* fall through to the soft-fail below */
|
|
3581
|
+
}
|
|
3582
|
+
}
|
|
3583
|
+
|
|
3584
|
+
if (!_warned_dangling_profile_id.has(acc_obj._id)) {
|
|
3585
|
+
_warned_dangling_profile_id.add(acc_obj._id);
|
|
3586
|
+
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'}`);
|
|
3587
|
+
}
|
|
3588
|
+
|
|
3589
|
+
if (!account_profile_obj) {
|
|
3590
|
+
return { uid, account_profile_id: null, app_id: acc_obj.account_project_id, is_main: false, account_profile_obj: null };
|
|
3591
|
+
}
|
|
3592
|
+
|
|
3593
|
+
// Persist the repair only when the bad id came from the account's own stored state. A bad
|
|
3594
|
+
// explicit profile_id argument is the caller's problem, not something to write back.
|
|
3595
|
+
if (!profile_id && acc_obj.account_info?.active_account_profile_id !== active_account_profile_id) {
|
|
3596
|
+
try {
|
|
3597
|
+
acc_obj.account_info = acc_obj.account_info || {};
|
|
3598
|
+
acc_obj.account_info.active_account_profile_id = active_account_profile_id;
|
|
3599
|
+
if (!acc_obj.account_profile_id) acc_obj.account_profile_id = active_account_profile_id;
|
|
3600
|
+
await db_module.save_couch_doc('xuda_accounts', acc_obj); // conflict-safe (retry loop)
|
|
3601
|
+
console.log(`[get_active_account_profile_info] self-healed acc ${acc_obj._id}: active profile reset to ${active_account_profile_id}`);
|
|
3602
|
+
} catch (heal_err) {
|
|
3603
|
+
/* the in-memory fallback above still serves this request */
|
|
3604
|
+
}
|
|
3605
|
+
}
|
|
3606
|
+
}
|
|
3607
|
+
|
|
3472
3608
|
if (account_profile_obj.share_item_id) {
|
|
3473
3609
|
// set the original profile id if shared
|
|
3474
3610
|
active_account_profile_id = account_profile_obj.share_item_id;
|
|
@@ -3645,13 +3781,14 @@ export const verify_account = async function (req) {
|
|
|
3645
3781
|
console.warn('[verify_account] confirm_email banner cleanup failed:', e.message);
|
|
3646
3782
|
}
|
|
3647
3783
|
|
|
3648
|
-
//
|
|
3649
|
-
//
|
|
3650
|
-
//
|
|
3651
|
-
//
|
|
3652
|
-
//
|
|
3653
|
-
//
|
|
3654
|
-
//
|
|
3784
|
+
// Confirming an email no longer earns an avatar on its own — level 2 does,
|
|
3785
|
+
// and verify_module starts the generation when the account crosses it. This
|
|
3786
|
+
// call stays because it costs nothing and closes the one ordering it would
|
|
3787
|
+
// otherwise miss: an account that already holds level 2 while still sitting at
|
|
3788
|
+
// stat 1, where can_generate_avatar refused up to the save above. For everyone
|
|
3789
|
+
// else ensure_profile_avatar re-reads the doc, finds the level bar unmet, and
|
|
3790
|
+
// returns. Fire-and-forget either way: generation is heavy (vision + image
|
|
3791
|
+
// ops) and must not hold up the verify response.
|
|
3655
3792
|
try {
|
|
3656
3793
|
ensure_profile_avatar({ uid: account_id }).catch((e) => console.warn('[verify_account] deferred avatar start failed:', e.message));
|
|
3657
3794
|
} catch (e) {
|
|
@@ -4311,15 +4448,23 @@ const level_pattern = function (doc) {
|
|
|
4311
4448
|
return `level-${level}-pattern.png`;
|
|
4312
4449
|
};
|
|
4313
4450
|
|
|
4314
|
-
//
|
|
4315
|
-
//
|
|
4316
|
-
//
|
|
4317
|
-
//
|
|
4318
|
-
//
|
|
4319
|
-
//
|
|
4320
|
-
//
|
|
4451
|
+
// Where a contact comes from decides which language its tile speaks, and the two
|
|
4452
|
+
// are not interchangeable.
|
|
4453
|
+
//
|
|
4454
|
+
// A contact with a `contact_uid` is a real Xuda account, so its tile is that
|
|
4455
|
+
// account's verification level. This used to be computed and then thrown away by
|
|
4456
|
+
// a switch on `avatar_source` that returned the generic xu tile, which is why a
|
|
4457
|
+
// card for a verified person showed no level at all.
|
|
4458
|
+
//
|
|
4459
|
+
// A contact with no account behind it has no level and must never be given one,
|
|
4460
|
+
// not even L0: nobody scored that person, and a card that says otherwise is a
|
|
4461
|
+
// claim we cannot back. Those keep the tile for where they walked in from, the
|
|
4462
|
+
// mailbox, the web, or an incoming call.
|
|
4463
|
+
//
|
|
4464
|
+
// Ahead of both, the states that mean the card cannot show either: a shared card
|
|
4465
|
+
// wears its owner's avatar, an avatar mid-processing says so, spam says spam.
|
|
4321
4466
|
const get_contact_pattern = async function (doc) {
|
|
4322
|
-
let ret =
|
|
4467
|
+
let ret = `default-pattern.png`;
|
|
4323
4468
|
|
|
4324
4469
|
if (doc?.shared_from_uid) {
|
|
4325
4470
|
const shared_from_uid_ret = await get_account_name({ uid_query: doc.shared_from_uid });
|
|
@@ -4338,12 +4483,34 @@ const get_contact_pattern = async function (doc) {
|
|
|
4338
4483
|
}
|
|
4339
4484
|
} else if (doc.is_spam) {
|
|
4340
4485
|
ret = `spam-pattern.png`;
|
|
4486
|
+
} else if (doc.contact_uid) {
|
|
4487
|
+
ret = level_pattern(doc);
|
|
4488
|
+
} else {
|
|
4489
|
+
switch (doc.source) {
|
|
4490
|
+
case 'read emails': {
|
|
4491
|
+
ret = `email-pattern.png`;
|
|
4492
|
+
break;
|
|
4493
|
+
}
|
|
4494
|
+
// Every way a stranger reaches us through a web surface reads as web: the
|
|
4495
|
+
// chat widget, a contact form, and the older plain 'web'. They are the
|
|
4496
|
+
// same story to whoever is looking at the card.
|
|
4497
|
+
case 'web':
|
|
4498
|
+
case 'widget':
|
|
4499
|
+
case 'contact_form': {
|
|
4500
|
+
ret = `web-pattern.png`;
|
|
4501
|
+
break;
|
|
4502
|
+
}
|
|
4503
|
+
// A contact the phone system created for an unknown caller (voice_module
|
|
4504
|
+
// writes this when an inbound call comes from a number we do not hold).
|
|
4505
|
+
case 'inbound_call': {
|
|
4506
|
+
ret = `phone-pattern.png`;
|
|
4507
|
+
break;
|
|
4508
|
+
}
|
|
4509
|
+
|
|
4510
|
+
default:
|
|
4511
|
+
break;
|
|
4512
|
+
}
|
|
4341
4513
|
}
|
|
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
4514
|
return ret;
|
|
4348
4515
|
};
|
|
4349
4516
|
|
|
@@ -5421,6 +5588,16 @@ export const ts_contact = async function (uid, contact_id) {
|
|
|
5421
5588
|
};
|
|
5422
5589
|
|
|
5423
5590
|
const set_account_profile_picture = async function (uid, account_uid, metadata, job_id, headers, account_profile_info) {
|
|
5591
|
+
// Normalised HERE rather than at each call site because every background
|
|
5592
|
+
// trigger — verify_account, the L2 crossing, the widget signup — has no
|
|
5593
|
+
// request to take headers from, and an omitted argument arrives as null over
|
|
5594
|
+
// the queue rather than undefined, so a default parameter would not catch it.
|
|
5595
|
+
// drive_module reads headers['cf-connecting-ip'] unguarded when it records an
|
|
5596
|
+
// upload, so a null threw there and the whole generation was lost. It failed
|
|
5597
|
+
// quietly: the throw was caught below and written to profile_avatar_error,
|
|
5598
|
+
// which nothing displays, leaving accounts sitting at stat 1 with no avatar
|
|
5599
|
+
// and no visible reason.
|
|
5600
|
+
headers = headers || {};
|
|
5424
5601
|
await update_account_profile_picture_status(account_uid, 1);
|
|
5425
5602
|
try {
|
|
5426
5603
|
let profile_picture;
|
|
@@ -5451,7 +5628,7 @@ const set_account_profile_picture = async function (uid, account_uid, metadata,
|
|
|
5451
5628
|
profile_picture = account_info.profile_picture;
|
|
5452
5629
|
}
|
|
5453
5630
|
|
|
5454
|
-
if (
|
|
5631
|
+
if (avatar_is_due(account_info)) {
|
|
5455
5632
|
let business_size;
|
|
5456
5633
|
|
|
5457
5634
|
switch (account_obj.membership_plan) {
|
|
@@ -5499,6 +5676,28 @@ const set_account_profile_picture = async function (uid, account_uid, metadata,
|
|
|
5499
5676
|
if (!_.isObject(file_ret.data)) throw new Error('file_ret not an object');
|
|
5500
5677
|
account_info.profile_avatar_obj = file_ret.data;
|
|
5501
5678
|
account_info.profile_avatar = account_info.profile_avatar_obj.file_url;
|
|
5679
|
+
// Which photo this avatar was made from. Saved in the same write as the
|
|
5680
|
+
// avatar so the two can never disagree, and read back by avatar_is_due —
|
|
5681
|
+
// it is what stops a generation repeating and what makes the next photo
|
|
5682
|
+
// change ask for a new one.
|
|
5683
|
+
account_info.profile_avatar_from = account_info.profile_picture;
|
|
5684
|
+
// WHICH of the two things ai_module produced. get_profile_avatar makes an
|
|
5685
|
+
// 'authentic profile' — the person's own face, restored if needed,
|
|
5686
|
+
// background removed, centred on the detected face box — when the photo
|
|
5687
|
+
// can carry one, and silently falls back to a 'fictional' likeness
|
|
5688
|
+
// generated from the account's metadata when it cannot.
|
|
5689
|
+
//
|
|
5690
|
+
// That distinction is the whole point of holding this behind level 2: the
|
|
5691
|
+
// avatar is meant to BE the verified person, and an invented face
|
|
5692
|
+
// presented as theirs would say something untrue about an account that
|
|
5693
|
+
// has just proved who it belongs to. The picture window reads this and
|
|
5694
|
+
// says so rather than letting the substitution pass unremarked.
|
|
5695
|
+
//
|
|
5696
|
+
// Nothing had ever written avatar_source on the ACCOUNT path, though the
|
|
5697
|
+
// contact and profile builders set it and every card builder copies
|
|
5698
|
+
// account_info.avatar_source onto the doc it makes — so they were all
|
|
5699
|
+
// copying undefined.
|
|
5700
|
+
account_info.avatar_source = file_ret.data.avatar_source;
|
|
5502
5701
|
|
|
5503
5702
|
const account_save_ret = await db_module.save_couch_doc('xuda_accounts', account_obj);
|
|
5504
5703
|
await update_account_profile_picture_status(account_uid, 3);
|
|
@@ -5511,12 +5710,18 @@ const set_account_profile_picture = async function (uid, account_uid, metadata,
|
|
|
5511
5710
|
}
|
|
5512
5711
|
};
|
|
5513
5712
|
|
|
5514
|
-
//
|
|
5515
|
-
//
|
|
5516
|
-
//
|
|
5517
|
-
//
|
|
5518
|
-
//
|
|
5519
|
-
//
|
|
5713
|
+
// The one internal entry point for "start this account's avatar if one is
|
|
5714
|
+
// owed". Called by the widget Google-signup flow (a fresh visitor arriving with
|
|
5715
|
+
// their Google photo) and by verify_module the moment an account crosses into
|
|
5716
|
+
// level 2, which is the trigger that matters: reaching level 2 is what earns the
|
|
5717
|
+
// avatar, and it can be reached without anyone touching this account's profile.
|
|
5718
|
+
//
|
|
5719
|
+
// Every rule lives in the two helpers rather than here, so a caller cannot get
|
|
5720
|
+
// an avatar for an account that has not earned one by picking this door:
|
|
5721
|
+
// can_generate_avatar holds the level bar, avatar_is_due holds "not already
|
|
5722
|
+
// made from this photo, and not already running". A caller with nothing owing
|
|
5723
|
+
// is a cheap no-op. set_account_profile_picture maintains profile_avatar_stat
|
|
5724
|
+
// (1 → 2 → 3), which is what the dashboard polls.
|
|
5520
5725
|
export const ensure_profile_avatar = async function (req, job_id, headers) {
|
|
5521
5726
|
try {
|
|
5522
5727
|
const { uid } = req || {};
|
|
@@ -5524,7 +5729,7 @@ export const ensure_profile_avatar = async function (req, job_id, headers) {
|
|
|
5524
5729
|
const { code, data: account_obj } = await db_module.get_couch_doc('xuda_accounts', uid);
|
|
5525
5730
|
if (code < 0 || !account_obj) return { code: -1, data: 'account not found' };
|
|
5526
5731
|
const info = account_obj.account_info || {};
|
|
5527
|
-
if (
|
|
5732
|
+
if (can_generate_avatar(account_obj) && avatar_is_due(info)) {
|
|
5528
5733
|
// Best-effort profile context for AI-usage attribution. A freshly-created
|
|
5529
5734
|
// widget visitor may have no profile/project yet — tolerate that.
|
|
5530
5735
|
let account_profile_info;
|
|
@@ -7503,6 +7708,96 @@ const _current_credit_period = function (account_doc) {
|
|
|
7503
7708
|
|
|
7504
7709
|
// Single source of truth for "how many credits does this account have left".
|
|
7505
7710
|
// Everything that gates, meters or displays credits must go through here.
|
|
7711
|
+
// The membership plans SELL a monthly AI allowance, but the only thing that has
|
|
7712
|
+
// ever minted it into the ledger is the Stripe invoice.paid webhook
|
|
7713
|
+
// (stripe_module `_grant_period_credits_for_invoice`). Miss that webhook once
|
|
7714
|
+
// and there is no second chance: `archive_expire_ai_credits` retires the last
|
|
7715
|
+
// grant at its 30-day mark, the balance silently falls back to the free daily
|
|
7716
|
+
// drip, and the account is hard-blocked by the gate while the plans page and
|
|
7717
|
+
// `get_effective_entitlements` both still promise the full allowance.
|
|
7718
|
+
//
|
|
7719
|
+
// That is not hypothetical. A Team account on dev sat at 1,600 granted until
|
|
7720
|
+
// 2026-08-11 09:00 UTC, when the cron expired a grant written on 2026-07-12.
|
|
7721
|
+
// Nothing replaced it, so the account dropped to 31 credits (30 daily drips
|
|
7722
|
+
// plus a friendship bonus) against 219 already spent, and every AI call was
|
|
7723
|
+
// refused. The old safety net for this, add_ai_credits_to_active_accounts, is
|
|
7724
|
+
// commented out (see the call site above).
|
|
7725
|
+
//
|
|
7726
|
+
// So the allowance heals itself: a paid membership with no live membership
|
|
7727
|
+
// grant gets THIS period's allowance minted here. Minting twice is what would
|
|
7728
|
+
// actually cost money, so three independent guards stand in the way: a live
|
|
7729
|
+
// grant from any membership plan wins, the account carries a per-period marker,
|
|
7730
|
+
// and record_ai_credit refuses a duplicate (credited_uid, details) outright.
|
|
7731
|
+
// The grant carries period_end_ts, so the expiry cron leaves it alone until the
|
|
7732
|
+
// period it belongs to is genuinely over.
|
|
7733
|
+
const _membership_plan_of = function (account_doc) {
|
|
7734
|
+
const plan = _conf.PLAN_OBJ?.[account_doc?.membership_plan];
|
|
7735
|
+
if (!plan || plan.category !== 'membership') return null;
|
|
7736
|
+
// The free tier's allowance is the daily drip, which already writes its own
|
|
7737
|
+
// ledger docs. Minting on top of it would hand every free account a second
|
|
7738
|
+
// month's worth.
|
|
7739
|
+
if (!(Number(plan.price) > 0)) return null;
|
|
7740
|
+
const credits = Number(plan.features?.ai_credits);
|
|
7741
|
+
if (!Number.isFinite(credits) || credits <= 0) return null;
|
|
7742
|
+
return { plan, credits };
|
|
7743
|
+
};
|
|
7744
|
+
|
|
7745
|
+
const _self_heal_period_grant = async function (uid, doc, period, by_source) {
|
|
7746
|
+
try {
|
|
7747
|
+
if (doc?.stat !== 3) return 0;
|
|
7748
|
+
// Past due is exactly when an allowance should NOT be minted: the webhook
|
|
7749
|
+
// only ever granted on a PAID invoice.
|
|
7750
|
+
if (doc?.account_billing_hold_status) return 0;
|
|
7751
|
+
|
|
7752
|
+
const found = _membership_plan_of(doc);
|
|
7753
|
+
if (!found) return 0;
|
|
7754
|
+
|
|
7755
|
+
// A live grant from any membership plan means the normal path worked (or a
|
|
7756
|
+
// downgrade left the old one running). Either way this period is covered,
|
|
7757
|
+
// and a plan change must not be a way to mint a second allowance.
|
|
7758
|
+
for (const [source, amount] of Object.entries(by_source || {})) {
|
|
7759
|
+
if (!(amount > 0)) continue;
|
|
7760
|
+
const p = _conf.PLAN_OBJ?.[source];
|
|
7761
|
+
if (p && p.category === 'membership') return 0;
|
|
7762
|
+
}
|
|
7763
|
+
|
|
7764
|
+
// Already healed this period. A grant retired mid-period (an expiry, a
|
|
7765
|
+
// roll) must not come back, so this is checked before minting, not after.
|
|
7766
|
+
if (Number(doc.credits_selfheal_period_ts) === period.start) return 0;
|
|
7767
|
+
|
|
7768
|
+
const day = new Date(period.start).toISOString().slice(0, 10);
|
|
7769
|
+
const ret = await record_ai_credit('system', found.credits, found.plan.id, `plan allowance ${found.plan.id} ${day}`, uid, {
|
|
7770
|
+
kind: CREDIT_KIND.PERIOD,
|
|
7771
|
+
period_start_ts: period.start,
|
|
7772
|
+
period_end_ts: period.end,
|
|
7773
|
+
});
|
|
7774
|
+
if (!(ret?.code > -1)) return 0;
|
|
7775
|
+
|
|
7776
|
+
// Fresh read for the write: the caller's copy of the account doc may be
|
|
7777
|
+
// held across a long request, and this marker must not ride on top of
|
|
7778
|
+
// whatever else it has since changed.
|
|
7779
|
+
try {
|
|
7780
|
+
const fresh = await db_module.get_couch_doc_native('xuda_accounts', uid);
|
|
7781
|
+
if (fresh) {
|
|
7782
|
+
fresh.credits_selfheal_period_ts = period.start;
|
|
7783
|
+
await db_module.save_couch_doc('xuda_accounts', fresh);
|
|
7784
|
+
}
|
|
7785
|
+
} catch (e) {
|
|
7786
|
+
// The marker is an optimisation, not the guard: record_ai_credit's own
|
|
7787
|
+
// duplicate check still makes a second mint impossible.
|
|
7788
|
+
console.error('[credit selfheal marker]', e?.message || e);
|
|
7789
|
+
}
|
|
7790
|
+
if (doc && typeof doc === 'object') doc.credits_selfheal_period_ts = period.start;
|
|
7791
|
+
|
|
7792
|
+
console.log(`[credit selfheal] ${uid} +${found.credits} (${found.plan.id}) for period starting ${day}`);
|
|
7793
|
+
return found.credits;
|
|
7794
|
+
} catch (err) {
|
|
7795
|
+
// Never let this break a balance read: the gate calls it on every AI call.
|
|
7796
|
+
console.error('[credit selfheal]', err?.message || err);
|
|
7797
|
+
return 0;
|
|
7798
|
+
}
|
|
7799
|
+
};
|
|
7800
|
+
|
|
7506
7801
|
export const get_credit_balance = async function (uid, account_doc) {
|
|
7507
7802
|
const doc = account_doc || (await db_module.get_couch_doc_native('xuda_accounts', uid));
|
|
7508
7803
|
const period = _current_credit_period(doc);
|
|
@@ -7523,6 +7818,15 @@ export const get_credit_balance = async function (uid, account_doc) {
|
|
|
7523
7818
|
else period_grant += credits;
|
|
7524
7819
|
}
|
|
7525
7820
|
|
|
7821
|
+
// Mint this period's plan allowance if nothing else did (see above). Costs
|
|
7822
|
+
// nothing on the hot path when there is a live grant: every guard but the
|
|
7823
|
+
// last reads state that is already in hand.
|
|
7824
|
+
const healed = await _self_heal_period_grant(uid, doc, period, by_source);
|
|
7825
|
+
if (healed > 0) {
|
|
7826
|
+
period_grant += healed;
|
|
7827
|
+
by_source[doc.membership_plan] = (by_source[doc.membership_plan] || 0) + healed;
|
|
7828
|
+
}
|
|
7829
|
+
|
|
7526
7830
|
const usage_ret = await get_account_ai_usage({ uid, ts_from: period.start, ts_to: Date.now() });
|
|
7527
7831
|
const used = _usage_total(usage_ret?.data?.usage);
|
|
7528
7832
|
|
|
@@ -8401,6 +8705,41 @@ export const archive_expire_ai_credits = async function () {
|
|
|
8401
8705
|
}
|
|
8402
8706
|
};
|
|
8403
8707
|
|
|
8708
|
+
// Of the three ai_credit queries in this file, two were already indexed and one
|
|
8709
|
+
// was not:
|
|
8710
|
+
//
|
|
8711
|
+
// record_ai_credit's duplicate check { docType, credited_uid, details }
|
|
8712
|
+
// -> idx_billing_credit_lookup, fine
|
|
8713
|
+
// archive_expire_ai_credits { docType, stat, date_created_ts: { $lt } }
|
|
8714
|
+
// -> stat_docType_date_created_ts-json-index, fine
|
|
8715
|
+
// roll_credit_period's grant retirement { docType, credited_uid, stat }, limit 9999
|
|
8716
|
+
// -> _all_docs, i.e. a FULL SCAN of the whole ledger on every period roll
|
|
8717
|
+
//
|
|
8718
|
+
// master's xuda_billing holds ~27.5k docs, so that last one read all of them to
|
|
8719
|
+
// return one account's handful of grants, and Couch logged "No matching index
|
|
8720
|
+
// found ... the number of documents examined is high in proportion to the number
|
|
8721
|
+
// of results returned" every time.
|
|
8722
|
+
//
|
|
8723
|
+
// credited_uid leads because it is by far the most selective field; all three
|
|
8724
|
+
// clauses are equalities, so there is no range field to keep last.
|
|
8725
|
+
//
|
|
8726
|
+
// Not host-gated: xuda_billing is reachable wherever this module runs, and the PUT
|
|
8727
|
+
// no-ops once the design doc is already there (including when it arrived by
|
|
8728
|
+
// replication rather than from this node's own run).
|
|
8729
|
+
(async () => {
|
|
8730
|
+
try {
|
|
8731
|
+
const ret = await db_module.create_couch_index('xuda_billing', {
|
|
8732
|
+
index: { fields: ['credited_uid', 'docType', 'stat'] },
|
|
8733
|
+
name: 'idx_credit_uid_type_stat',
|
|
8734
|
+
ddoc: 'idx_credit_uid_type_stat',
|
|
8735
|
+
type: 'json',
|
|
8736
|
+
});
|
|
8737
|
+
if (ret?.error) console.error('[credits] xuda_billing credited_uid/docType/stat index create failed:', ret.error);
|
|
8738
|
+
} catch (err) {
|
|
8739
|
+
console.error('[credits] xuda_billing credited_uid/docType/stat index init failed:', err?.message || err);
|
|
8740
|
+
}
|
|
8741
|
+
})();
|
|
8742
|
+
|
|
8404
8743
|
export const read_accounts_emails = async function () {
|
|
8405
8744
|
const active_accounts = await db_module.find_couch_query('xuda_accounts', { selector: { stat: 3, docType: 'account' }, limit: 99999 });
|
|
8406
8745
|
for await (let account_doc of active_accounts.docs) {
|