@xuda.io/account_module 1.2.2279 → 1.2.2281

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
@@ -1,5 +1,6 @@
1
1
  import path from 'path';
2
2
  import fs from 'fs';
3
+ import crypto from 'node:crypto';
3
4
  import _ from 'lodash';
4
5
  // import { exec } from 'child_process';
5
6
  // import util from 'util';
@@ -36,6 +37,7 @@ const account_info_properties = [
36
37
  'network_country_code',
37
38
  'network_city_slug',
38
39
  'public_profile_disabled',
40
+ 'login_screen_obj',
39
41
  ];
40
42
 
41
43
  global._conf = (await import(path.join(process.env.XUDA_HOME, "common", "load_conf.mjs"))).loadConf();
@@ -213,6 +215,19 @@ export const update_account_info = async function (req, job_id, headers) {
213
215
  delete account_obj.account_info.job_id;
214
216
  delete account_obj.account_info.app_id;
215
217
 
218
+ // Changing the email away from a hard-bounced address lifts an email-bounce
219
+ // suspension immediately (the daily sweep_email_bounces covers email changes
220
+ // made through any other path).
221
+ if (account_info_changes_arr.includes('email')) {
222
+ const eb = account_obj.account_email_bounce;
223
+ if (eb && (eb.status === 'suspended' || eb.status === 'flagged')
224
+ && (account_obj.account_info.email || '').toLowerCase().trim() !== (eb.recipient || '').toLowerCase().trim()) {
225
+ eb.status = 'cleared';
226
+ eb.cleared_ts = Date.now();
227
+ account_obj.account_email_bounce_suspended = 0;
228
+ }
229
+ }
230
+
216
231
  const save_ret = await db_module.save_couch_doc('xuda_accounts', account_obj);
217
232
 
218
233
  if (account_obj.account_info?.profile_picture) {
@@ -281,12 +296,21 @@ export const update_account_info = async function (req, job_id, headers) {
281
296
  return save_ret;
282
297
  };
283
298
  export const update_account_preferences = async function (req) {
284
- const { uid, hide_create_button, default_dashboard, sidebar_style, enable_thumbnail_avatar_generation } = req;
299
+ const { uid, hide_create_button, default_dashboard, sidebar_style, enable_thumbnail_avatar_generation, sidenav_mode, newsletter_opt_in } = req;
285
300
  try {
286
301
  let account_doc = await db_module.get_couch_doc_native('xuda_accounts', uid);
287
302
  account_doc.ts = Date.now();
288
303
 
289
- account_doc.preferences = { hide_create_button, default_dashboard, sidebar_style, enable_thumbnail_avatar_generation };
304
+ // Merge over the existing prefs so a save from a screen that doesn't
305
+ // know about newer keys (e.g. sidenav_mode) can't silently wipe them.
306
+ account_doc.preferences = { ...(account_doc.preferences || {}), hide_create_button, default_dashboard, sidebar_style, enable_thumbnail_avatar_generation };
307
+ // 'static' (curated groups, today's behavior) | 'recent' (auto-reorder
308
+ // by last activity). Only touched when the caller sends it.
309
+ if (sidenav_mode !== undefined) account_doc.preferences.sidenav_mode = sidenav_mode;
310
+ // Weekly newsletter opt-in (default true at signup). Backs the profile toggle
311
+ // + the onboarding checkbox; the email-footer unsubscribe link flips this same
312
+ // flag via router /newsletter/unsubscribe. Only touched when the caller sends it.
313
+ if (newsletter_opt_in !== undefined) account_doc.preferences.newsletter_opt_in = !!newsletter_opt_in;
290
314
 
291
315
  const save_ret = await db_module.save_couch_doc('xuda_accounts', account_doc);
292
316
  if (save_ret.code < 0) {
@@ -298,6 +322,80 @@ export const update_account_preferences = async function (req) {
298
322
  }
299
323
  };
300
324
 
325
+ // ── Left-menu dash state ─────────────────────────────────────────────────
326
+ // Per-user doc holding the sidenav's per-ITEM state: pinned items + last-
327
+ // activity timestamps (powers the "recent" auto-reorder mode and the items
328
+ // grid). Kept OUT of the account doc on purpose: activity is touched on
329
+ // every app open, and the account doc has too many other writers to absorb
330
+ // that churn. The mode itself lives in preferences.sidenav_mode.
331
+ const DASH_STATE_ID = (uid) => `dash_state_${uid}`;
332
+ // Newest-N cap: the sidenav/grid never needs more, and it bounds doc growth
333
+ // for accounts that open lots of apps over years.
334
+ const DASH_ACTIVITY_CAP = 300;
335
+
336
+ const _get_dash_state_doc = async function (uid) {
337
+ // no_cache — this doc is read-modify-write; a stale memcached copy would
338
+ // silently drop pins/touches made from another node.
339
+ const ret = await db_module.get_couch_doc('xuda_accounts', DASH_STATE_ID(uid), true);
340
+ if (ret.code > 0 && ret.data) return ret.data;
341
+ return { _id: DASH_STATE_ID(uid), docType: 'dash_state', uid, activity: {}, pins: {} };
342
+ };
343
+
344
+ export const get_dash_state = async function (req) {
345
+ try {
346
+ if (!req.uid) return { code: -1, data: 'uid missing' };
347
+ const doc = await _get_dash_state_doc(req.uid);
348
+ return { code: 1, data: { activity: doc.activity || {}, pins: doc.pins || {} } };
349
+ } catch (err) {
350
+ return { code: -1, data: err.message };
351
+ }
352
+ };
353
+
354
+ export const touch_item_activity = async function (req) {
355
+ const { uid, app_id } = req;
356
+ if (!uid || !app_id) return { code: -1, data: 'uid/app_id missing' };
357
+ try {
358
+ const doc = await _get_dash_state_doc(uid);
359
+ doc.activity = doc.activity || {};
360
+ doc.activity[app_id] = Date.now();
361
+
362
+ const keys = Object.keys(doc.activity);
363
+ if (keys.length > DASH_ACTIVITY_CAP) {
364
+ keys
365
+ .sort((a, b) => doc.activity[a] - doc.activity[b])
366
+ .slice(0, keys.length - DASH_ACTIVITY_CAP)
367
+ .forEach((k) => delete doc.activity[k]);
368
+ }
369
+
370
+ doc.ts = Date.now();
371
+ const save_ret = await db_module.save_couch_doc('xuda_accounts', doc);
372
+ if (save_ret.code < 0) throw new Error(save_ret.data);
373
+ return { code: 1, data: { app_id, ts: doc.activity[app_id] } };
374
+ } catch (err) {
375
+ return { code: -1, data: err.message };
376
+ }
377
+ };
378
+
379
+ export const set_item_pin = async function (req) {
380
+ const { uid, app_id, pinned } = req;
381
+ if (!uid || !app_id) return { code: -1, data: 'uid/app_id missing' };
382
+ try {
383
+ const doc = await _get_dash_state_doc(uid);
384
+ doc.pins = doc.pins || {};
385
+ if (pinned) {
386
+ doc.pins[app_id] = Date.now();
387
+ } else {
388
+ delete doc.pins[app_id];
389
+ }
390
+ doc.ts = Date.now();
391
+ const save_ret = await db_module.save_couch_doc('xuda_accounts', doc);
392
+ if (save_ret.code < 0) throw new Error(save_ret.data);
393
+ return { code: 1, data: { pins: doc.pins } };
394
+ } catch (err) {
395
+ return { code: -1, data: err.message };
396
+ }
397
+ };
398
+
301
399
  export const save_admin_presets = async function (req) {
302
400
  const ret = await db_module.get_couch_doc('xuda_master', req.app_id);
303
401
  if (ret.code < 0) {
@@ -322,6 +420,30 @@ export const save_admin_presets = async function (req) {
322
420
  return obj;
323
421
  };
324
422
 
423
+ // Effective entitlements = the membership plan's base quota PLUS the AI-workspace
424
+ // add-on's credits + storage stacked on top. The Free membership tier does NOT
425
+ // stack (it keeps its base only); every paid tier adds whatever ai_workspace plan
426
+ // the account carries. String quotas ("unlimited", e.g. Enterprise T3) short-circuit
427
+ // to Infinity so downstream numeric comparisons never cap. Single source of truth —
428
+ // use for quota checks and any effective-quota display/API.
429
+ export const get_effective_entitlements = function (account_doc) {
430
+ const membership = _conf.PLAN_OBJ?.[account_doc?.membership_plan];
431
+ const mf = membership?.features || {};
432
+
433
+ const numOrInf = (v) => (typeof v === 'string' ? Infinity : Number(v) || 0);
434
+ let ai_credits = numOrInf(mf.ai_credits);
435
+ let drive_gb = numOrInf(mf.drive);
436
+
437
+ if (account_doc?.membership_plan && account_doc.membership_plan !== 'free') {
438
+ const ws = _conf.PLAN_OBJ?.[account_doc?.ai_workspace_plan];
439
+ if (ws && ws.category === 'ai_workspace') {
440
+ if (Number.isFinite(ai_credits)) ai_credits += Number(ws.ai_credits) || 0;
441
+ if (Number.isFinite(drive_gb)) drive_gb += (Number(ws.size) || 0) / 1073741824;
442
+ }
443
+ }
444
+ return { ai_credits, drive_gb };
445
+ };
446
+
325
447
  export const increment_account_usage = async function (req) {
326
448
  const { uid } = req;
327
449
 
@@ -367,9 +489,17 @@ export const increment_account_usage = async function (req) {
367
489
 
368
490
  if (Date.now() - doc.ts >= interval) {
369
491
  const plan = _conf.PLAN_OBJ[account_data.membership_plan];
370
- if (!plan) throw new Error(`${account_data.account_info.first_name + ' ' + account_data.account_info.last_name} has no plan in file`);
492
+ if (!plan) {
493
+ // Only report missing plans for stat 3 (verified) accounts. Stat 1/2
494
+ // (unverified stubs, temp-pass invites, widget signups) have no plan
495
+ // until their first real login by design — skip silently.
496
+ if (account_data.stat === 3) throw new Error(`${account_data.account_info.first_name + ' ' + account_data.account_info.last_name} has no plan in file`);
497
+ return;
498
+ }
371
499
  const drive_size_total = account_data.user_drive_size + account_data.studio_drive_size + account_data.workspace_drive_size + account_data.builds_drive_size + account_data.plugins_drive_size;
372
- if (bytesToGB(drive_size_total) > plan.features.drive) {
500
+ // Effective drive quota = membership base + stacked AI-workspace storage (paid tiers only).
501
+ const { drive_gb: effective_drive_gb } = get_effective_entitlements(account_data);
502
+ if (bytesToGB(drive_size_total) > effective_drive_gb) {
373
503
  var price_per_hour = (bytesToGB(drive_size_total) * _conf.PRICE_OBJ.price_per_gb_drive) / _conf.PRICE_OBJ.avg_hours_in_month;
374
504
 
375
505
  doc.hours++;
@@ -597,6 +727,17 @@ const is_admin_email = (email) => {
597
727
  return ADMIN_EMAILS.has(String(email).toLowerCase());
598
728
  };
599
729
 
730
+ // Abuse-triage access = the ADMIN_EMAILS allowlist above (defense-in-depth,
731
+ // can't be granted by editing config) OR a platform superuser (the same gate
732
+ // the rest of the admin console — ops_*, firewall — uses). The comment above
733
+ // says "keep this in sync with the dashboard's admin-route gate", which is now
734
+ // the superuser check; the hardcoded email had drifted stale, locking real
735
+ // superusers (e.g. info@xuda.ai) out of /admin/abuse.
736
+ const is_abuse_admin = (uid, email) => {
737
+ if (is_admin_email(email)) return true;
738
+ return !!(uid && Array.isArray(_conf.superuser_account_ids) && _conf.superuser_account_ids.includes(uid));
739
+ };
740
+
600
741
  // Get the active billing cycle bounds for an account, on demand from
601
742
  // Stripe. Used by recompute_abuse_signals to scope its "this cycle"
602
743
  // counters correctly. Returns { cycle_start_ts, cycle_end_ts } in ms
@@ -755,7 +896,7 @@ export const get_flagged_accounts = async function (req) {
755
896
  if (!caller_uid) return { code: -1, data: 'unauthorized' };
756
897
  try {
757
898
  const caller_ret = await db_module.get_couch_doc('xuda_accounts', caller_uid);
758
- if (caller_ret.code < 0 || !is_admin_email(caller_ret.data.email)) {
899
+ if (caller_ret.code < 0 || !is_abuse_admin(caller_uid, caller_ret.data.email)) {
759
900
  return { code: -1, data: 'unauthorized' };
760
901
  }
761
902
 
@@ -801,7 +942,7 @@ export const sweep_abuse_signals = async function (req) {
801
942
  if (!caller_uid) return { code: -1, data: 'unauthorized' };
802
943
  try {
803
944
  const caller_ret = await db_module.get_couch_doc('xuda_accounts', caller_uid);
804
- if (caller_ret.code < 0 || !is_admin_email(caller_ret.data.email)) {
945
+ if (caller_ret.code < 0 || !is_abuse_admin(caller_uid, caller_ret.data.email)) {
805
946
  return { code: -1, data: 'unauthorized' };
806
947
  }
807
948
 
@@ -840,7 +981,7 @@ export const mark_account_reviewed = async function (req) {
840
981
  if (!caller_uid || !target_uid) return { code: -1, data: 'uid + target_uid required' };
841
982
  try {
842
983
  const caller_ret = await db_module.get_couch_doc('xuda_accounts', caller_uid);
843
- if (caller_ret.code < 0 || !is_admin_email(caller_ret.data.email)) {
984
+ if (caller_ret.code < 0 || !is_abuse_admin(caller_uid, caller_ret.data.email)) {
844
985
  return { code: -1, data: 'unauthorized' };
845
986
  }
846
987
 
@@ -966,6 +1107,57 @@ export const backfill_app_costs = async function (opts = {}) {
966
1107
  return summary;
967
1108
  };
968
1109
 
1110
+ // True when a plan is team-tier or higher (team/agency/enterprise_*). Sponsoring a
1111
+ // login screen requires this tier both at invite time and while the sponsorship is served.
1112
+ export const is_login_sponsor_tier = function (membership_plan) {
1113
+ const rank = _conf.PLAN_OBJ?.[membership_plan]?.rank;
1114
+ const team_rank = _conf.PLAN_OBJ?.['team']?.rank;
1115
+ return typeof rank === 'number' && typeof team_rank === 'number' && rank >= team_rank;
1116
+ };
1117
+
1118
+ // Resolve the effective login screen for an account, pre-auth-safe.
1119
+ // Precedence: an active sponsorship (sponsor still has an image AND is still team+)
1120
+ // > the account's own personal image (only while paid) > Xuda's default video.
1121
+ // Defensive: if the sponsorship views aren't present yet, it silently falls through
1122
+ // to personal/default, so it is safe to ship before the CouchDB views are deployed.
1123
+ export const resolve_effective_login_screen = async function (account_doc) {
1124
+ const DEFAULT = { source: 'default', media_type: 'video', file_url: null };
1125
+ try {
1126
+ if (!account_doc?._id) return DEFAULT;
1127
+
1128
+ // 1) Active sponsorship where this account is the invitee.
1129
+ try {
1130
+ const body = await db_module.get_couch_view_raw('xuda_team', 'login_sponsorship_by_invitee', {
1131
+ key: account_doc._id,
1132
+ include_docs: true,
1133
+ });
1134
+ const rows = body?.rows || [];
1135
+ // newest active sponsorship wins if there are (transient) duplicates
1136
+ const row = rows.map((r) => r.doc).filter(Boolean).sort((a, b) => (b?.team_req_status_date || 0) - (a?.team_req_status_date || 0))[0];
1137
+ if (row?.team_req_from_uid) {
1138
+ const { data: sponsor } = await db_module.get_couch_doc('xuda_accounts', row.team_req_from_uid);
1139
+ const sponsor_img = sponsor?.account_info?.login_screen_obj?.file_url;
1140
+ if (sponsor_img && is_login_sponsor_tier(sponsor?.membership_plan)) {
1141
+ return { source: 'sponsored', media_type: 'image', file_url: sponsor_img, sponsor_uid: row.team_req_from_uid, team_req_id: row._id };
1142
+ }
1143
+ // sponsor deleted their image or downgraded -> fall through to personal/default
1144
+ }
1145
+ } catch (e) {
1146
+ // views not deployed yet, or lookup failed -> treat as "not sponsored"
1147
+ }
1148
+
1149
+ // 2) The account's own image, but only while on a paid plan.
1150
+ const own = account_doc.account_info?.login_screen_obj;
1151
+ if (own?.file_url && (account_doc.membership_plan || 'free') !== 'free') {
1152
+ return { source: 'personal', media_type: 'image', file_url: own.file_url, w: own.w, h: own.h, bytes: own.bytes, uploaded_ts: own.uploaded_ts };
1153
+ }
1154
+
1155
+ return DEFAULT;
1156
+ } catch (err) {
1157
+ return DEFAULT;
1158
+ }
1159
+ };
1160
+
969
1161
  export const get_account_data = async function (req) {
970
1162
  var { uid, enforce_usage } = req;
971
1163
 
@@ -1012,6 +1204,11 @@ export const get_account_data = async function (req) {
1012
1204
  account_billing_hold_data: acc_obj.account_billing_hold_date,
1013
1205
  account_termination_status: acc_obj.account_termination_status,
1014
1206
  account_termination_data: acc_obj.account_termination_data,
1207
+ // Email-bounce suspension: the user can still log in; the dashboard
1208
+ // shows a suspension message (update email / open a ticket) and hides
1209
+ // everything else. Cleared automatically on email change.
1210
+ account_email_bounce_suspended: acc_obj.account_email_bounce_suspended || 0,
1211
+ account_email_bounce_recipient: acc_obj.account_email_bounce_suspended ? acc_obj.account_email_bounce?.recipient || '' : '',
1015
1212
  },
1016
1213
  drive_usage: {
1017
1214
  user_drive_size: acc_obj.user_drive_size || 0,
@@ -1025,6 +1222,9 @@ export const get_account_data = async function (req) {
1025
1222
 
1026
1223
  stripe_connect_account_id: acc_obj?.stripe_connect_account_obj?.id,
1027
1224
  stripe_connect_account_status: acc_obj?.stripe_connect_account_status,
1225
+ // Effective login screen (sponsored > personal > default) for the client to
1226
+ // render + cache in a cookie for pre-auth render on the next visit.
1227
+ login_screen: await resolve_effective_login_screen(acc_obj),
1028
1228
  };
1029
1229
 
1030
1230
  return ret;
@@ -1037,6 +1237,86 @@ export const get_account_data = async function (req) {
1037
1237
  // return get_info();
1038
1238
  };
1039
1239
 
1240
+ // reset_login_screen: restore the Xuda default. Allowed for any plan (incl. free).
1241
+ // If the caller is currently sponsored, this is a PERMANENT opt-out — it revokes the
1242
+ // sponsorship (stopping the sponsor's billing via team_module) — and it also clears
1243
+ // any personal image the caller uploaded/generated.
1244
+ export const reset_login_screen = async function (req, job_id, headers) {
1245
+ const uid = req.uid || req.token_ret?.data?.uid;
1246
+ if (!uid) return { code: -401, data: 'not authorized (no uid)' };
1247
+ try {
1248
+ const { data: account_obj } = await db_module.get_couch_doc('xuda_accounts', uid);
1249
+ if (!account_obj) return { code: -404, data: 'account not found' };
1250
+
1251
+ // Sponsored -> permanent opt-out (revoke + final flush + notify sponsor).
1252
+ let opted_out = false;
1253
+ try {
1254
+ const body = await db_module.get_couch_view_raw('xuda_team', 'login_sponsorship_by_invitee', { key: uid, include_docs: true });
1255
+ const row = (body?.rows || []).map((r) => r.doc).filter(Boolean)[0];
1256
+ const team_req_id = row?._id;
1257
+ if (team_req_id) {
1258
+ await team_ms.remove_login_sponsor({ uid, team_req_id });
1259
+ opted_out = true;
1260
+ }
1261
+ } catch (e) {
1262
+ // views not deployed / no sponsorship -> nothing to opt out of
1263
+ }
1264
+
1265
+ // Always drop the caller's own image too (free the R2 bytes).
1266
+ let cleared = false;
1267
+ const prev = account_obj.account_info?.login_screen_obj;
1268
+ if (prev) {
1269
+ if (prev.bucket) {
1270
+ try {
1271
+ await drive_ms.delete_file_from_bucket(prev.bucket);
1272
+ } catch (e) {}
1273
+ }
1274
+ delete account_obj.account_info.login_screen_obj;
1275
+ account_obj.ts = Date.now();
1276
+ await db_module.save_couch_doc('xuda_accounts', account_obj);
1277
+ cleared = true;
1278
+ }
1279
+
1280
+ return { code: 1, data: { login_screen: { source: 'default', media_type: 'video', file_url: null }, opted_out, cleared } };
1281
+ } catch (err) {
1282
+ return { code: -400, data: err.message };
1283
+ }
1284
+ };
1285
+
1286
+ // get_login_screen: PUBLIC pre-auth resolver for a first visit on a new device
1287
+ // (the primary pre-auth path is the cookie the client sets after login). Returns
1288
+ // ONLY { source, media_type, file_url } — never account internals — and returns the
1289
+ // default for any unknown/plain-screen account so it can't be used to enumerate emails.
1290
+ // Prefer keying by the opaque team_req_id (from a sponsorship invite link) or uid.
1291
+ export const get_login_screen = async function (req) {
1292
+ const DEFAULT = { code: 1, data: { source: 'default', media_type: 'video', file_url: null } };
1293
+ try {
1294
+ const { email, uid, team_req_id } = req;
1295
+ let account_obj = null;
1296
+
1297
+ if (uid) {
1298
+ const r = await db_module.get_couch_doc('xuda_accounts', uid);
1299
+ account_obj = r?.data || null;
1300
+ } else if (team_req_id) {
1301
+ const r = await db_module.get_couch_doc('xuda_team', team_req_id);
1302
+ const row = r?.data;
1303
+ if (row?.access_type === 'login_sponsorship' && row?.team_req_to_uid) {
1304
+ const a = await db_module.get_couch_doc('xuda_accounts', row.team_req_to_uid);
1305
+ account_obj = a?.data || null;
1306
+ }
1307
+ } else if (email) {
1308
+ const ret = await db_module.find_couch_query('xuda_accounts', { selector: { 'account_info.email': email }, limit: 1 });
1309
+ account_obj = ret?.docs?.[0] || null;
1310
+ }
1311
+
1312
+ if (!account_obj) return DEFAULT;
1313
+ const eff = await resolve_effective_login_screen(account_obj);
1314
+ return { code: 1, data: { source: eff.source, media_type: eff.media_type, file_url: eff.file_url } };
1315
+ } catch (err) {
1316
+ return DEFAULT;
1317
+ }
1318
+ };
1319
+
1040
1320
  export const get_account_projects = async function (req) {
1041
1321
  const { uid } = req;
1042
1322
  var opt = {
@@ -1114,6 +1394,25 @@ export const get_account_static_websites = async function (req) {
1114
1394
  }
1115
1395
  };
1116
1396
 
1397
+ // External apps are apps (app_type:'external_app') not emitted by any user_*
1398
+ // view — same Mango-query approach as get_account_static_websites → so the
1399
+ // sidenav bucket (DATA.external_app) picks up a newly connected app right after
1400
+ // create, without a full account reload.
1401
+ export const get_account_external_apps = async function (req) {
1402
+ try {
1403
+ const isSuper = _conf.superuser_account_ids?.includes(req.uid);
1404
+ const selector = isSuper ? { app_type: 'external_app', docType: 'app' } : { app_uId: req.uid, app_type: 'external_app', docType: 'app' };
1405
+ const ret = await db_module.find_couch_query('xuda_master', {
1406
+ selector,
1407
+ limit: 1000,
1408
+ });
1409
+ const rows = (ret?.docs || []).map((d) => ({ id: d._id, value: d }));
1410
+ return { code: 1, data: { rows } };
1411
+ } catch (err) {
1412
+ return { code: -1, data: err.message };
1413
+ }
1414
+ };
1415
+
1117
1416
  export const get_account_info = async function (req) {
1118
1417
  return await get_account_data({
1119
1418
  uid: req.uid,
@@ -1307,6 +1606,172 @@ export const ops_list_terminations = async function (req = {}) {
1307
1606
  } catch (err) { return { code: -1, data: err.message }; }
1308
1607
  };
1309
1608
 
1609
+ // ===================================================================
1610
+ // Ops customer-admin helpers (superuser only): lookup + billing hold +
1611
+ // AI-credit grant + direct notification. Same auth model as the
1612
+ // suspend/terminate ladder above — `req.uid` is the authenticated ops
1613
+ // caller (the HTTP layer sets it from the session), and the TARGET is
1614
+ // always passed as `account_uid`.
1615
+ // ===================================================================
1616
+
1617
+ // Lean, safe account summary for the ops dashboard (never leaks secrets/tokens).
1618
+ const _ops_account_summary = (a) => ({
1619
+ account_uid: a._id,
1620
+ email: a.account_info?.email || '',
1621
+ name: `${a.account_info?.first_name || ''} ${a.account_info?.last_name || ''}`.trim() || a.account_info?.full_name || '',
1622
+ membership_plan: a.membership_plan || 'free',
1623
+ ai_workspace_plan: a.ai_workspace_plan || '',
1624
+ suspended: a.account_suspension_status === 1,
1625
+ terminated: a.account_termination_status === 1,
1626
+ billing_hold: a.account_billing_hold_status === 1,
1627
+ appeal_status: a.appeal_status || 'none',
1628
+ finalized: !!a.termination_finalized_ts,
1629
+ });
1630
+
1631
+ // Find accounts by email / name / uid (substring, case-insensitive).
1632
+ export const ops_find_account = async function (req = {}) {
1633
+ try {
1634
+ if (!_ops_is_super(req)) return { code: -403, data: 'superuser only' };
1635
+ const q = String(req.query || '').trim();
1636
+ if (!q) return { code: -1, data: 'query is required' };
1637
+ const rx = `(?i)${q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`;
1638
+ const ret = await db_module.find_couch_query('xuda_accounts', {
1639
+ selector: {
1640
+ docType: 'account',
1641
+ $or: [
1642
+ { 'account_info.email': { $regex: rx } },
1643
+ { 'account_info.full_name': { $regex: rx } },
1644
+ { _id: { $regex: rx } },
1645
+ ],
1646
+ },
1647
+ limit: 25,
1648
+ }, true);
1649
+ return { code: 1, data: { rows: (ret.docs || []).map(_ops_account_summary) } };
1650
+ } catch (err) { return { code: -1, data: err.message }; }
1651
+ };
1652
+
1653
+ // Full status snapshot for one account incl. live AI-credit balance.
1654
+ export const ops_account_snapshot = async function (req = {}) {
1655
+ try {
1656
+ if (!_ops_is_super(req)) return { code: -403, data: 'superuser only' };
1657
+ const account_uid = req.account_uid;
1658
+ if (!account_uid) return { code: -1, data: 'account_uid is required' };
1659
+ const ar = await db_module.get_couch_doc('xuda_accounts', account_uid);
1660
+ if (ar.code < 0 || !ar.data) return { code: -1, data: 'account not found' };
1661
+ const a = ar.data;
1662
+ let credits = { max: 0, used: 0, remaining: 0 };
1663
+ try {
1664
+ const cr = await get_account_ai_usage({ uid: account_uid });
1665
+ if (cr && cr.code === 55) {
1666
+ const max = Math.round((cr.data?.credits?.total || 0) * 100) / 100;
1667
+ const used = Math.round((cr.data?.usage?.total || 0) * 100) / 100;
1668
+ credits = { max, used, remaining: Math.round((max - used) * 100) / 100, by_source: cr.data?.credits || {} };
1669
+ }
1670
+ } catch (e) { console.error('[ops snapshot credits]', e.message); }
1671
+ return {
1672
+ code: 1,
1673
+ data: {
1674
+ ..._ops_account_summary(a),
1675
+ suspension_date: a.account_suspension_date || null,
1676
+ suspension_reason: a.suspension_reason || '',
1677
+ termination_date: a.account_termination_date || null,
1678
+ termination_reason: a.termination_reason || '',
1679
+ appeal_deadline: a.termination_appeal_deadline || null,
1680
+ billing_hold_date: a.account_billing_hold_date || null,
1681
+ billing_hold_reason: a.billing_hold_reason || '',
1682
+ reinstated_ts: a.reinstated_ts || null,
1683
+ entitlements: get_effective_entitlements(a),
1684
+ credits,
1685
+ },
1686
+ };
1687
+ } catch (err) { return { code: -1, data: err.message }; }
1688
+ };
1689
+
1690
+ // Put an account's billing on hold (manual; mirrors the Stripe-webhook hold).
1691
+ export const ops_set_billing_hold = async function (req = {}) {
1692
+ try {
1693
+ if (!_ops_is_super(req)) return { code: -403, data: 'superuser only' };
1694
+ const account_uid = req.account_uid;
1695
+ if (!account_uid) return { code: -1, data: 'account_uid is required' };
1696
+ const ar = await db_module.get_couch_doc('xuda_accounts', account_uid);
1697
+ if (ar.code < 0 || !ar.data) return { code: -1, data: 'account not found' };
1698
+ const account = ar.data;
1699
+ account.account_billing_hold_status = 1;
1700
+ account.account_billing_hold_date = Date.now();
1701
+ account.billing_hold_reason = req.reason || '';
1702
+ account.billing_hold_by_uid = req.uid;
1703
+ const sr = await db_module.save_couch_doc('xuda_accounts', account);
1704
+ if (sr.code < 0) return { code: -1, data: sr.data };
1705
+ ws_dashboard_msa.emit_message_to_dashboard({ service: 'account_billing_hold_applied', to: [account_uid], data: { account_billing_hold_status: 1, account_billing_hold_date: account.account_billing_hold_date } });
1706
+ _ops_notify_ops(`Billing hold applied: ${account.account_info?.email || account_uid}`, [['Account', account_uid], ['Email', account.account_info?.email], ['By', req.uid], ['Reason', req.reason || '']], 'OPS-BHOLD');
1707
+ logs_msa.add_account_log({ uid: account_uid, method: 'ops_set_billing_hold', source: 'ops dashboard', response_status_code: 200, response_data: `billing hold by ${req.uid}` });
1708
+ return { code: 1, data: { billing_hold: account_uid } };
1709
+ } catch (err) { return { code: -1, data: err.message }; }
1710
+ };
1711
+
1712
+ // Release a billing hold (mirrors release_account_billing_hold_if_current).
1713
+ export const ops_release_billing_hold = async function (req = {}) {
1714
+ try {
1715
+ if (!_ops_is_super(req)) return { code: -403, data: 'superuser only' };
1716
+ const account_uid = req.account_uid;
1717
+ if (!account_uid) return { code: -1, data: 'account_uid is required' };
1718
+ const ar = await db_module.get_couch_doc('xuda_accounts', account_uid);
1719
+ if (ar.code < 0 || !ar.data) return { code: -1, data: 'account not found' };
1720
+ const account = ar.data;
1721
+ account.account_billing_hold_status = 0;
1722
+ account.account_billing_hold_date = Date.now();
1723
+ account.billing_hold_by_uid = req.uid;
1724
+ const sr = await db_module.save_couch_doc('xuda_accounts', account);
1725
+ if (sr.code < 0) return { code: -1, data: sr.data };
1726
+ ws_dashboard_msa.emit_message_to_dashboard({ service: 'account_billing_hold_released', to: [account_uid], data: { account_billing_hold_status: 0, account_billing_hold_date: account.account_billing_hold_date } });
1727
+ _ops_notify_ops(`Billing hold released: ${account.account_info?.email || account_uid}`, [['Account', account_uid], ['Email', account.account_info?.email], ['By', req.uid]], 'OPS-BHOLD');
1728
+ logs_msa.add_account_log({ uid: account_uid, method: 'ops_release_billing_hold', source: 'ops dashboard', response_status_code: 200, response_data: `billing hold released by ${req.uid}` });
1729
+ return { code: 1, data: { released: account_uid } };
1730
+ } catch (err) { return { code: -1, data: err.message }; }
1731
+ };
1732
+
1733
+ // Grant AI credits to an account (idempotent per unique details string).
1734
+ export const ops_add_ai_credits = async function (req = {}) {
1735
+ try {
1736
+ if (!_ops_is_super(req)) return { code: -403, data: 'superuser only' };
1737
+ const account_uid = req.account_uid;
1738
+ const credits = Number(req.credits);
1739
+ if (!account_uid) return { code: -1, data: 'account_uid is required' };
1740
+ if (!Number.isFinite(credits) || credits <= 0) return { code: -1, data: 'credits must be a positive number' };
1741
+ if (credits > 100000) return { code: -1, data: 'credits exceeds max single grant (100000)' };
1742
+ const ar = await db_module.get_couch_doc('xuda_accounts', account_uid);
1743
+ if (ar.code < 0 || !ar.data) return { code: -1, data: 'account not found' };
1744
+ const account = ar.data;
1745
+ const details = `admin grant ${credits} by ${req.uid} @ ${Date.now()}`;
1746
+ const cr = await record_ai_credit(req.uid, credits, 'admin_grant', details, account_uid);
1747
+ if (cr.code < 0) return { code: -1, data: cr.data };
1748
+ if (req.notify) {
1749
+ notification_msa.submit_notification({ type: 'account', uid_arr: [account_uid], subject: `${credits} AI credits added to your account`, body: `<h2>${credits} AI credits added</h2><p>Our team has added ${credits} AI credits to your account. Enjoy!</p>`, delivery_method: ['banner', 'email'], display_type: 'success' });
1750
+ }
1751
+ _ops_notify_ops(`AI credits granted: ${account.account_info?.email || account_uid}`, [['Account', account_uid], ['Email', account.account_info?.email], ['By', req.uid], ['Credits', credits], ['Reason', req.reason || '']], 'OPS-CREDIT');
1752
+ logs_msa.add_account_log({ uid: account_uid, method: 'ops_add_ai_credits', source: 'ops dashboard', response_status_code: 200, response_data: `granted ${credits} credits by ${req.uid}` });
1753
+ return { code: 1, data: { credited: account_uid, credits } };
1754
+ } catch (err) { return { code: -1, data: err.message }; }
1755
+ };
1756
+
1757
+ // Send a direct notification to a single customer (banner + email by default).
1758
+ export const ops_send_notification = async function (req = {}) {
1759
+ try {
1760
+ if (!_ops_is_super(req)) return { code: -403, data: 'superuser only' };
1761
+ const account_uid = req.account_uid;
1762
+ const subject = String(req.subject || '').trim();
1763
+ const body_in = String(req.body || '').trim();
1764
+ if (!account_uid) return { code: -1, data: 'account_uid is required' };
1765
+ if (!subject || !body_in) return { code: -1, data: 'subject and body are required' };
1766
+ const body = /<[a-z][\s\S]*>/i.test(body_in) ? body_in : `<p>${body_in.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</p>`;
1767
+ const delivery_method = Array.isArray(req.delivery_method) && req.delivery_method.length ? req.delivery_method : ['banner', 'email'];
1768
+ const display_type = req.display_type || 'info';
1769
+ const nr = await notification_msa.submit_notification({ type: 'account', uid_arr: [account_uid], subject, body, delivery_method, display_type, sender_uid: req.uid });
1770
+ logs_msa.add_account_log({ uid: account_uid, method: 'ops_send_notification', source: 'ops dashboard', response_status_code: 200, response_data: `notification "${subject}" sent by ${req.uid} via ${delivery_method.join('+')}` });
1771
+ return { code: 1, data: { sent: account_uid, notification: nr?.data || {} } };
1772
+ } catch (err) { return { code: -1, data: err.message }; }
1773
+ };
1774
+
1310
1775
  const TIPS_FILE_PATH = path.join(process.env.XUDA_HOME, 'dist', 'tips', 'tips.json');
1311
1776
  let _did_you_know_tips_cache = null;
1312
1777
  let _did_you_know_tips_cache_mtime = 0;
@@ -1686,10 +2151,10 @@ export const validate_user_plan = async function (req) {
1686
2151
  // group_level: 2,
1687
2152
  });
1688
2153
 
1689
- if (app_obj.app_type === 'master' && user_active_app_requests_count_ret.data?.rows?.[0]?.value >= plan.features.team) {
2154
+ if (app_obj.app_type === 'master' && user_active_app_requests_count_ret.data?.rows?.[0]?.value >= plan.features.team_members) {
1690
2155
  return {
1691
2156
  code: -9,
1692
- data: `Number of team shares (${user_active_app_requests_count_ret.data.rows[0].value}) exceeds to the ${plan.features.team} plan limits`,
2157
+ data: `Number of team shares (${user_active_app_requests_count_ret.data.rows[0].value}) exceeds to the ${plan.features.team_members} plan limits`,
1693
2158
  };
1694
2159
  }
1695
2160
 
@@ -1765,6 +2230,83 @@ export const add_account_log_util = async function (body, status, service, sourc
1765
2230
  logs_msa.add_account_log(get_account_log_object(body, status, service, source, response_data, ip, headers, security));
1766
2231
  };
1767
2232
 
2233
+ // Prefixes for read-style methods — reads are not "actions on the entity", so
2234
+ // they're excluded from the app action log (mirrors the account-log read skip).
2235
+ const _APP_LOG_READ_PREFIXES = ['get_', 'list_', 'find_', 'search_', 'count_', 'check_', 'read_', 'fetch_', 'preview_', 'validate_'];
2236
+ // Keys never copied into the slim arg_summary (defence-in-depth vs. secret bloat).
2237
+ const _APP_LOG_ARG_SENSITIVE = /pass|token|secret|key|cred|card|cvv|ssh|auth|otp|code/i;
2238
+
2239
+ const _app_log_resolve_app_id = (body, response_data) => {
2240
+ const d = (body && body.data) || {};
2241
+ const r = response_data || {};
2242
+ return (
2243
+ d.app_id ||
2244
+ body?.app_id ||
2245
+ r.app_id ||
2246
+ r.data?.app_id ||
2247
+ r.app_obj?._id ||
2248
+ r.data?.app_obj?._id ||
2249
+ null
2250
+ );
2251
+ };
2252
+
2253
+ // Build a tiny, sanitized summary of the action's arguments for the log row —
2254
+ // short scalars only, sensitive keys dropped, capped in count + length.
2255
+ const _app_log_arg_summary = (body) => {
2256
+ try {
2257
+ const d = (body && body.data) || {};
2258
+ const out = {};
2259
+ let n = 0;
2260
+ for (const [k, v] of Object.entries(d)) {
2261
+ if (n >= 12) break;
2262
+ if (_APP_LOG_ARG_SENSITIVE.test(k)) continue;
2263
+ if (v === null) continue;
2264
+ const t = typeof v;
2265
+ if (t === 'string') out[k] = v.length > 120 ? v.slice(0, 120) + '…' : v;
2266
+ else if (t === 'number' || t === 'boolean') out[k] = v;
2267
+ else continue;
2268
+ n++;
2269
+ }
2270
+ return Object.keys(out).length ? out : null;
2271
+ } catch (e) {
2272
+ return null;
2273
+ }
2274
+ };
2275
+
2276
+ // Entity ACTION log. Sibling of add_account_log_util, but purpose-built for the
2277
+ // per-entity Logs tab: records any action taken ON an app/server/vps/external
2278
+ // app (keyed by app_id), INCLUDING private methods (external_app's are private),
2279
+ // and EXCLUDING pure reads. Stored centrally via logs_module.add_app_log so it
2280
+ // works for standalone/server apps that have no project DB.
2281
+ export const add_app_log_util = async function (body, status, service, source, response_data, ip, headers) {
2282
+ try {
2283
+ if (!service || !_conf.cpi_methods?.[service]) return;
2284
+ const app_id = _app_log_resolve_app_id(body, response_data);
2285
+ if (!app_id) return; // not an entity action
2286
+
2287
+ // Skip read-style calls made without a developer key — they aren't actions.
2288
+ const is_key = !!(body?.api_key || body?.api_pk || body?.api_sk);
2289
+ if (!is_key && _APP_LOG_READ_PREFIXES.some((p) => service.startsWith(p))) return;
2290
+ // Don't log the log-reader itself, or the utils, to avoid recursion/noise.
2291
+ if (service === 'get_logs' || service === 'get_logs_counts' || service === 'add_account_log_util' || service === 'add_app_log_util') return;
2292
+
2293
+ const channel = headers?.['xu-channel'] === 'mcp' ? 'mcp' : is_key ? 'api' : 'dashboard';
2294
+ logs_msa.add_app_log({
2295
+ app_id,
2296
+ uid: body?.uid,
2297
+ method: service,
2298
+ method_label: _conf.cpi_methods?.[service]?.name || service,
2299
+ source,
2300
+ channel,
2301
+ response_status_code: status,
2302
+ ip,
2303
+ arg_summary: _app_log_arg_summary(body),
2304
+ });
2305
+ } catch (e) {
2306
+ console.error('add_app_log_util', e.message);
2307
+ }
2308
+ };
2309
+
1768
2310
  export const save_ssh_key = async function (req) {
1769
2311
  const { uid, _id, ssh_key_name, ssh_key_content } = req;
1770
2312
 
@@ -2860,30 +3402,57 @@ const _bounce_settings_url = () => {
2860
3402
  return `https://${host}/dashboard/settings`;
2861
3403
  };
2862
3404
 
2863
- // Alert the user to update their email (banner + SMS per the topic's delivery
2864
- // methods). SMS is gated/log-only in notification_module until sms_enabled.
2865
- const _send_bounce_alert = async (account, attempt, max) => {
3405
+ // Suspension notices: in-dashboard banner + SMS to the user (email would just
3406
+ // bounce again; SMS is gated/log-only in notification_module until sms_enabled)
3407
+ // and an ops email to the superusers.
3408
+ const _notify_bounce_suspended = async (account) => {
2866
3409
  const eb = account.account_email_bounce || {};
2867
- return await notification_msa.submit_notification({
2868
- type: 'account',
2869
- app_id: null,
2870
- uid_arr: [account._id],
2871
- topic: 'email_bounce_update_required',
2872
- params: {
2873
- recipient_email: eb.recipient || account.account_info?.email || '',
2874
- settings_url: _bounce_settings_url(),
2875
- attempt,
2876
- max,
2877
- },
2878
- });
3410
+ const host = _conf.is_debug ? process.env.XUDA_HOSTNAME || _conf.domain : _conf.domain;
3411
+ try {
3412
+ await notification_msa.submit_notification({
3413
+ type: 'account',
3414
+ app_id: null,
3415
+ uid_arr: [account._id],
3416
+ topic: 'email_bounce_update_required',
3417
+ params: {
3418
+ recipient_email: eb.recipient || account.account_info?.email || '',
3419
+ settings_url: _bounce_settings_url(),
3420
+ support_url: `https://${host}/dashboard/support`,
3421
+ },
3422
+ });
3423
+ } catch (e) { console.error('bounce user notice failed:', e.message); }
3424
+ const supers = _conf.superuser_account_ids || [];
3425
+ if (supers.length) {
3426
+ try {
3427
+ await notification_msa.submit_notification({
3428
+ type: 'account',
3429
+ app_id: null,
3430
+ uid_arr: supers,
3431
+ topic: 'email_bounce_suspended',
3432
+ system: true,
3433
+ params: {
3434
+ uid: account._id,
3435
+ account_email: account.account_info?.email || eb.recipient || '',
3436
+ recipient: eb.recipient || '',
3437
+ reason: eb.reason || '',
3438
+ status_code: eb.status_code || '',
3439
+ attempts: eb.bounce_count || 1,
3440
+ domain: host,
3441
+ },
3442
+ });
3443
+ } catch (e) { console.error('ops bounce-suspend alert failed:', e.message); }
3444
+ }
2879
3445
  };
2880
3446
 
2881
- // Hard-email-bounce flag. Called (via account_ms RPC) by email_module's
3447
+ // Hard-email-bounce handler. Called (via account_ms RPC) by email_module's
2882
3448
  // scan_platform_bounces when a platform email to this account's address bounces
2883
- // permanently (5.x.x). Records the flag and, on the FIRST transition into
2884
- // flagged, fires alert #1 immediately (attempts 2..max + suspend are driven by
2885
- // the daily sweep_email_bounces cron). Idempotent: repeated bounces for an
2886
- // already-flagged/suspended account only refresh last_bounce_ts (no re-alert).
3449
+ // permanently (5.x.x). Policy: suspend IMMEDIATELY (account_email_bounce_suspended,
3450
+ // enforced at router_module.validate_app_active_status; outbound platform email
3451
+ // to the bounced address is suppressed in notification_module). The user can
3452
+ // still log in: the dashboard shows the suspension message and they can open a
3453
+ // support ticket or update their email, which lifts the suspension instantly
3454
+ // (update_account_info) or via the daily sweep.
3455
+ // TODO(long-term): notify by SMS/voice first, wait a grace window, then suspend.
2887
3456
  export const flag_email_bounce = async function (req) {
2888
3457
  const { uid, recipient, reason, status_code, message_id, source } = req || {};
2889
3458
  if (!uid) return { code: -1, data: 'uid required' };
@@ -2893,8 +3462,7 @@ export const flag_email_bounce = async function (req) {
2893
3462
  const account = acct_ret.data;
2894
3463
  const now = Date.now();
2895
3464
  const eb = account.account_email_bounce || {};
2896
- const was_active = eb.status === 'flagged' || eb.status === 'suspended';
2897
- const max = Number(_conf.bounce_monitor?.suspend_after_alerts || 3);
3465
+ const was_suspended = eb.status === 'suspended';
2898
3466
 
2899
3467
  eb.recipient = recipient || eb.recipient;
2900
3468
  eb.reason = reason || eb.reason;
@@ -2903,49 +3471,38 @@ export const flag_email_bounce = async function (req) {
2903
3471
  eb.source = source || eb.source || 'dsn';
2904
3472
  eb.last_bounce_ts = now;
2905
3473
  eb.bounce_count = (eb.bounce_count || 0) + 1;
2906
- if (eb.status !== 'suspended') {
2907
- eb.status = 'flagged';
2908
- eb.first_bounce_ts = eb.first_bounce_ts || now;
2909
- if (!was_active) {
2910
- // Fresh flag -> immediate first alert.
2911
- eb.attempt_count = 1;
2912
- eb.first_alert_ts = now;
2913
- eb.last_attempt_ts = now;
2914
- } else if (eb.attempt_count == null) {
2915
- eb.attempt_count = 0;
2916
- }
3474
+ eb.first_bounce_ts = eb.first_bounce_ts || now;
3475
+ if (!was_suspended) {
3476
+ eb.status = 'suspended';
3477
+ eb.suspended_ts = now;
3478
+ account.account_email_bounce_suspended = 1;
2917
3479
  }
2918
3480
 
2919
3481
  account.account_email_bounce = eb;
2920
3482
  const save_ret = await db_module.save_couch_doc('xuda_accounts', account);
2921
3483
  if (save_ret.code < 0) return save_ret;
2922
3484
 
2923
- if (!was_active) {
2924
- try { await _send_bounce_alert(account, 1, max); } catch (e) { console.error('flag_email_bounce alert #1 failed:', e.message); }
2925
- }
2926
- console.log(`flag_email_bounce: ${uid} <${recipient}> ${status_code || ''} -> status=${eb.status} attempt=${eb.attempt_count} bounce_count=${eb.bounce_count}${!was_active ? ' (alert #1 sent)' : ''}`);
2927
- return { code: 1, data: { uid, status: eb.status, attempt_count: eb.attempt_count, bounce_count: eb.bounce_count } };
3485
+ if (!was_suspended) await _notify_bounce_suspended(account);
3486
+ console.log(`flag_email_bounce: ${uid} <${recipient}> ${status_code || ''} -> status=${eb.status} bounce_count=${eb.bounce_count}${!was_suspended ? ' (suspended now)' : ''}`);
3487
+ return { code: 1, data: { uid, status: eb.status, bounce_count: eb.bounce_count } };
2928
3488
  } catch (err) {
2929
3489
  console.error('flag_email_bounce error:', err);
2930
3490
  return { code: -10, data: err.message };
2931
3491
  }
2932
3492
  };
2933
3493
 
2934
- // Daily retry/escalation sweep for flagged accounts (controller cron). For each
2935
- // account with account_email_bounce.status === 'flagged':
2936
- // - if the user changed their email away from the bounced address -> CLEAR;
2937
- // - else if under the alert cap and >= alert_interval since last -> next alert;
2938
- // - else (cap reached) -> SUSPEND (account_email_bounce_suspended) + ops alert.
2939
- // `req` overrides: { interval_ms, max, limit, uids }.
3494
+ // Daily reconciliation sweep (controller cron) over bounce-affected accounts:
3495
+ // - email changed away from the bounced address -> CLEAR the suspension
3496
+ // (update_account_info already clears synchronously on the dashboard path;
3497
+ // this catches email changes made any other way);
3498
+ // - status still 'flagged' (legacy state from the pre-immediate-suspend alert
3499
+ // ladder) -> SUSPEND now.
3500
+ // `req` overrides: { limit, uids }.
2940
3501
  export const sweep_email_bounces = async function (req = {}) {
2941
- const bm = _conf.bounce_monitor || {};
2942
- const DAY_MS = 24 * 60 * 60 * 1000;
2943
- const interval = Number(req.interval_ms ?? bm.alert_interval_ms ?? DAY_MS);
2944
- const max = Number(req.max ?? bm.suspend_after_alerts ?? 3);
2945
3502
  const now = Date.now();
2946
- const summary = { flagged: 0, cleared: 0, alerted: 0, suspended: 0, skipped: 0 };
3503
+ const summary = { matched: 0, cleared: 0, suspended: 0, skipped: 0 };
2947
3504
  try {
2948
- const selector = { 'account_email_bounce.status': 'flagged' };
3505
+ const selector = { 'account_email_bounce.status': { $in: ['flagged', 'suspended'] } };
2949
3506
  if (Array.isArray(req.uids) && req.uids.length) selector._id = { $in: req.uids };
2950
3507
  const find_ret = await db_module.find_couch_query('xuda_accounts', {
2951
3508
  selector,
@@ -2953,14 +3510,14 @@ export const sweep_email_bounces = async function (req = {}) {
2953
3510
  limit: req.limit ?? 99999,
2954
3511
  });
2955
3512
  const rows = find_ret?.data?.docs || find_ret?.docs || [];
2956
- summary.flagged = rows.length;
3513
+ summary.matched = rows.length;
2957
3514
 
2958
3515
  for (const row of rows) {
2959
3516
  const acct_ret = await db_module.get_couch_doc('xuda_accounts', row._id);
2960
3517
  if (acct_ret.code < 0) continue;
2961
3518
  const account = acct_ret.data;
2962
3519
  const eb = account.account_email_bounce;
2963
- if (!eb || eb.status !== 'flagged') { summary.skipped++; continue; }
3520
+ if (!eb || (eb.status !== 'flagged' && eb.status !== 'suspended')) { summary.skipped++; continue; }
2964
3521
 
2965
3522
  // CLEAR: the user updated their email away from the bounced address.
2966
3523
  const cur_email = (account.account_info?.email || '').toLowerCase().trim();
@@ -2968,55 +3525,22 @@ export const sweep_email_bounces = async function (req = {}) {
2968
3525
  eb.status = 'cleared';
2969
3526
  eb.cleared_ts = now;
2970
3527
  account.account_email_bounce = eb;
3528
+ account.account_email_bounce_suspended = 0;
2971
3529
  await db_module.save_couch_doc('xuda_accounts', account);
2972
3530
  summary.cleared++;
2973
3531
  continue;
2974
3532
  }
2975
3533
 
2976
- // Cadence gate.
2977
- if (eb.last_attempt_ts && now - eb.last_attempt_ts < interval) { summary.skipped++; continue; }
2978
-
2979
- if ((eb.attempt_count || 0) < max) {
2980
- const next = (eb.attempt_count || 0) + 1;
2981
- eb.attempt_count = next;
2982
- eb.last_attempt_ts = now;
2983
- account.account_email_bounce = eb;
2984
- await db_module.save_couch_doc('xuda_accounts', account);
2985
- try { await _send_bounce_alert(account, next, max); } catch (e) { console.error('bounce alert failed:', e.message); }
2986
- summary.alerted++;
2987
- } else {
2988
- // SUSPEND after the alert cap. Dedicated flag (enforced at
2989
- // router_module.validate_app_active_status alongside billing
2990
- // suspension) so we never clobber account_suspension_status.
3534
+ if (eb.status === 'flagged') {
2991
3535
  eb.status = 'suspended';
2992
3536
  eb.suspended_ts = now;
2993
3537
  account.account_email_bounce = eb;
2994
3538
  account.account_email_bounce_suspended = 1;
2995
3539
  await db_module.save_couch_doc('xuda_accounts', account);
2996
-
2997
- const host = _conf.is_debug ? process.env.XUDA_HOSTNAME || _conf.domain : _conf.domain;
2998
- const supers = _conf.superuser_account_ids || [];
2999
- if (supers.length) {
3000
- try {
3001
- await notification_msa.submit_notification({
3002
- type: 'account',
3003
- app_id: null,
3004
- uid_arr: supers,
3005
- topic: 'email_bounce_suspended',
3006
- system: true,
3007
- params: {
3008
- uid: account._id,
3009
- account_email: account.account_info?.email || eb.recipient || '',
3010
- recipient: eb.recipient || '',
3011
- reason: eb.reason || '',
3012
- status_code: eb.status_code || '',
3013
- attempts: eb.attempt_count || max,
3014
- domain: host,
3015
- },
3016
- });
3017
- } catch (e) { console.error('ops bounce-suspend alert failed:', e.message); }
3018
- }
3540
+ await _notify_bounce_suspended(account);
3019
3541
  summary.suspended++;
3542
+ } else {
3543
+ summary.skipped++;
3020
3544
  }
3021
3545
  }
3022
3546
  console.log('sweep_email_bounces:', JSON.stringify(summary));
@@ -5231,3 +5755,557 @@ setTimeout(async () => {
5231
5755
  const uid = 'd39126e0e2c51ffbd1aad10709fc8335';
5232
5756
  // onboarding_completed({ uid });
5233
5757
  }, 2000);
5758
+
5759
+ // ============================================================================
5760
+ // WEEKLY NEWSLETTER ENGINE
5761
+ // ----------------------------------------------------------------------------
5762
+ // The controller_module weekly cron (master only) calls newsletter_generate,
5763
+ // which assembles one item from each content source (did_you_know / dev_articles
5764
+ // / news_articles CouchDB + a fashion promo + a feature highlight), mints a fresh
5765
+ // 10% monthly Stripe coupon, saves a NUMBERED draft issue to xuda_newsletter, and
5766
+ // emails a sample to ops (info@xuda.ai) + a superuser notification for review.
5767
+ // Nothing goes to subscribers until a superuser hits Publish in the dashboard
5768
+ // (newsletter_publish), which blasts to every preferences.newsletter_opt_in
5769
+ // account with a per-recipient unsubscribe link + List-Unsubscribe header.
5770
+ // Opt-out flips the same flag the router /newsletter/unsubscribe route reads.
5771
+ // ============================================================================
5772
+ const NEWSLETTER_DB = 'xuda_newsletter';
5773
+
5774
+ // Curated pool for the weekly coupon + "explore a feature" highlight. Ads are
5775
+ // deliberately absent — they're never discounted (and are one-off charges, so a
5776
+ // subscription coupon can't apply to them anyway).
5777
+ // gif = basename under https://xuda.ai/dist/images/email/<gif>.gif (the same
5778
+ // animated demos the onboarding email uses). Only vps/studio/contacts/ai_chats
5779
+ // exist, so features reuse the closest match.
5780
+ const NEWSLETTER_FEATURES = [
5781
+ { key: 'vps', label: 'VPS', blurb: 'Spin up a full Linux VPS on the Xuda farm in seconds.', gif: 'vps' },
5782
+ { key: 'static_website', label: 'Static Website', blurb: 'Host a blazing-fast static site with a free subdomain or your own domain.', gif: 'studio' },
5783
+ { key: 'full_stack_vps', label: 'Full Stack VPS', blurb: 'An AI-managed box that reads, writes and fixes its own files and databases.', gif: 'vps' },
5784
+ { key: 'auto_backup', label: 'Automatic Backups', blurb: 'Scheduled, restorable backups for your apps and data.', gif: 'vps' },
5785
+ { key: 'custom_domain', label: 'Custom Domains', blurb: 'Connect your own domain with automatic TLS.', gif: 'studio' },
5786
+ { key: 'drive', label: 'Xuda Drive', blurb: 'Store, share and OCR your files right inside your workspace.', gif: 'contacts' },
5787
+ { key: 'ai_workspace', label: 'AI Workspace', blurb: 'Build apps by chatting with AI agents that ship real code.', gif: 'ai_chats' },
5788
+ ];
5789
+ const _nl_gif_url = (g) => (g ? `https://xuda.ai/dist/images/email/${g}.gif` : '');
5790
+
5791
+ const _nl_esc = (v) =>
5792
+ String(v == null ? '' : v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
5793
+
5794
+ // Next issue number = max(existing) + 1. Read-only projection (never PUT these).
5795
+ const _nl_next_number = async () => {
5796
+ try {
5797
+ const ret = await db_module.find_couch_query(NEWSLETTER_DB, { selector: { docType: 'newsletter_issue' }, fields: ['issue_number'], limit: 100000 }, true);
5798
+ return (ret.docs || []).reduce((m, d) => Math.max(m, d.issue_number || 0), 0) + 1;
5799
+ } catch (e) {
5800
+ return 1;
5801
+ }
5802
+ };
5803
+
5804
+ // One random published doc of a docType from a content DB.
5805
+ const _nl_one = async (db, docType) => {
5806
+ try {
5807
+ const ret = await db_module.find_couch_query(db, { selector: { docType }, limit: 300 }, true);
5808
+ const docs = (ret.docs || []).filter((d) => d.published !== false);
5809
+ return docs.length ? docs[Math.floor(Math.random() * docs.length)] : null;
5810
+ } catch (e) {
5811
+ return null;
5812
+ }
5813
+ };
5814
+
5815
+ const _nl_fashion_url = (p) => {
5816
+ const rel = p.custom_url || (p.slug ? '/' + p.slug : '');
5817
+ return `https://xuda.fashion${rel}`;
5818
+ };
5819
+ const _nl_fashion_img = (p) => {
5820
+ const first = p.images && (p.images.front || Object.values(p.images)[0]);
5821
+ return first ? `https://xuda.fashion${first}` : '';
5822
+ };
5823
+ // A fashion product prioritized to the recipient's CITY, then COUNTRY, then a
5824
+ // featured active tee, then the classic tee (mirrors the city-merch placeholder
5825
+ // rule). Matches the city/country name inside the product title or tags.
5826
+ // `rotate` (the issue number) rotates through the matching tees so each weekly
5827
+ // issue features a different shirt for the same city/country.
5828
+ const _nl_fashion_for = (pool, country, city, rotate = 0) => {
5829
+ const CLASSIC = { title: 'XUDA.AI Classic Tee', url: 'https://xuda.fashion', img: '' };
5830
+ if (!Array.isArray(pool) || !pool.length) return CLASSIC;
5831
+ const mk = (p) => ({ title: p.title, url: _nl_fashion_url(p), img: _nl_fashion_img(p) });
5832
+ const hay = (p) => `${p.title || ''} ${(p.tags || []).join(' ')}`.toLowerCase();
5833
+ const rot = (arr) => arr[(((rotate | 0) % arr.length) + arr.length) % arr.length];
5834
+ const cty = String(city || '').trim().toLowerCase();
5835
+ const ctry = String(country || '').trim().toLowerCase();
5836
+ if (cty) {
5837
+ const hits = pool.filter((p) => hay(p).includes(cty));
5838
+ if (hits.length) return mk(rot(hits));
5839
+ }
5840
+ if (ctry && ctry.length > 2) {
5841
+ const hits = pool.filter((p) => hay(p).includes(ctry));
5842
+ if (hits.length) return mk(rot(hits));
5843
+ }
5844
+ const actives = pool.filter((p) => p.active !== false);
5845
+ const list = actives.length ? actives : pool;
5846
+ return list.length ? mk(rot(list)) : CLASSIC;
5847
+ };
5848
+
5849
+ const _nl_fashion_pool = async () => {
5850
+ try {
5851
+ const fr = await db_module.find_couch_query('xuda_website', { selector: { docType: 'xuda_fashion' }, limit: 200 }, true);
5852
+ return fr.docs || [];
5853
+ } catch (e) {
5854
+ return [];
5855
+ }
5856
+ };
5857
+
5858
+ // xuda.network partners (ambassadors + mentors) are xuda_accounts flagged
5859
+ // is_xuda_network_ambassador; the role is encoded in network_profile_url
5860
+ // (.../ambassadors/... vs .../mentors/...), keyed by network_country_code + city.
5861
+ const _nl_partner_pool = async () => {
5862
+ try {
5863
+ const r = await db_module.find_couch_query('xuda_accounts', { selector: { docType: 'account', 'account_info.is_xuda_network_ambassador': true }, limit: 500 }, true);
5864
+ return (r.docs || []).map((d) => {
5865
+ const a = d.account_info || {};
5866
+ const url = a.network_profile_url || '';
5867
+ return {
5868
+ name: [a.first_name, a.last_name].filter(Boolean).join(' ') || a.username || 'Xuda partner',
5869
+ role: /\/mentors\//i.test(url) ? 'mentor' : /\/ambassadors\//i.test(url) ? 'ambassador' : '',
5870
+ cc: (a.network_country_code || '').toLowerCase(),
5871
+ country: (a.country || '').toLowerCase(),
5872
+ city: (a.city || '').toLowerCase(),
5873
+ pic: a.profile_picture || a.profile_avatar || '',
5874
+ url,
5875
+ };
5876
+ });
5877
+ } catch (e) {
5878
+ return [];
5879
+ }
5880
+ };
5881
+
5882
+ // One ambassador + one mentor for the recipient, prioritized city → cc → country.
5883
+ const _nl_partners_for = (pool, cc, country, city) => {
5884
+ if (!Array.isArray(pool) || !pool.length) return { ambassador: null, mentor: null };
5885
+ const wcc = String(cc || '').toLowerCase();
5886
+ const wcity = String(city || '').trim().toLowerCase();
5887
+ const wctry = String(country || '').trim().toLowerCase();
5888
+ const pick = (role) => {
5889
+ const list = pool.filter((p) => p.role === role && p.url);
5890
+ if (!list.length) return null;
5891
+ return (
5892
+ (wcity && list.find((p) => p.city && p.city === wcity)) ||
5893
+ (wcc && list.find((p) => p.cc && p.cc === wcc)) ||
5894
+ (wctry && wctry.length > 1 && list.find((p) => p.country && p.country === wctry)) ||
5895
+ null
5896
+ );
5897
+ };
5898
+ return { ambassador: pick('ambassador'), mentor: pick('mentor') };
5899
+ };
5900
+
5901
+ // Ensure the account carries an opaque unsubscribe token; mint + save if missing.
5902
+ const _nl_ensure_unsub_token = async (account) => {
5903
+ if (account.newsletter_unsub_token) return account.newsletter_unsub_token;
5904
+ account.newsletter_unsub_token = crypto.randomBytes(16).toString('hex');
5905
+ try {
5906
+ await db_module.save_couch_doc('xuda_accounts', account);
5907
+ } catch (e) {}
5908
+ return account.newsletter_unsub_token;
5909
+ };
5910
+
5911
+ // Render the issue to rich HTML for one recipient (fashion + unsubscribe +
5912
+ // greeting are per-recipient; the rest is shared issue content). Design language
5913
+ // mirrors the onboarding email: image cards, a dark gradient feature card, a
5914
+ // bright coupon hero, emoji section labels, #0091FF buttons. Sits inside the
5915
+ // platform email_template.html wrapper (which adds the XUDA logo + ~500px card).
5916
+ const _nl_titlecase = (s) => String(s || '').replace(/\b\w/g, (m) => m.toUpperCase());
5917
+ const _nl_avatar_url = (host, pic) => {
5918
+ const p = String(pic || '');
5919
+ if (/^https?:\/\//i.test(p)) return p;
5920
+ if (p.startsWith('/')) return `https://xuda.ai${p}`;
5921
+ return '';
5922
+ };
5923
+
5924
+ const _nl_render_email = (issue, { host, first_name, fashion, partners, unsub_url }) => {
5925
+ const c = issue.content || {};
5926
+ const esc = _nl_esc;
5927
+ const btn = (href, txt, bg = '#0091FF', fg = '#fff') =>
5928
+ `<a href="${esc(href)}" style="display:inline-block;background:${bg};color:${fg};text-decoration:none;padding:11px 24px;border-radius:9px;font-weight:700;font-size:14px">${esc(txt)} &rarr;</a>`;
5929
+
5930
+ // A white content card with an optional top image. imgFit 'cover' crops to a
5931
+ // banner; 'contain' shows the WHOLE image (used for the t-shirt) on imgBg.
5932
+ const card = ({ img, imgAlt, emoji, label, title, href, text, btnText, btnHref, imgFit = 'cover', imgBg = '#f4f6fa', imgMaxH = '210px' }) => {
5933
+ let image = '';
5934
+ if (img) {
5935
+ const inner = imgFit === 'contain'
5936
+ ? `<div style="background:${imgBg};text-align:center;padding:16px"><img src="${esc(img)}" alt="${esc(imgAlt || '')}" style="display:inline-block;max-width:100%;max-height:${imgMaxH};height:auto"></div>`
5937
+ : `<img src="${esc(img)}" alt="${esc(imgAlt || '')}" width="100%" style="display:block;width:100%;max-height:${imgMaxH};object-fit:cover">`;
5938
+ image = `<a href="${esc(href || btnHref || '#')}" style="text-decoration:none">${inner}</a>`;
5939
+ }
5940
+ const titleHtml = href ? `<a href="${esc(href)}" style="color:#0b1220;text-decoration:none">${esc(title)}</a>` : esc(title);
5941
+ return `
5942
+ <table cellpadding="0" cellspacing="0" width="100%" style="border-collapse:separate;margin:0 0 16px">
5943
+ <tr><td style="background:#ffffff;border:1px solid #e9edf3;border-radius:14px;overflow:hidden">
5944
+ ${image}
5945
+ <div style="padding:18px 20px">
5946
+ <div style="font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:#0091FF;font-weight:700;margin-bottom:7px">${emoji} ${esc(label)}</div>
5947
+ <div style="font-size:18px;font-weight:700;line-height:1.28;color:#0b1220;margin-bottom:7px">${titleHtml}</div>
5948
+ <p style="color:#475569;font-size:14px;line-height:1.55;margin:0 0 15px">${esc(text)}</p>
5949
+ ${btn(btnHref, btnText)}
5950
+ </div>
5951
+ </td></tr>
5952
+ </table>`;
5953
+ };
5954
+
5955
+ const parts = [];
5956
+
5957
+ // Coupon — bright hero offer, leads the issue.
5958
+ if (c.coupon) {
5959
+ parts.push(`
5960
+ <table cellpadding="0" cellspacing="0" width="100%" style="border-collapse:separate;margin:0 0 20px">
5961
+ <tr><td style="background:linear-gradient(135deg,#0091FF,#0b57c9);border-radius:16px;padding:24px 22px;text-align:center">
5962
+ <div style="display:inline-block;background:rgba(255,255,255,.20);color:#fff;font-size:11px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;padding:5px 13px;border-radius:20px;margin-bottom:12px">🎁 This week only</div>
5963
+ <div style="font-size:24px;font-weight:800;color:#fff;line-height:1.15;margin:0 0 4px">${esc(c.coupon.percent_off)}% off ${esc(c.coupon.feature)}</div>
5964
+ <p style="color:#dcefff;font-size:14px;margin:0 0 16px">for ${esc(c.coupon.duration_in_months)} month${c.coupon.duration_in_months > 1 ? 's' : ''}. Use this code at checkout</p>
5965
+ <div style="display:inline-block;background:#ffffff;color:#0b1220;font-family:'SF Mono',Menlo,Consolas,monospace;font-size:20px;font-weight:800;letter-spacing:2px;padding:12px 24px;border-radius:10px;margin-bottom:18px">${esc(c.coupon.promo_code)}</div>
5966
+ <div><a href="https://${esc(host)}/dashboard" style="display:inline-block;background:#0b1220;color:#fff;text-decoration:none;padding:12px 28px;border-radius:9px;font-weight:700;font-size:14px">Claim offer &rarr;</a></div>
5967
+ </td></tr>
5968
+ </table>`);
5969
+ }
5970
+
5971
+ // Did you know — tip image hosted on prod (always loads in mail).
5972
+ if (c.tip) {
5973
+ parts.push(card({
5974
+ img: c.tip.image ? `https://xuda.ai${c.tip.image}` : '',
5975
+ imgAlt: c.tip.title, emoji: '💡', label: 'Did you know?',
5976
+ title: c.tip.title, href: c.tip.url, text: c.tip.text,
5977
+ btnText: 'Try it', btnHref: c.tip.url || `https://${host}/dashboard`,
5978
+ }));
5979
+ }
5980
+
5981
+ // Tip of the week — a compact accent callout (a second, distinct tip).
5982
+ if (c.tip2) {
5983
+ parts.push(`
5984
+ <table cellpadding="0" cellspacing="0" width="100%" style="border-collapse:separate;margin:0 0 16px">
5985
+ <tr><td style="background:#eff6ff;border-left:4px solid #0091FF;border-radius:10px;padding:16px 18px">
5986
+ <div style="font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:#0b66c2;font-weight:700;margin-bottom:5px">⭐ Tip of the week</div>
5987
+ <div style="font-size:16px;font-weight:700;color:#0b1220;margin:0 0 5px">${esc(c.tip2.title)}</div>
5988
+ <p style="color:#334155;font-size:14px;line-height:1.55;margin:0 0 ${c.tip2.url ? '12px' : '0'}">${esc(c.tip2.text)}</p>
5989
+ ${c.tip2.url ? btn(c.tip2.url, 'See how') : ''}
5990
+ </td></tr>
5991
+ </table>`);
5992
+ }
5993
+
5994
+ // News — DARK box; big hero image (SSR /news/<slug>/hero.png) full width on top.
5995
+ if (c.news) {
5996
+ const nurl = `https://${host}/news/${c.news.slug}`;
5997
+ parts.push(`
5998
+ <table cellpadding="0" cellspacing="0" width="100%" style="border-collapse:separate;margin:0 0 16px">
5999
+ <tr><td style="background:linear-gradient(160deg,#0b1220,#10213b);border-radius:14px;overflow:hidden">
6000
+ <a href="${esc(nurl)}" style="text-decoration:none"><img src="https://${esc(host)}/news/${encodeURIComponent(c.news.slug)}/hero.png" alt="${esc(c.news.headline)}" width="100%" style="display:block;width:100%;max-height:230px;object-fit:cover"></a>
6001
+ <div style="padding:18px 20px">
6002
+ <div style="font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:#66BDFF;font-weight:700;margin-bottom:7px">📰 News</div>
6003
+ <div style="font-size:18px;font-weight:700;line-height:1.28;margin-bottom:7px"><a href="${esc(nurl)}" style="color:#ffffff;text-decoration:none">${esc(c.news.headline)}</a></div>
6004
+ <p style="color:#cbd5e1;font-size:14px;line-height:1.55;margin:0 0 15px">${esc(c.news.deck || c.news.description)}</p>
6005
+ <a href="${esc(nurl)}" style="display:inline-block;background:#0091FF;color:#fff;text-decoration:none;padding:11px 24px;border-radius:9px;font-weight:700;font-size:14px">Read the story &rarr;</a>
6006
+ </div>
6007
+ </td></tr>
6008
+ </table>`);
6009
+ }
6010
+
6011
+ // From the blog — no image; clean card.
6012
+ if (c.dev) {
6013
+ parts.push(card({
6014
+ emoji: '📖', label: 'From the blog',
6015
+ title: c.dev.title, href: `https://${host}/blog/${c.dev.slug}`,
6016
+ text: c.dev.description,
6017
+ btnText: 'Read the guide', btnHref: `https://${host}/blog/${c.dev.slug}`,
6018
+ }));
6019
+ }
6020
+
6021
+ // Explore a feature — dark gradient card with an animated GIF demo.
6022
+ if (c.feature) {
6023
+ const gif = c.feature.gif ? _nl_gif_url(c.feature.gif) : '';
6024
+ parts.push(`
6025
+ <table cellpadding="0" cellspacing="0" width="100%" style="border-collapse:separate;margin:0 0 16px">
6026
+ <tr><td style="background:linear-gradient(160deg,#0b1220,#10213b);border-radius:14px;padding:22px">
6027
+ <div style="display:inline-block;background:#0091FF;color:#fff;font-size:11px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;padding:4px 11px;border-radius:20px;margin-bottom:14px">⚡ Explore a feature</div>
6028
+ ${gif ? `<a href="https://${esc(host)}/dashboard" style="text-decoration:none"><img src="${esc(gif)}" alt="${esc(c.feature.label)}" width="100%" style="display:block;width:100%;border-radius:10px;border:1px solid #22344f;margin:0 0 14px"></a>` : ''}
6029
+ <div style="font-size:20px;font-weight:700;color:#ffffff;margin:0 0 6px">${esc(c.feature.label)}</div>
6030
+ <p style="color:#cbd5e1;font-size:14px;line-height:1.55;margin:0 0 16px">${esc(c.feature.blurb)}</p>
6031
+ <a href="https://${esc(host)}/dashboard" style="display:inline-block;background:#0091FF;color:#fff;text-decoration:none;padding:11px 24px;border-radius:9px;font-weight:700;font-size:14px">Explore &rarr;</a>
6032
+ </td></tr>
6033
+ </table>`);
6034
+ }
6035
+
6036
+ // Wear the brand — DARK box so the dark-background tee blends edge to edge; the
6037
+ // full-bleed image shows the WHOLE tee. City/country-first, rotates per issue.
6038
+ if (fashion) {
6039
+ const teeImg = fashion.img
6040
+ ? `<a href="${esc(fashion.url)}" style="text-decoration:none"><img src="${esc(fashion.img)}" alt="${esc(fashion.title)}" width="100%" style="display:block;width:100%;height:auto"></a>`
6041
+ : '';
6042
+ parts.push(`
6043
+ <table cellpadding="0" cellspacing="0" width="100%" style="border-collapse:separate;margin:0 0 16px">
6044
+ <tr><td style="background:linear-gradient(160deg,#160f2b,#0b1220);border-radius:14px;overflow:hidden">
6045
+ ${teeImg}
6046
+ <div style="padding:16px 20px 20px">
6047
+ <div style="font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:#66BDFF;font-weight:700;margin-bottom:7px">👕 Wear the brand</div>
6048
+ <div style="font-size:18px;font-weight:700;line-height:1.28;margin-bottom:7px"><a href="${esc(fashion.url)}" style="color:#ffffff;text-decoration:none">${esc(fashion.title)}</a></div>
6049
+ <p style="color:#cbd5e1;font-size:14px;line-height:1.55;margin:0 0 15px">Rep Xuda in your city. Premium tees, shipped worldwide.</p>
6050
+ <a href="${esc(fashion.url)}" style="display:inline-block;background:#0091FF;color:#fff;text-decoration:none;padding:11px 24px;border-radius:9px;font-weight:700;font-size:14px">Shop xuda.fashion &rarr;</a>
6051
+ </div>
6052
+ </td></tr>
6053
+ </table>`);
6054
+ }
6055
+
6056
+ // Your local Xuda team — moved to the bottom, each partner on its own bigger
6057
+ // card (avatar left, details + profile link right).
6058
+ const amb = partners && partners.ambassador;
6059
+ const men = partners && partners.mentor;
6060
+ if (amb || men) {
6061
+ const person_card = (p) => {
6062
+ if (!p) return '';
6063
+ const av = _nl_avatar_url(host, p.pic);
6064
+ const avatar = av
6065
+ ? `<img src="${esc(av)}" alt="${esc(p.name)}" width="76" height="76" style="border-radius:50%;object-fit:cover;border:2px solid #e9edf3;display:block">`
6066
+ : `<div style="width:76px;height:76px;border-radius:50%;background:#0091FF;color:#fff;font-size:30px;font-weight:800;line-height:76px;text-align:center">${esc((p.name || '?').slice(0, 1))}</div>`;
6067
+ const role = _nl_titlecase(p.role || 'Partner');
6068
+ const emoji = p.role === 'mentor' ? '🧭' : '📣';
6069
+ const where = p.city ? `${role} in ${esc(_nl_titlecase(p.city))}` : role;
6070
+ return `
6071
+ <table cellpadding="0" cellspacing="0" width="100%" style="border-collapse:separate;margin:0 0 12px">
6072
+ <tr><td style="background:#ffffff;border:1px solid #e9edf3;border-radius:14px;padding:18px 20px">
6073
+ <table cellpadding="0" cellspacing="0" width="100%"><tr>
6074
+ <td width="88" style="vertical-align:middle">${avatar}</td>
6075
+ <td style="vertical-align:middle;padding-left:16px">
6076
+ <div style="font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:#0091FF;font-weight:700;margin-bottom:3px">${emoji} ${esc(where)}</div>
6077
+ <div style="font-size:19px;font-weight:800;color:#0b1220;margin:0 0 10px">${esc(p.name)}</div>
6078
+ <a href="${esc(p.url)}" style="display:inline-block;background:#eff6ff;color:#0b66c2;text-decoration:none;padding:8px 16px;border-radius:8px;font-weight:700;font-size:13px">View profile &rarr;</a>
6079
+ </td>
6080
+ </tr></table>
6081
+ </td></tr>
6082
+ </table>`;
6083
+ };
6084
+ parts.push(`<p style="text-align:center;font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:#0091FF;font-weight:700;margin:6px 0 4px">🤝 Your local Xuda team</p>
6085
+ <p style="text-align:center;color:#94a3b8;font-size:13px;margin:0 0 14px">Your regional ambassador and mentor are here to help you build.</p>`);
6086
+ if (amb) parts.push(person_card(amb));
6087
+ if (men) parts.push(person_card(men));
6088
+ }
6089
+
6090
+ const greet = first_name ? `Hi ${esc(first_name)},` : 'Hi there,';
6091
+ let dateStr = '';
6092
+ try { dateStr = new Date(issue.created_ts || Date.now()).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }); } catch (e) {}
6093
+
6094
+ return `
6095
+ <div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif">
6096
+ <div style="text-align:center;margin:0 0 8px">
6097
+ <span style="display:inline-block;background:#eff6ff;color:#0b66c2;font-size:11px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;padding:5px 13px;border-radius:20px">Issue #${esc(issue.issue_number)}${dateStr ? ' &middot; ' + esc(dateStr) : ''}</span>
6098
+ </div>
6099
+ <h1 style="text-align:center;font-size:28px;font-weight:800;color:#0b1220;margin:0 0 8px">Xuda Weekly</h1>
6100
+ <p style="text-align:center;color:#475569;font-size:15px;line-height:1.55;margin:0 auto 24px;max-width:400px"><strong style="color:#0b1220">${greet}</strong> here's what's new at Xuda this week: a quick tip, fresh reads, a feature to try, and this week's deal.</p>
6101
+ ${parts.join('')}
6102
+ <p style="text-align:center;color:#94a3b8;font-size:12px;line-height:1.7;margin:26px 0 0;border-top:1px solid #e9edf3;padding-top:18px">You're receiving this because you opted in to Xuda updates.<br><a href="${esc(unsub_url)}" style="color:#94a3b8;text-decoration:underline">Unsubscribe</a> &nbsp;&middot;&nbsp; <a href="https://${esc(host)}/dashboard" style="color:#94a3b8;text-decoration:underline">Your account</a></p>
6103
+ </div>`;
6104
+ };
6105
+
6106
+ // Demo recipient for previews/samples. Uses a real region (Berlin/DE) so the
6107
+ // city/country-targeted sections — fashion + the local ambassador/mentor — render
6108
+ // instead of falling back, and a demo first name so personalization is visible.
6109
+ const _nl_sample_recipient = (host) => ({
6110
+ host,
6111
+ first_name: 'Alex',
6112
+ cc: 'de',
6113
+ country: 'Germany',
6114
+ city: 'Berlin',
6115
+ fashion: { title: 'XUDA.AI Classic Tee', url: 'https://xuda.fashion', img: '' },
6116
+ partners: { ambassador: null, mentor: null },
6117
+ unsub_url: `https://${host}/newsletter/unsubscribe?u=SAMPLE&t=SAMPLE`,
6118
+ });
6119
+
6120
+ const _newsletter_send_sample_internal = async (issue, to_email) => {
6121
+ const host = _ops_host();
6122
+ const [fpool, ppool] = await Promise.all([_nl_fashion_pool(), _nl_partner_pool()]);
6123
+ const rcpt = _nl_sample_recipient(host);
6124
+ rcpt.fashion = _nl_fashion_for(fpool, rcpt.country, rcpt.city, issue.issue_number);
6125
+ rcpt.partners = _nl_partners_for(ppool, rcpt.cc, rcpt.country, rcpt.city);
6126
+ const html = _nl_render_email(issue, rcpt);
6127
+ // email_ms (request/response) so we get a real {code} back — email_msa is
6128
+ // fire-and-forget and would report undefined.
6129
+ return email_ms.send_email({ email: to_email, subject: `[SAMPLE] ${issue.subject}`, body: html, display_type: 'info' });
6130
+ };
6131
+
6132
+ // Build the weekly draft: content + coupon + numbered issue, then sample to ops.
6133
+ // req.system=true when called by the cron (skips the superuser gate).
6134
+ export const newsletter_generate = async function (req = {}) {
6135
+ try {
6136
+ if (!req.system && !_ops_is_super(req)) return { code: -403, data: 'superuser only' };
6137
+
6138
+ // Two DISTINCT tips: one for "Did you know?", one for "Tip of the week".
6139
+ const _two_tips = async () => {
6140
+ try {
6141
+ const ret = await db_module.find_couch_query('xuda_did_you_know', { selector: { docType: 'did_you_know' }, limit: 300 }, true);
6142
+ const docs = (ret.docs || []).filter((d) => d.published !== false);
6143
+ if (!docs.length) return [null, null];
6144
+ const i = Math.floor(Math.random() * docs.length);
6145
+ let j = i;
6146
+ if (docs.length > 1) { while (j === i) j = Math.floor(Math.random() * docs.length); }
6147
+ return [docs[i], docs.length > 1 ? docs[j] : null];
6148
+ } catch (e) { return [null, null]; }
6149
+ };
6150
+ const [[tip, tip2], dev, news] = await Promise.all([
6151
+ _two_tips(),
6152
+ _nl_one('xuda_dev_articles', 'dev_article'),
6153
+ _nl_one('xuda_news_articles', 'news_article'),
6154
+ ]);
6155
+ const feature = NEWSLETTER_FEATURES[Math.floor(Math.random() * NEWSLETTER_FEATURES.length)];
6156
+
6157
+ let coupon = null;
6158
+ try {
6159
+ const cret = await stripe_ms.newsletter_create_coupon({ percent_off: 10, duration_in_months: 1, feature: feature.label });
6160
+ if (cret && cret.code > 0) coupon = cret.data;
6161
+ else console.error('[newsletter] coupon create failed:', cret && cret.data);
6162
+ } catch (e) {
6163
+ console.error('[newsletter] coupon error:', e.message);
6164
+ }
6165
+
6166
+ const issue_number = await _nl_next_number();
6167
+ const subject = `Xuda Weekly #${issue_number}${coupon ? `: ${coupon.percent_off}% off ${coupon.feature}` : ''}`;
6168
+ const issue = {
6169
+ _id: `nl_issue_${issue_number}`,
6170
+ docType: 'newsletter_issue',
6171
+ issue_number,
6172
+ status: 'draft',
6173
+ subject,
6174
+ created_ts: Date.now(),
6175
+ content: {
6176
+ tip: tip ? { title: tip.title, text: tip.text, url: tip.url, slug: tip.slug, image: tip.image } : null,
6177
+ tip2: tip2 ? { title: tip2.title, text: tip2.text, url: tip2.url, slug: tip2.slug } : null,
6178
+ dev: dev ? { title: dev.title, description: dev.description, slug: dev.slug } : null,
6179
+ news: news ? { headline: news.headline, deck: news.deck, description: news.description, slug: news.slug } : null,
6180
+ feature: { key: feature.key, label: feature.label, blurb: feature.blurb, gif: feature.gif },
6181
+ coupon,
6182
+ },
6183
+ sent: { count: 0, failed: 0 },
6184
+ };
6185
+ const save = await db_module.save_couch_doc(NEWSLETTER_DB, issue);
6186
+ if (save.code < 0) return { code: -1, data: save.data };
6187
+
6188
+ // Sample to ops for review — before anything reaches subscribers.
6189
+ try { await _newsletter_send_sample_internal(issue, 'info@xuda.ai'); } catch (e) { console.error('[newsletter] sample failed:', e.message); }
6190
+ _ops_notify_ops(
6191
+ `Newsletter draft #${issue_number} ready to review`,
6192
+ [['Issue', '#' + issue_number], ['Subject', subject], ['Coupon', coupon ? `${coupon.promo_code} (${coupon.percent_off}% ${coupon.feature})` : '(none)'], ['Review', 'a [SAMPLE] copy was emailed to info@xuda.ai'], ['Publish', 'Admin → Newsletter Manager']],
6193
+ 'NL-' + issue_number,
6194
+ );
6195
+ try { logs_msa.add_account_log({ uid: req.uid || 'system', method: 'newsletter_generate', source: 'newsletter', response_status_code: 200, response_data: `draft #${issue_number}` }); } catch (e) {}
6196
+ return { code: 1, data: { issue_number, id: issue._id, subject, coupon } };
6197
+ } catch (err) {
6198
+ return { code: -1, data: err.message };
6199
+ }
6200
+ };
6201
+
6202
+ export const newsletter_list_issues = async function (req = {}) {
6203
+ try {
6204
+ if (!_ops_is_super(req)) return { code: -403, data: 'superuser only' };
6205
+ const ret = await db_module.find_couch_query(NEWSLETTER_DB, { selector: { docType: 'newsletter_issue' }, limit: 500 }, true);
6206
+ const rows = (ret.docs || [])
6207
+ .sort((a, b) => (b.issue_number || 0) - (a.issue_number || 0))
6208
+ .map((d) => ({ id: d._id, issue_number: d.issue_number, status: d.status, subject: d.subject, created_ts: d.created_ts, published_ts: d.published_ts || null, coupon: d.content && d.content.coupon ? d.content.coupon.promo_code : null, sent: d.sent || { count: 0, failed: 0 } }));
6209
+ return { code: 1, data: { rows } };
6210
+ } catch (err) {
6211
+ return { code: -1, data: err.message };
6212
+ }
6213
+ };
6214
+
6215
+ export const newsletter_preview = async function (req = {}) {
6216
+ try {
6217
+ if (!_ops_is_super(req)) return { code: -403, data: 'superuser only' };
6218
+ const id = req.id || `nl_issue_${req.issue_number}`;
6219
+ const ret = await db_module.get_couch_doc(NEWSLETTER_DB, id);
6220
+ if (ret.code < 0 || !ret.data) return { code: -1, data: 'issue not found' };
6221
+ const host = _ops_host();
6222
+ const [fpool, ppool] = await Promise.all([_nl_fashion_pool(), _nl_partner_pool()]);
6223
+ const rcpt = _nl_sample_recipient(host);
6224
+ rcpt.fashion = _nl_fashion_for(fpool, rcpt.country, rcpt.city, ret.data.issue_number);
6225
+ rcpt.partners = _nl_partners_for(ppool, rcpt.cc, rcpt.country, rcpt.city);
6226
+ const html = _nl_render_email(ret.data, rcpt);
6227
+ return { code: 1, data: { issue_number: ret.data.issue_number, subject: ret.data.subject, status: ret.data.status, html } };
6228
+ } catch (err) {
6229
+ return { code: -1, data: err.message };
6230
+ }
6231
+ };
6232
+
6233
+ export const newsletter_send_sample = async function (req = {}) {
6234
+ try {
6235
+ if (!_ops_is_super(req)) return { code: -403, data: 'superuser only' };
6236
+ const id = req.id || `nl_issue_${req.issue_number}`;
6237
+ const ret = await db_module.get_couch_doc(NEWSLETTER_DB, id);
6238
+ if (ret.code < 0 || !ret.data) return { code: -1, data: 'issue not found' };
6239
+ const to = req.email || 'info@xuda.ai';
6240
+ const sr = await _newsletter_send_sample_internal(ret.data, to);
6241
+ return { code: 1, data: { sent_to: to, email_code: sr && sr.code } };
6242
+ } catch (err) {
6243
+ return { code: -1, data: err.message };
6244
+ }
6245
+ };
6246
+
6247
+ // The blast runs in the account_module worker AFTER newsletter_publish returns,
6248
+ // so the admin HTTP call doesn't block on the whole send.
6249
+ const _newsletter_blast = async (issue, by_uid) => {
6250
+ const host = _ops_host();
6251
+ const [pool, ppool] = await Promise.all([_nl_fashion_pool(), _nl_partner_pool()]);
6252
+ let sent = 0, failed = 0, total = 0;
6253
+ try {
6254
+ const rret = await db_module.find_couch_query('xuda_accounts', { selector: { docType: 'account', stat: 3, 'preferences.newsletter_opt_in': true }, limit: 100000 }, true);
6255
+ const recipients = rret.docs || [];
6256
+ total = recipients.length;
6257
+ for (const acct of recipients) {
6258
+ const email = acct.account_info && acct.account_info.email;
6259
+ if (!email) continue;
6260
+ try {
6261
+ const token = await _nl_ensure_unsub_token(acct);
6262
+ const unsub_url = `https://${host}/newsletter/unsubscribe?u=${encodeURIComponent(acct._id)}&t=${encodeURIComponent(token)}`;
6263
+ const ai = acct.account_info || {};
6264
+ const fashion = _nl_fashion_for(pool, ai.country, ai.city, issue.issue_number);
6265
+ const partners = _nl_partners_for(ppool, ai.network_country_code, ai.country, ai.city);
6266
+ const html = _nl_render_email(issue, { host, first_name: ai.first_name, fashion, partners, unsub_url });
6267
+ // email_ms (request/response) returns a real {code} for accurate counts.
6268
+ const er = await email_ms.send_email({ email, subject: issue.subject, body: html, display_type: 'info', headers: { 'List-Unsubscribe': `<${unsub_url}>`, 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click' } });
6269
+ if (er && er.code > 0) sent++; else failed++;
6270
+ } catch (e) {
6271
+ failed++;
6272
+ }
6273
+ }
6274
+ } catch (e) {
6275
+ console.error('[newsletter] blast query failed:', e.message);
6276
+ }
6277
+ try {
6278
+ const fresh = await db_module.get_couch_doc_native(NEWSLETTER_DB, issue._id);
6279
+ fresh.status = 'published';
6280
+ fresh.published_ts = Date.now();
6281
+ fresh.published_by = by_uid;
6282
+ fresh.sent = { count: sent, failed };
6283
+ await db_module.save_couch_doc(NEWSLETTER_DB, fresh);
6284
+ } catch (e) {
6285
+ console.error('[newsletter] finalize failed:', e.message);
6286
+ }
6287
+ _ops_notify_ops(`Newsletter #${issue.issue_number} published`, [['Issue', '#' + issue.issue_number], ['Sent', sent], ['Failed', failed], ['Recipients', total]], 'NL-DONE-' + issue.issue_number);
6288
+ };
6289
+
6290
+ export const newsletter_publish = async function (req = {}) {
6291
+ try {
6292
+ if (!_ops_is_super(req)) return { code: -403, data: 'superuser only' };
6293
+ const id = req.id || `nl_issue_${req.issue_number}`;
6294
+ const gret = await db_module.get_couch_doc(NEWSLETTER_DB, id);
6295
+ if (gret.code < 0 || !gret.data) return { code: -1, data: 'issue not found' };
6296
+ const issue = gret.data;
6297
+ if (issue.status === 'published' && !req.force) return { code: -1, data: 'already published (pass force to resend)' };
6298
+ if (issue.status === 'publishing' && !req.force) return { code: -1, data: 'already publishing' };
6299
+
6300
+ // Ops alert BEFORE the blast, then mark publishing and kick the send
6301
+ // detached so this HTTP call returns immediately.
6302
+ _ops_notify_ops(`Publishing newsletter #${issue.issue_number}`, [['Issue', '#' + issue.issue_number], ['Subject', issue.subject], ['By', req.uid]], 'NL-PUB-' + issue.issue_number);
6303
+ issue.status = 'publishing';
6304
+ issue.publish_started_ts = Date.now();
6305
+ await db_module.save_couch_doc(NEWSLETTER_DB, issue);
6306
+ _newsletter_blast(issue, req.uid).catch((e) => console.error('[newsletter] blast crashed:', e.message));
6307
+ return { code: 1, data: { issue_number: issue.issue_number, status: 'publishing' } };
6308
+ } catch (err) {
6309
+ return { code: -1, data: err.message };
6310
+ }
6311
+ };