diff --git a/accounts.js b/accounts.js
index ff2626a..3bd17ee 100644
--- a/accounts.js
+++ b/accounts.js
@@ -201,6 +201,23 @@ const J = {
if (p.memberId) return { error: 'That position is already registered on-chain and cannot be unlinked.' };
delete this.db.positions[a]; this.save(); return { ok: true };
},
+ // admin only: point the account at a different main wallet (or none); the caller re-reads the member id
+ async adminSetAddress(e, a) {
+ const acct = this.db.byEmail[e]; if (!acct) return { error: 'No such account.' };
+ if (a) {
+ if (this.db.byAddress[a] && this.db.byAddress[a] !== e) return { error: 'That wallet is the main wallet of another account.' };
+ if (this.db.positions[a]) return { error: 'That wallet is a linked position on ' + (this.db.positions[a].email === e ? 'this' : 'another') + ' account.' };
+ }
+ if (acct.address) delete this.db.byAddress[acct.address];
+ acct.address = a || null; if (a) this.db.byAddress[a] = e; this.save(); return { ok: true, account: pub(acct) };
+ },
+ async positionByAddress(a) { const p = this.db.positions[a]; return p ? { address: a, email: p.email, memberId: p.memberId } : null; },
+ async removeAccount(e) {
+ const acct = this.db.byEmail[e]; if (!acct) return { error: 'No such account.' };
+ if (acct.address) delete this.db.byAddress[acct.address]; if (acct.code) delete this.db.byCode[acct.code];
+ for (const [a, p] of Object.entries(this.db.positions)) if (p.email === e) delete this.db.positions[a];
+ delete this.db.byEmail[e]; this.save(); return { ok: true };
+ },
};
// ---- MySQL mode ----
@@ -350,6 +367,18 @@ const D = {
if (r.member_id) return { error: 'That position is already registered on-chain and cannot be unlinked.' };
await db.q('DELETE FROM positions WHERE address=?', [a]); return { ok: true };
},
+ async adminSetAddress(e, a) {
+ if (a) {
+ const o = (await db.q('SELECT email FROM accounts WHERE address=?', [a]))[0]; if (o && o.email !== e) return { error: 'That wallet is the main wallet of another account.' };
+ const pos = (await db.q('SELECT email FROM positions WHERE address=?', [a]))[0]; if (pos) return { error: 'That wallet is a linked position on ' + (pos.email === e ? 'this' : 'another') + ' account.' };
+ }
+ await db.q('UPDATE accounts SET address=? WHERE email=?', [a || null, e]); return { ok: true, account: await this.byEmail(e) };
+ },
+ async positionByAddress(a) { const r = (await db.q('SELECT * FROM positions WHERE address=?', [a]))[0]; return r ? { address: r.address, email: r.email, memberId: r.member_id || 0 } : null; },
+ async removeAccount(e) {
+ await db.q('DELETE FROM positions WHERE email=?', [e]);
+ const r = await db.q('DELETE FROM accounts WHERE email=?', [e]); return r.affectedRows ? { ok: true } : { error: 'No such account.' };
+ },
};
const impl = () => db.enabled() ? D : J;
@@ -446,6 +475,9 @@ module.exports = { init, signup, login, ensure, byEmail, byAddress, byCode, byUs
setWallOffers: (e, j) => impl().setWallOffers(String(e || '').toLowerCase(), j),
setProfile: (e, a, bio, socials) => impl().setProfile(String(e || '').toLowerCase(), a, bio, socials),
touchSeen: e => impl().touchSeen(String(e || '').toLowerCase()),
+ adminSetAddress: (e, a) => impl().adminSetAddress(String(e || '').toLowerCase(), a ? normAddr(a) : null),
+ positionByAddress: a => impl().positionByAddress(normAddr(a)),
+ removeAccount: e => impl().removeAccount(String(e || '').toLowerCase()),
setChatAvailable: (e, v) => impl().setChatAvailable(String(e || '').toLowerCase(), v),
getMutes: e => impl().getMutes(String(e || '').toLowerCase()),
setMute: (o, t, m) => impl().setMute(String(o || '').toLowerCase(), String(t || '').toLowerCase(), m),
diff --git a/adminmember.js b/adminmember.js
new file mode 100644
index 0000000..aca5586
--- /dev/null
+++ b/adminmember.js
@@ -0,0 +1,109 @@
+// Admin member card (Marty, 2026-09-13): one lookup that gathers everything known about a member so
+// the admin can search, drill down and act without digging through pages. Read side only; the
+// edits live in server.js (/api/admin/member PATCH/DELETE) and accounts.js.
+const fs = require('fs');
+const path = require('path');
+const db = require('./db');
+let R = null; // { accounts, ads, chain, tank, legacy, promos, messages, dataDir }
+function init(refs) { R = refs; }
+
+const nameOf = a => a ? (a.username ? '@' + a.username : a.memberId ? 'member #' + a.memberId : a.email) : null;
+
+// resolve a search token: email, @username, member #, share code, or wallet address
+async function resolve(q) {
+ const { accounts } = R;
+ let t = String(q || '').trim(); if (!t) return null;
+ if (t.includes('@') && t.indexOf('@') > 0) { const a = await accounts.byEmail(t.toLowerCase()); if (a) return a; }
+ const u = t.replace(/^@/, '');
+ if (/^0x[0-9a-f]{40}$/i.test(t)) {
+ const a = await accounts.byAddress(t.toLowerCase()); if (a) return a;
+ const pos = accounts.positionByAddress ? await accounts.positionByAddress(t.toLowerCase()) : null;
+ if (pos && pos.email) return accounts.byEmail(pos.email);
+ }
+ if (/^#?\d+$/.test(t)) {
+ const id = Number(t.replace('#', ''));
+ const a = await accounts.byMemberId(id); if (a) return a;
+ const pos = await accounts.positionByMember(id); if (pos && pos.email) return accounts.byEmail(pos.email);
+ }
+ let a = await accounts.byUsername(u.toLowerCase()); if (a) return a;
+ a = await accounts.byCode(u.toLowerCase()); if (a) return a;
+ return null;
+}
+
+async function view(email) {
+ const { accounts, ads, chain, tank, legacy, promos, messages } = R;
+ const acct = await accounts.byEmail(String(email || '').toLowerCase());
+ if (!acct) return null;
+ const out = { account: acct };
+ // sponsor + upline chain (site-side sponsorship, up to 5 levels)
+ const up = []; let cur = acct; const seen = new Set([acct.email]);
+ for (let i = 0; i < 5 && cur; i++) {
+ const s = await accounts.sponsorOf(cur.email).catch(() => null);
+ if (!s || seen.has(s.email)) break; seen.add(s.email);
+ up.push({ email: s.email, name: nameOf(s), memberId: s.memberId || 0 }); cur = s;
+ }
+ out.upline = up; out.sponsorName = up[0] ? up[0].name : null;
+ // positions (extra wallets) + every on-chain id this account owns
+ const positions = await accounts.positions(acct.email).catch(() => []);
+ out.positions = positions;
+ const ids = new Set([acct.memberId, ...positions.map(p => p.memberId)].filter(Boolean));
+ out.ids = [...ids];
+ // on-chain
+ out.chain = null;
+ if (acct.memberId) {
+ try { const m = await chain.member(acct.memberId); out.chain = { memberId: acct.memberId, sponsorId: m.sponsorId, buyerCount: m.buyerCount, activated: m.activated, account: m.account,
+ level: m.buyerCount >= 5 ? 3 : m.buyerCount >= 2 ? 2 : 1 }; } catch (e) { out.chain = { memberId: acct.memberId, readError: true }; }
+ }
+ const purchases = [], received = [], qualified = new Set(); let receivedWei = 0n, spentWei = 0n, spentCents = 0;
+ if (ids.size) {
+ for (const ev of chain.recentEvents(1e9)) {
+ if (ev.type === 'Purchase' && ids.has(ev.buyerId)) { purchases.push({ ts: ev.ts, buyerId: ev.buyerId, priceCents: ev.priceCents, paidWei: ev.paidWei, credits: ev.creditAmount, tx: ev.tx }); spentWei += BigInt(ev.paidWei || 0); spentCents += Number(ev.priceCents || 0); }
+ if (ev.type === 'TierPaid' && ids.has(ev.recipientId)) { received.push({ ts: ev.ts, buyerId: ev.buyerId, tier: ev.tier, amountWei: ev.amountWei, tx: ev.tx }); receivedWei += BigInt(ev.amountWei || 0); }
+ if (ev.type === 'BuyerCounted' && ids.has(ev.sponsorId) && ev.newBuyerId) qualified.add(ev.newBuyerId);
+ }
+ }
+ purchases.sort((a, b) => b.ts - a.ts); received.sort((a, b) => b.ts - a.ts);
+ out.purchases = purchases; out.received = received.slice(0, 50);
+ out.totals = { purchases: purchases.length, spentCents, spentWei: spentWei.toString(), receivedWei: receivedWei.toString(), payoutsIn: received.length };
+ const buyerNames = await accounts.namesForMembers([...new Set([...purchases.map(p => p.buyerId), ...received.map(r => r.buyerId)])]).catch(() => ({}));
+ out.names = buyerNames;
+ // credits
+ try { out.credits = await ads.balances([...ids], acct.email); } catch (e) { out.credits = null; }
+ try { out.earnedSplit = await ads.earnedSplit(acct.email); } catch (e) {}
+ // campaigns
+ try { out.campaigns = (await ads.listCampaigns(acct.email)).map(c => ({ id: c.id, type: c.type, status: c.status, name: c.name || c.title || '', budget: c.budget, spent: c.spent, created: c.created, views: c.views, clicks: c.clicks })); } catch (e) { out.campaigns = []; }
+ // line (3 levels) with wallet / bought / qualified per person
+ const levels = await accounts.downline(acct.email, 3).catch(() => []);
+ const boughtIds = new Set(chain.recentEvents(1e9).filter(ev => ev.type === 'Purchase').map(ev => ev.buyerId));
+ out.line = levels.map(L => ({ level: L.level, members: L.members.map(m => ({ email: m.email, name: nameOf(m), username: m.username, memberId: m.memberId || 0, wallet: !!m.address, joined: m.created, lastSeen: m.lastSeen || 0,
+ bought: !!(m.memberId && boughtIds.has(m.memberId)), qualified: !!(m.memberId && qualified.has(m.memberId)) })) }));
+ out.lineCounts = out.line.map(L => L.members.length);
+ // tank / adoptions
+ try {
+ const tv = await tank.adminView();
+ out.tank = { waiting: !!tv.waiting.find(w => w.email === acct.email),
+ adoptedBy: tv.adoptions.filter(a => a.adoptee === acct.email).map(a => ({ name: a.adopterName, email: a.adopter, created: a.created, status: a.status || a.state || '' })),
+ adopted: tv.adoptions.filter(a => a.adopter === acct.email).map(a => ({ name: a.adopteeName, email: a.adoptee, created: a.created, status: a.status || a.state || '' })) };
+ } catch (e) { out.tank = null; }
+ // legacy + promo + drip + earning + messages
+ try { const rec = legacy.lookup(acct.email); let g = null; try { g = JSON.parse(fs.readFileSync(path.join(R.dataDir, 'legacy-grants.json'), 'utf8'))[acct.email] || null; } catch (e) {} out.legacy = rec ? { brand: rec.b, seg: rec.s === 'a' ? 'advertiser' : 'earner', grant: g } : null; } catch (e) { out.legacy = null; }
+ try {
+ if (db.enabled()) out.promos = await db.q('SELECT code, credits, via, ts FROM promo_redemptions WHERE email=? ORDER BY ts DESC', [acct.email]);
+ else out.promos = (await promos.adminView()).recent.filter(r => r.email === acct.email);
+ } catch (e) { out.promos = []; }
+ try { out.drip = db.enabled() ? (await db.q('SELECT step, next_at, started, stopped, ref, angle FROM drips WHERE email=?', [acct.email]))[0] || null : null; } catch (e) { out.drip = null; }
+ try { const vs = await ads.viewStatus(acct.email); out.earning = { today: vs.views || vs.viewsToday || 0, claimed: !!vs.claimed, streakDay: vs.streakDay || 0 }; } catch (e) { out.earning = null; }
+ try {
+ if (db.enabled()) {
+ const since = new Date(Date.now() - 14 * 86400000).toISOString().slice(0, 10);
+ const dv = await db.q('SELECT day, views, claimed, video_count FROM daily_views WHERE email=? AND day>=? ORDER BY day DESC', [acct.email, since]);
+ out.days = dv; out.activeDays14 = dv.filter(d => d.views > 0 || d.video_count > 0).length; out.claims14 = dv.filter(d => d.claimed).length;
+ const mc = await db.q('SELECT COUNT(*) n FROM sponsor_messages WHERE from_email=? OR to_email=?', [acct.email, acct.email]); out.messageCount = Number(mc[0].n) || 0;
+ const vv = await db.q('SELECT COUNT(*) n FROM visit_seen WHERE email=?', [acct.email]).catch(() => [{ n: 0 }]); out.visits = Number(vv[0].n) || 0;
+ const vw = await db.q('SELECT COUNT(*) n FROM video_seen WHERE email=?', [acct.email]).catch(() => [{ n: 0 }]); out.videos = Number(vw[0].n) || 0;
+ }
+ } catch (e) {}
+ return out;
+}
+
+module.exports = { init, resolve, view, nameOf };
diff --git a/public/admin.html b/public/admin.html
index 72df072..7c5e7bd 100644
--- a/public/admin.html
+++ b/public/admin.html
@@ -9,6 +9,11 @@