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 @@