Admin: member search + drilldown card (identity, chain, credits, line, purchases, payouts, campaigns) with username/sponsor/wallet/credits/delete actions; sortable + filterable admin tables; Earn tab zero-credit button wording

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-13 06:27:07 -05:00
parent 1faa43780e
commit 0c7f957db4
7 changed files with 330 additions and 11 deletions
+52 -1
View File
@@ -28,7 +28,8 @@ const tank = require('./tank'); // holding tank: unsponsored free members, a
const legacy = require('./legacy'); // Faucet Wave / Tier One Ads bridge: welcome-back credits for listed emails
const traffic = require('./traffic'); // public page views by referring domain (admin Traffic tab)
const promos = require('./promos'); // partner promo codes -> free ad credits (link ?promo=CODE or the dashboard box)
const blog = require('./blog'); // admin-written coaching articles, server-rendered public /blog with SEO metadata (Marty, 2026-09-12)
const blog = require('./blog');
const adminMember = require('./adminmember'); // admin member card: search, drilldown, edits (Marty, 2026-09-13) // admin-written coaching articles, server-rendered public /blog with SEO metadata (Marty, 2026-09-12)
const TRAFFIC_PAGES = new Set(['/', '/ledger', '/contract', '/shorts', '/plays', '/wallets', '/launch', '/partners', '/earning', '/blog']);
let tankWaitCache = null; // dashboard: who is waiting for a sponsor (refreshed every minute)
const geo = require('./geo'); // viewer country -> tier (DB-IP lite), for campaign targeting
@@ -330,6 +331,7 @@ async function boot() {
traffic.init({ dataDir: DATA_DIR });
promos.init({ dataDir: DATA_DIR });
blog.init({ dataDir: DATA_DIR });
adminMember.init({ accounts, ads, chain, tank, legacy, promos, messages, dataDir: DATA_DIR });
setInterval(() => tankNotifyTick().catch(e => console.error('tank notify', e.message)), 15 * 60 * 1000); // new tank arrivals -> Telegram
geo.init({ dataDir: DATA_DIR }).catch(e => console.error('geo init', e.message));
setInterval(() => geo.refresh().catch(e => console.error('geo refresh', e.message)), 24 * 3600 * 1000); // monthly file, checked daily
@@ -1096,6 +1098,55 @@ const server = http.createServer(async (req, res) => {
await ads.addEarned(s.email, g.credits);
return json(res, 200, { ok: true, credits: g.credits, code: g.code, partner: g.partner });
}
// -- admin: member card. GET ?q= resolves email / @username / #id / share code / wallet;
// PATCH edits username, sponsor, main wallet or grants credits; DELETE removes a free account (2026-09-13)
if (p === '/api/admin/member' && req.method === 'GET') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
const q = u.searchParams.get('q') || u.searchParams.get('email') || '';
const a = await adminMember.resolve(q);
if (!a) return json(res, 404, { error: 'No member matches "' + q.slice(0, 60) + '".' });
return json(res, 200, await adminMember.view(a.email));
}
if (p === '/api/admin/member' && req.method === 'PATCH') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
const b = await readBody(req);
const e = String(b.email || '').trim().toLowerCase(); const acct = e && await accounts.byEmail(e);
if (!acct) return json(res, 404, { error: 'No such member.' });
if (b.username !== undefined) {
const un = String(b.username || '').trim().toLowerCase();
if (!/^[a-z0-9_]{3,20}$/.test(un)) return json(res, 400, { error: 'Username: 3 to 20 letters, numbers or underscore.' });
const r = await accounts.setUsername(e, un); if (r.error) return json(res, 400, r);
console.log('admin username', e, un);
}
if (b.sponsorRef !== undefined) { const r = await accounts.setSponsorRef(e, String(b.sponsorRef || '').trim()); if (r.error) return json(res, 400, r); console.log('admin sponsor', e, String(b.sponsorRef || '').trim()); }
if (b.address !== undefined) {
const a = String(b.address || '').trim().toLowerCase();
if (a && !/^0x[0-9a-f]{40}$/.test(a)) return json(res, 400, { error: 'That is not a wallet address.' });
const r = await accounts.adminSetAddress(e, a || null); if (r.error) return json(res, 400, r);
let mid = 0; if (a) { try { mid = Number(await chain.memberIdByAccount(a)) || 0; } catch (x) {} }
await accounts.setMemberId(e, mid);
if (db.enabled()) await db.q('UPDATE sessions SET address=?, member_id=? WHERE email=?', [a || null, mid || null, e]).catch(() => {});
console.log('admin wallet swap', e, a || '(none)', 'member', mid);
}
if (b.grantCredits !== undefined) {
const n = Math.round(Number(b.grantCredits));
if (!(n > 0) || n > 100000) return json(res, 400, { error: 'Credits: a whole number from 1 to 100,000.' });
await ads.addEarned(e, n);
console.log('admin credits', e, n, String(b.note || '').slice(0, 100));
}
return json(res, 200, await adminMember.view(e));
}
if (p === '/api/admin/member' && req.method === 'DELETE') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
const e = String(u.searchParams.get('email') || '').trim().toLowerCase(); const acct = e && await accounts.byEmail(e);
if (!acct) return json(res, 404, { error: 'No such member.' });
if (acct.memberId) return json(res, 400, { error: 'Member #' + acct.memberId + ' is registered on-chain and cannot be deleted.' });
const pos = await accounts.positions(e); if (pos.some(x => x.memberId)) return json(res, 400, { error: 'This account owns a registered position and cannot be deleted.' });
const r = await accounts.removeAccount(e); if (r.error) return json(res, 400, r);
if (db.enabled()) await db.q('DELETE FROM sessions WHERE email=?', [e]).catch(() => {});
console.log('admin removed account', e);
return json(res, 200, { ok: true });
}
// -- admin: blog (list all incl. drafts, save/create, delete) (Marty, 2026-09-12)
if (p === '/api/admin/blog' && req.method === 'GET') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });