@xuda.io/account_module 1.2.2280 → 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
 
@@ -375,7 +497,9 @@ export const increment_account_usage = async function (req) {
375
497
  return;
376
498
  }
377
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;
378
- 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) {
379
503
  var price_per_hour = (bytesToGB(drive_size_total) * _conf.PRICE_OBJ.price_per_gb_drive) / _conf.PRICE_OBJ.avg_hours_in_month;
380
504
 
381
505
  doc.hours++;
@@ -603,6 +727,17 @@ const is_admin_email = (email) => {
603
727
  return ADMIN_EMAILS.has(String(email).toLowerCase());
604
728
  };
605
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
+
606
741
  // Get the active billing cycle bounds for an account, on demand from
607
742
  // Stripe. Used by recompute_abuse_signals to scope its "this cycle"
608
743
  // counters correctly. Returns { cycle_start_ts, cycle_end_ts } in ms
@@ -761,7 +896,7 @@ export const get_flagged_accounts = async function (req) {
761
896
  if (!caller_uid) return { code: -1, data: 'unauthorized' };
762
897
  try {
763
898
  const caller_ret = await db_module.get_couch_doc('xuda_accounts', caller_uid);
764
- 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)) {
765
900
  return { code: -1, data: 'unauthorized' };
766
901
  }
767
902
 
@@ -807,7 +942,7 @@ export const sweep_abuse_signals = async function (req) {
807
942
  if (!caller_uid) return { code: -1, data: 'unauthorized' };
808
943
  try {
809
944
  const caller_ret = await db_module.get_couch_doc('xuda_accounts', caller_uid);
810
- 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)) {
811
946
  return { code: -1, data: 'unauthorized' };
812
947
  }
813
948
 
@@ -846,7 +981,7 @@ export const mark_account_reviewed = async function (req) {
846
981
  if (!caller_uid || !target_uid) return { code: -1, data: 'uid + target_uid required' };
847
982
  try {
848
983
  const caller_ret = await db_module.get_couch_doc('xuda_accounts', caller_uid);
849
- 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)) {
850
985
  return { code: -1, data: 'unauthorized' };
851
986
  }
852
987
 
@@ -972,6 +1107,57 @@ export const backfill_app_costs = async function (opts = {}) {
972
1107
  return summary;
973
1108
  };
974
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
+
975
1161
  export const get_account_data = async function (req) {
976
1162
  var { uid, enforce_usage } = req;
977
1163
 
@@ -1018,6 +1204,11 @@ export const get_account_data = async function (req) {
1018
1204
  account_billing_hold_data: acc_obj.account_billing_hold_date,
1019
1205
  account_termination_status: acc_obj.account_termination_status,
1020
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 || '' : '',
1021
1212
  },
1022
1213
  drive_usage: {
1023
1214
  user_drive_size: acc_obj.user_drive_size || 0,
@@ -1031,6 +1222,9 @@ export const get_account_data = async function (req) {
1031
1222
 
1032
1223
  stripe_connect_account_id: acc_obj?.stripe_connect_account_obj?.id,
1033
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),
1034
1228
  };
1035
1229
 
1036
1230
  return ret;
@@ -1043,6 +1237,86 @@ export const get_account_data = async function (req) {
1043
1237
  // return get_info();
1044
1238
  };
1045
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
+
1046
1320
  export const get_account_projects = async function (req) {
1047
1321
  const { uid } = req;
1048
1322
  var opt = {
@@ -1120,6 +1394,25 @@ export const get_account_static_websites = async function (req) {
1120
1394
  }
1121
1395
  };
1122
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
+
1123
1416
  export const get_account_info = async function (req) {
1124
1417
  return await get_account_data({
1125
1418
  uid: req.uid,
@@ -1313,6 +1606,172 @@ export const ops_list_terminations = async function (req = {}) {
1313
1606
  } catch (err) { return { code: -1, data: err.message }; }
1314
1607
  };
1315
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
+
1316
1775
  const TIPS_FILE_PATH = path.join(process.env.XUDA_HOME, 'dist', 'tips', 'tips.json');
1317
1776
  let _did_you_know_tips_cache = null;
1318
1777
  let _did_you_know_tips_cache_mtime = 0;
@@ -1692,10 +2151,10 @@ export const validate_user_plan = async function (req) {
1692
2151
  // group_level: 2,
1693
2152
  });
1694
2153
 
1695
- 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) {
1696
2155
  return {
1697
2156
  code: -9,
1698
- 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`,
1699
2158
  };
1700
2159
  }
1701
2160
 
@@ -1771,6 +2230,83 @@ export const add_account_log_util = async function (body, status, service, sourc
1771
2230
  logs_msa.add_account_log(get_account_log_object(body, status, service, source, response_data, ip, headers, security));
1772
2231
  };
1773
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
+
1774
2310
  export const save_ssh_key = async function (req) {
1775
2311
  const { uid, _id, ssh_key_name, ssh_key_content } = req;
1776
2312
 
@@ -2866,30 +3402,57 @@ const _bounce_settings_url = () => {
2866
3402
  return `https://${host}/dashboard/settings`;
2867
3403
  };
2868
3404
 
2869
- // Alert the user to update their email (banner + SMS per the topic's delivery
2870
- // methods). SMS is gated/log-only in notification_module until sms_enabled.
2871
- 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) => {
2872
3409
  const eb = account.account_email_bounce || {};
2873
- return await notification_msa.submit_notification({
2874
- type: 'account',
2875
- app_id: null,
2876
- uid_arr: [account._id],
2877
- topic: 'email_bounce_update_required',
2878
- params: {
2879
- recipient_email: eb.recipient || account.account_info?.email || '',
2880
- settings_url: _bounce_settings_url(),
2881
- attempt,
2882
- max,
2883
- },
2884
- });
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
+ }
2885
3445
  };
2886
3446
 
2887
- // 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
2888
3448
  // scan_platform_bounces when a platform email to this account's address bounces
2889
- // permanently (5.x.x). Records the flag and, on the FIRST transition into
2890
- // flagged, fires alert #1 immediately (attempts 2..max + suspend are driven by
2891
- // the daily sweep_email_bounces cron). Idempotent: repeated bounces for an
2892
- // 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.
2893
3456
  export const flag_email_bounce = async function (req) {
2894
3457
  const { uid, recipient, reason, status_code, message_id, source } = req || {};
2895
3458
  if (!uid) return { code: -1, data: 'uid required' };
@@ -2899,8 +3462,7 @@ export const flag_email_bounce = async function (req) {
2899
3462
  const account = acct_ret.data;
2900
3463
  const now = Date.now();
2901
3464
  const eb = account.account_email_bounce || {};
2902
- const was_active = eb.status === 'flagged' || eb.status === 'suspended';
2903
- const max = Number(_conf.bounce_monitor?.suspend_after_alerts || 3);
3465
+ const was_suspended = eb.status === 'suspended';
2904
3466
 
2905
3467
  eb.recipient = recipient || eb.recipient;
2906
3468
  eb.reason = reason || eb.reason;
@@ -2909,49 +3471,38 @@ export const flag_email_bounce = async function (req) {
2909
3471
  eb.source = source || eb.source || 'dsn';
2910
3472
  eb.last_bounce_ts = now;
2911
3473
  eb.bounce_count = (eb.bounce_count || 0) + 1;
2912
- if (eb.status !== 'suspended') {
2913
- eb.status = 'flagged';
2914
- eb.first_bounce_ts = eb.first_bounce_ts || now;
2915
- if (!was_active) {
2916
- // Fresh flag -> immediate first alert.
2917
- eb.attempt_count = 1;
2918
- eb.first_alert_ts = now;
2919
- eb.last_attempt_ts = now;
2920
- } else if (eb.attempt_count == null) {
2921
- eb.attempt_count = 0;
2922
- }
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;
2923
3479
  }
2924
3480
 
2925
3481
  account.account_email_bounce = eb;
2926
3482
  const save_ret = await db_module.save_couch_doc('xuda_accounts', account);
2927
3483
  if (save_ret.code < 0) return save_ret;
2928
3484
 
2929
- if (!was_active) {
2930
- try { await _send_bounce_alert(account, 1, max); } catch (e) { console.error('flag_email_bounce alert #1 failed:', e.message); }
2931
- }
2932
- 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)' : ''}`);
2933
- 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 } };
2934
3488
  } catch (err) {
2935
3489
  console.error('flag_email_bounce error:', err);
2936
3490
  return { code: -10, data: err.message };
2937
3491
  }
2938
3492
  };
2939
3493
 
2940
- // Daily retry/escalation sweep for flagged accounts (controller cron). For each
2941
- // account with account_email_bounce.status === 'flagged':
2942
- // - if the user changed their email away from the bounced address -> CLEAR;
2943
- // - else if under the alert cap and >= alert_interval since last -> next alert;
2944
- // - else (cap reached) -> SUSPEND (account_email_bounce_suspended) + ops alert.
2945
- // `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 }.
2946
3501
  export const sweep_email_bounces = async function (req = {}) {
2947
- const bm = _conf.bounce_monitor || {};
2948
- const DAY_MS = 24 * 60 * 60 * 1000;
2949
- const interval = Number(req.interval_ms ?? bm.alert_interval_ms ?? DAY_MS);
2950
- const max = Number(req.max ?? bm.suspend_after_alerts ?? 3);
2951
3502
  const now = Date.now();
2952
- const summary = { flagged: 0, cleared: 0, alerted: 0, suspended: 0, skipped: 0 };
3503
+ const summary = { matched: 0, cleared: 0, suspended: 0, skipped: 0 };
2953
3504
  try {
2954
- const selector = { 'account_email_bounce.status': 'flagged' };
3505
+ const selector = { 'account_email_bounce.status': { $in: ['flagged', 'suspended'] } };
2955
3506
  if (Array.isArray(req.uids) && req.uids.length) selector._id = { $in: req.uids };
2956
3507
  const find_ret = await db_module.find_couch_query('xuda_accounts', {
2957
3508
  selector,
@@ -2959,14 +3510,14 @@ export const sweep_email_bounces = async function (req = {}) {
2959
3510
  limit: req.limit ?? 99999,
2960
3511
  });
2961
3512
  const rows = find_ret?.data?.docs || find_ret?.docs || [];
2962
- summary.flagged = rows.length;
3513
+ summary.matched = rows.length;
2963
3514
 
2964
3515
  for (const row of rows) {
2965
3516
  const acct_ret = await db_module.get_couch_doc('xuda_accounts', row._id);
2966
3517
  if (acct_ret.code < 0) continue;
2967
3518
  const account = acct_ret.data;
2968
3519
  const eb = account.account_email_bounce;
2969
- if (!eb || eb.status !== 'flagged') { summary.skipped++; continue; }
3520
+ if (!eb || (eb.status !== 'flagged' && eb.status !== 'suspended')) { summary.skipped++; continue; }
2970
3521
 
2971
3522
  // CLEAR: the user updated their email away from the bounced address.
2972
3523
  const cur_email = (account.account_info?.email || '').toLowerCase().trim();
@@ -2974,55 +3525,22 @@ export const sweep_email_bounces = async function (req = {}) {
2974
3525
  eb.status = 'cleared';
2975
3526
  eb.cleared_ts = now;
2976
3527
  account.account_email_bounce = eb;
3528
+ account.account_email_bounce_suspended = 0;
2977
3529
  await db_module.save_couch_doc('xuda_accounts', account);
2978
3530
  summary.cleared++;
2979
3531
  continue;
2980
3532
  }
2981
3533
 
2982
- // Cadence gate.
2983
- if (eb.last_attempt_ts && now - eb.last_attempt_ts < interval) { summary.skipped++; continue; }
2984
-
2985
- if ((eb.attempt_count || 0) < max) {
2986
- const next = (eb.attempt_count || 0) + 1;
2987
- eb.attempt_count = next;
2988
- eb.last_attempt_ts = now;
2989
- account.account_email_bounce = eb;
2990
- await db_module.save_couch_doc('xuda_accounts', account);
2991
- try { await _send_bounce_alert(account, next, max); } catch (e) { console.error('bounce alert failed:', e.message); }
2992
- summary.alerted++;
2993
- } else {
2994
- // SUSPEND after the alert cap. Dedicated flag (enforced at
2995
- // router_module.validate_app_active_status alongside billing
2996
- // suspension) so we never clobber account_suspension_status.
3534
+ if (eb.status === 'flagged') {
2997
3535
  eb.status = 'suspended';
2998
3536
  eb.suspended_ts = now;
2999
3537
  account.account_email_bounce = eb;
3000
3538
  account.account_email_bounce_suspended = 1;
3001
3539
  await db_module.save_couch_doc('xuda_accounts', account);
3002
-
3003
- const host = _conf.is_debug ? process.env.XUDA_HOSTNAME || _conf.domain : _conf.domain;
3004
- const supers = _conf.superuser_account_ids || [];
3005
- if (supers.length) {
3006
- try {
3007
- await notification_msa.submit_notification({
3008
- type: 'account',
3009
- app_id: null,
3010
- uid_arr: supers,
3011
- topic: 'email_bounce_suspended',
3012
- system: true,
3013
- params: {
3014
- uid: account._id,
3015
- account_email: account.account_info?.email || eb.recipient || '',
3016
- recipient: eb.recipient || '',
3017
- reason: eb.reason || '',
3018
- status_code: eb.status_code || '',
3019
- attempts: eb.attempt_count || max,
3020
- domain: host,
3021
- },
3022
- });
3023
- } catch (e) { console.error('ops bounce-suspend alert failed:', e.message); }
3024
- }
3540
+ await _notify_bounce_suspended(account);
3025
3541
  summary.suspended++;
3542
+ } else {
3543
+ summary.skipped++;
3026
3544
  }
3027
3545
  }
3028
3546
  console.log('sweep_email_bounces:', JSON.stringify(summary));
@@ -5237,3 +5755,557 @@ setTimeout(async () => {
5237
5755
  const uid = 'd39126e0e2c51ffbd1aad10709fc8335';
5238
5756
  // onboarding_completed({ uid });
5239
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
+ };