@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 +1177 -99
- package/index_ms.mjs +84 -0
- package/index_msa.mjs +84 -0
- package/package.json +5 -2
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
|
-
|
|
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)
|
|
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
|
-
|
|
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 || !
|
|
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 || !
|
|
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 || !
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').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.
|
|
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.
|
|
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
|
-
//
|
|
2864
|
-
//
|
|
2865
|
-
|
|
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
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
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
|
|
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).
|
|
2884
|
-
//
|
|
2885
|
-
// the
|
|
2886
|
-
//
|
|
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
|
|
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
|
-
|
|
2907
|
-
|
|
2908
|
-
eb.
|
|
2909
|
-
|
|
2910
|
-
|
|
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 (!
|
|
2924
|
-
|
|
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
|
|
2935
|
-
//
|
|
2936
|
-
//
|
|
2937
|
-
//
|
|
2938
|
-
// -
|
|
2939
|
-
//
|
|
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 = {
|
|
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.
|
|
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
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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)} →</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 →</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 →</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 →</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 →</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 →</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 ? ' · ' + 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> · <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
|
+
};
|