@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 +1170 -98
- 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
|
|
|
@@ -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
|
-
|
|
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 || !
|
|
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 || !
|
|
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 || !
|
|
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, '&').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
|
+
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
2870
|
-
//
|
|
2871
|
-
|
|
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
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
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
|
|
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).
|
|
2890
|
-
//
|
|
2891
|
-
// the
|
|
2892
|
-
//
|
|
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
|
|
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
|
-
|
|
2913
|
-
|
|
2914
|
-
eb.
|
|
2915
|
-
|
|
2916
|
-
|
|
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 (!
|
|
2930
|
-
|
|
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
|
|
2941
|
-
//
|
|
2942
|
-
//
|
|
2943
|
-
//
|
|
2944
|
-
// -
|
|
2945
|
-
//
|
|
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 = {
|
|
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.
|
|
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
|
-
|
|
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, '&').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
|
+
};
|