@xuda.io/account_module 1.2.2296 → 1.2.2298

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.mjs CHANGED
@@ -1925,8 +1925,18 @@ export const ops_reinstate_account = async function (req = {}) {
1925
1925
  export const ops_list_terminations = async function (req = {}) {
1926
1926
  try {
1927
1927
  if (!_ops_is_super(req)) return { code: -403, data: 'superuser only' };
1928
- const ret = await db_module.find_couch_query('xuda_accounts', { selector: { docType: 'account', $or: [{ account_suspension_status: 1 }, { account_termination_status: 1 }] }, limit: 9999 }, true);
1929
- const rows = (ret.docs || []).map((a) => ({
1928
+ // Suspended OR terminated. CouchDB Mango uses one index per query, so it
1929
+ // cannot serve an $or across two different fields and would full-scan every
1930
+ // account (logs "documents examined is high"). Query each branch separately
1931
+ // (each hits its own [docType, account_*_status] index) and merge, deduping
1932
+ // accounts flagged as both.
1933
+ const [susp_ret, term_ret] = await Promise.all([
1934
+ db_module.find_couch_query('xuda_accounts', { selector: { docType: 'account', account_suspension_status: 1 }, limit: 9999 }, true),
1935
+ db_module.find_couch_query('xuda_accounts', { selector: { docType: 'account', account_termination_status: 1 }, limit: 9999 }, true),
1936
+ ]);
1937
+ const by_id = new Map();
1938
+ for (const a of [...(susp_ret?.docs || []), ...(term_ret?.docs || [])]) by_id.set(a._id, a);
1939
+ const rows = [...by_id.values()].map((a) => ({
1930
1940
  account_uid: a._id,
1931
1941
  email: a.account_info?.email,
1932
1942
  name: `${a.account_info?.first_name || ''} ${a.account_info?.last_name || ''}`.trim(),
@@ -1999,10 +2009,10 @@ export const ops_account_snapshot = async function (req = {}) {
1999
2009
  const a = ar.data;
2000
2010
  let credits = { max: 0, used: 0, remaining: 0 };
2001
2011
  try {
2002
- const cr = await get_account_ai_usage({ uid: account_uid });
2012
+ const cr = await get_account_ai_usage({ uid: account_uid, ..._LIFETIME_WINDOW });
2003
2013
  if (cr && cr.code === 55) {
2004
2014
  const max = Math.round((cr.data?.credits?.total || 0) * 100) / 100;
2005
- const used = Math.round((cr.data?.usage?.total || 0) * 100) / 100;
2015
+ const used = Math.round(_usage_total(cr.data?.usage) * 100) / 100;
2006
2016
  credits = { max, used, remaining: Math.round((max - used) * 100) / 100, by_source: cr.data?.credits || {} };
2007
2017
  }
2008
2018
  } catch (e) { console.error('[ops snapshot credits]', e.message); }
@@ -6471,11 +6481,13 @@ export const save_cache_hit = async function (_id) {
6471
6481
  ///////////////////////////////
6472
6482
 
6473
6483
  export const get_account_ai_usage = async function (req, job_id, headers) {
6474
- const d = new Date();
6475
- const curr_month = Number(String(d.getMonth() + 1).padStart(2, '0')); // months are 0-based
6476
- const curr_year = d.getFullYear();
6477
-
6478
- const { uid, year_from = curr_year, month_from = curr_month, day_from = 0, year_to = curr_year, month_to = curr_month, day_to = 99 } = req;
6484
+ // Default to the LIFETIME window (all-time spend) — the credit model is a
6485
+ // remaining-pool ledger (see _LIFETIME_WINDOW + the Credit Management page).
6486
+ // Callers that want a specific range (the usage chart in WorkspaceUsage.vue)
6487
+ // pass explicit year/month/day. The nav meter + ring call with no window, so
6488
+ // this makes them read lifetime "used" and match the Credit Management page
6489
+ // instead of a current-month slice against a lifetime pool.
6490
+ const { uid, year_from = 2000, month_from = 1, day_from = 1, year_to = 2999, month_to = 12, day_to = 31 } = req;
6479
6491
  try {
6480
6492
  const app_id = await get_account_default_project_id(uid);
6481
6493
 
@@ -6499,8 +6511,13 @@ export const get_account_ai_usage = async function (req, job_id, headers) {
6499
6511
 
6500
6512
  // console.log('ai_usage', ai_usage.rows);
6501
6513
 
6502
- let total_usage = response.total_usage;
6503
6514
  let profile = response.profiles;
6515
+ // The ai_usage2 list function emits per-profile spend but sometimes a null
6516
+ // rolled-up total. Roll it up HERE at the source so every consumer gets a real
6517
+ // usage.total — including the dashboard nav (shared-store fetchCredits reads
6518
+ // data.usage.total directly). Without this, Number(null) -> 0 hid all spend in
6519
+ // the meter/ring and disabled the account-wide credit cap.
6520
+ let total_usage = _usage_total({ total: response.total_usage, profile });
6504
6521
 
6505
6522
  const ai_credits = await db_module.get_couch_view('xuda_billing', 'ai_credits', {
6506
6523
  startkey: [uid, ''],
@@ -6610,6 +6627,23 @@ const _fold_usage = function (profile_map) {
6610
6627
  return { by_profile, by_user };
6611
6628
  };
6612
6629
 
6630
+ // Single source of truth for an account's rolled-up AI-credit usage. The usage
6631
+ // view reliably emits per-profile spend but sometimes returns a null rolled-up
6632
+ // total (usage.total); Number(null)||0 then collapsed "used" to 0 EVERYWHERE —
6633
+ // hiding spend in the nav/meter AND making the account-wide credit cap never
6634
+ // fire. Prefer the view's own total when finite, otherwise sum the per-profile
6635
+ // fold (same source, same credit unit).
6636
+ const _usage_total = function (usage_data) {
6637
+ // NOTE: Number(null) === 0 (and IS finite), which is exactly what collapsed
6638
+ // "used" to 0 — so guard on the RAW type, not the coerced number. Only trust
6639
+ // the view's own total when it is a real finite number; otherwise roll up the
6640
+ // per-profile fold.
6641
+ const t = usage_data?.total;
6642
+ if (typeof t === 'number' && Number.isFinite(t)) return t;
6643
+ const { by_profile } = _fold_usage(usage_data?.profile);
6644
+ return Object.values(by_profile).reduce((a, b) => a + (Number(b) || 0), 0);
6645
+ };
6646
+
6613
6647
  // Wide window so "used" reads as all-time spend (the lifetime / remaining-pool model).
6614
6648
  const _LIFETIME_WINDOW = { year_from: 2000, month_from: 1, day_from: 1, year_to: 2999, month_to: 12, day_to: 31 };
6615
6649
 
@@ -6625,8 +6659,8 @@ const _get_scoped_usage = async function (owner_uid, need_raw) {
6625
6659
  if (cached && now - cached.ts < _SCOPED_USAGE_TTL && (!need_raw || cached.has_raw)) return cached.data;
6626
6660
 
6627
6661
  const usage_ret = await get_account_ai_usage({ uid: owner_uid, ..._LIFETIME_WINDOW });
6628
- const total = Number(usage_ret?.data?.usage?.total) || 0;
6629
6662
  const { by_profile, by_user } = _fold_usage(usage_ret?.data?.usage?.profile);
6663
+ const total = _usage_total(usage_ret?.data?.usage);
6630
6664
 
6631
6665
  let by_model = {};
6632
6666
  let by_source = {};
@@ -6757,8 +6791,8 @@ export const evaluate_credit_gate = async function (req) {
6757
6791
 
6758
6792
  // Back-compat: no rules or disabled => legacy single ledger cap, byte-for-byte.
6759
6793
  if (!rules || rules.enabled === false) {
6760
- const usage = await get_account_ai_usage({ uid: owner });
6761
- const over = (Number(usage?.data?.credits?.total) || 0) - (Number(usage?.data?.usage?.total) || 0) < -0.5;
6794
+ const usage = await get_account_ai_usage({ uid: owner, ..._LIFETIME_WINDOW });
6795
+ const over = (Number(usage?.data?.credits?.total) || 0) - _usage_total(usage?.data?.usage) < -0.5;
6762
6796
  return { block: over, scope: over ? 'pool' : null, reason: over ? _SCOPE_REASON.ledger : '', hard_breached: over ? [{ scope: 'pool', key: 'ledger' }] : [], soft_breached: [], account_id: owner };
6763
6797
  }
6764
6798
 
@@ -7105,10 +7139,13 @@ export const broadcast_credits = function (uid) {
7105
7139
  _credits_broadcast_timers[uid] = setTimeout(async () => {
7106
7140
  delete _credits_broadcast_timers[uid];
7107
7141
  try {
7108
- const ret = await get_account_ai_usage({ uid });
7142
+ // Lifetime window (matches the Credit Management meter) + the same null-total
7143
+ // rollup as _get_scoped_usage — get_account_ai_usage can return a null
7144
+ // rolled-up usage.total, which otherwise broadcasts used:0 to the live nav.
7145
+ const ret = await get_account_ai_usage({ uid, ..._LIFETIME_WINDOW });
7109
7146
  if (!ret || ret.code !== 55) return;
7110
7147
  const max = Math.round((ret.data?.credits?.total || 0) * 100) / 100;
7111
- const used = Math.round((ret.data?.usage?.total || 0) * 100) / 100;
7148
+ const used = Math.round(_usage_total(ret.data?.usage) * 100) / 100;
7112
7149
  ws_dashboard_msa.emit_message_to_dashboard({
7113
7150
  service: 'credits_update',
7114
7151
  to: [uid],
@@ -7547,6 +7584,26 @@ const _nl_one = async (db, docType) => {
7547
7584
  }
7548
7585
  })();
7549
7586
 
7587
+ // The abuse-review queue (the flagged-accounts list, see ~line 1014) selects on
7588
+ // `abuse_signals.flagged` with no index, so Couch full-scans xuda_accounts and
7589
+ // logs "No matching index found". xuda_accounts lives locally on dev + master;
7590
+ // ensure the index at module load, idempotent, on those content-home nodes only.
7591
+ (async () => {
7592
+ const host = process.env.XUDA_HOSTNAME;
7593
+ if (host !== 'dev.xuda.ai' && host !== 'master.xuda.ai') return;
7594
+ try {
7595
+ const ret = await db_module.create_couch_index('xuda_accounts', {
7596
+ index: { fields: ['abuse_signals.flagged'] },
7597
+ name: 'idx_abuse_signals_flagged',
7598
+ ddoc: 'idx_abuse_signals_flagged',
7599
+ type: 'json',
7600
+ });
7601
+ if (ret?.error) console.error('[abuse] xuda_accounts abuse_signals.flagged index create failed:', ret.error);
7602
+ } catch (err) {
7603
+ console.error('[abuse] xuda_accounts abuse_signals.flagged index init failed:', err?.message || err);
7604
+ }
7605
+ })();
7606
+
7550
7607
  const _nl_fashion_url = (p) => {
7551
7608
  const rel = p.custom_url || (p.slug ? '/' + p.slug : '');
7552
7609
  return `https://xuda.fashion${rel}`;
@@ -7907,7 +7964,12 @@ export const newsletter_generate = async function (req = {}) {
7907
7964
  if (!req.system && !_ops_is_super(req)) return { code: -403, data: 'superuser only' };
7908
7965
  // Master-only (dev exempt for testing): the issue DB must never be created
7909
7966
  // on a regional couch, and the weekly coupon/sample flow belongs to master.
7910
- if (!_conf.is_debug && process.env.XUDA_HOSTNAME !== 'master.xuda.ai') return { code: -1, data: 'newsletter_generate runs on master only' };
7967
+ // MASTER ONLY, dev included. Dev is is_debug but still sends real [SAMPLE]/
7968
+ // [OPS] mail and keeps its own issue counter, so a "Generate now" on dev
7969
+ // blasts live info@ inboxes and creates dev-only drafts that never appear in
7970
+ // the master Newsletter Manager (dev does not replicate to master). Preview /
7971
+ // Test still work on dev for reviewing render; only issuing is locked here.
7972
+ if (process.env.XUDA_HOSTNAME !== 'master.xuda.ai') return { code: -1, data: 'newsletter_generate runs on master only' };
7911
7973
  await _nl_ensure_storage();
7912
7974
 
7913
7975
  // Two DISTINCT tips: one for "Did you know?", one for "Tip of the week".
@@ -8068,7 +8130,9 @@ export const newsletter_publish = async function (req = {}) {
8068
8130
  if (!_ops_is_super(req)) return { code: -403, data: 'superuser only' };
8069
8131
  // Same master-only gate as generate: the blast must run where xuda_newsletter
8070
8132
  // and the authoritative xuda_accounts live (dev exempt for testing).
8071
- if (!_conf.is_debug && process.env.XUDA_HOSTNAME !== 'master.xuda.ai') return { code: -1, data: 'newsletter_publish runs on master only' };
8133
+ // MASTER ONLY, dev included: a publish on dev would blast dev's opted-in
8134
+ // accounts with real mail. Locked so only the real master issues a newsletter.
8135
+ if (process.env.XUDA_HOSTNAME !== 'master.xuda.ai') return { code: -1, data: 'newsletter_publish runs on master only' };
8072
8136
  const id = req.id || `nl_issue_${req.issue_number}`;
8073
8137
  const gret = await db_module.get_couch_doc(NEWSLETTER_DB, id);
8074
8138
  if (gret.code < 0 || !gret.data) return { code: -1, data: 'issue not found' };