Coaching layer + tools: coach your directs (rungs, stalled, one-click nudges), automatic member nudges + weekly sponsor digest, prospects list, per-angle link stats, broadcast templates, Qualified Start calculator, Telegram proof feed, send-failed alerts, admin P&L pane, automatic credit burner (ethers), username lock, home-page comparison, printable checklist, wall link in Promo tools

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-10 07:30:16 -05:00
parent 0c30e831eb
commit 4156b1f815
24 changed files with 1264 additions and 489 deletions
+115 -2
View File
@@ -23,6 +23,8 @@ const drip = require('./drip');
const spaces = require('./spaces'); // DO Spaces video storage (inert unless DO_SPACES_* set)
let QR = null; try { QR = require('qrcode'); } catch (e) { /* optional */ }
const chatbot = require('./chatbot');
const coach = require('./coach'); // coaching view, nudges, digest, prospects, link stats
const burner = require('./burner'); // automatic on-chain credit burns (inert without ENGINE_KEY)
const PORT = Number(process.env.PORT || 3000);
const ROOT = __dirname;
@@ -230,7 +232,7 @@ async function frameCheck(url) {
}
async function boot() {
await db.init({ dataDir: DATA_DIR }); // no-op without DATABASE_URL (JSON mode)
chain.init({ onEvent: ev => { attachNames([ev]).then(a => pushFeed(a[0])).catch(() => pushFeed(ev)); emailOnEvent(ev).catch(() => {}); } });
chain.init({ onEvent: ev => { attachNames([ev]).then(a => pushFeed(a[0])).catch(() => pushFeed(ev)); emailOnEvent(ev).catch(() => {}); telegramOnEvent(ev).catch(() => {}); } });
auth.init({ dataDir: DATA_DIR, chain, isProd: IS_PROD, site: 'instantadpay.com' });
accounts.init({ dataDir: DATA_DIR });
ads.init({ dataDir: DATA_DIR, chain });
@@ -242,6 +244,12 @@ async function boot() {
setTimeout(() => ads.dailySweep().catch(e => console.error('sweep', e.message)), 60 * 1000);
setInterval(() => ads.dailySweep().catch(e => console.error('sweep', e.message)), 60 * 60 * 1000);
// follow-up email sequence: send whatever came due (every 10 min, first pass shortly after boot)
coach.init({ dataDir: DATA_DIR, chain, accounts, mailer });
burner.init({ chain, ads });
setTimeout(() => coach.nudgeTick().catch(e => console.error('coach', e.message)), 90 * 1000);
setInterval(() => coach.nudgeTick().catch(e => console.error('coach', e.message)), 60 * 60 * 1000);
setTimeout(() => burner.tick().catch(e => console.error('burner', e.message)), 45 * 1000);
setInterval(() => burner.tick().catch(e => console.error('burner', e.message)), 5 * 60 * 1000);
setTimeout(() => drip.tick().catch(e => console.error('drip', e.message)), 30 * 1000);
setInterval(() => drip.tick().catch(e => console.error('drip', e.message)), 10 * 60 * 1000);
// NAS reconcile: pull syndicated delivery into the unified credit pool
@@ -259,7 +267,10 @@ function siteConfig() {
return Object.assign({
siteName: 'InstantAdPay',
tagline: 'Advertise and earn. Locked in code, not promises.',
rehearsal: true // shows the testnet banner; flipped off at mainnet launch
rehearsal: true, // shows the testnet banner; flipped off at mainnet launch
// payment-proof Telegram feed (blank = off) and the P&L pane's fixed monthly cost
telegramBotToken: '', telegramChatId: '', telegramTopicId: '', telegramEvents: 'payouts', telegramCtaUrl: 'https://instantadpay.com/',
pnlFixedMonthlyUsd: 0
}, saved);
}
@@ -431,8 +442,38 @@ async function emailOnEvent(ev) {
}
else if (ev.type === 'TierPaid') await notify(ev.recipientId, 'You just got paid on InstantAdPay', 'A level-' + ev.tier + ' payout of ' + weiToPol(ev.amountWei) + ' POL just landed in your wallet.');
else if (ev.type === 'AwardPaid') await notify(ev.toId, 'You just got paid on InstantAdPay', weiToPol(ev.amountWei) + ' POL just landed in your wallet.');
else if (ev.type === 'PassedUp' && ev.reason === 'send-failed') {
// the member WAS qualified but their wallet rejected the POL (usually a smart-contract
// wallet that needs more than the capped gas): tell them and the admin, loudly
await notify(ev.skippedId, 'Your wallet rejected a payout on InstantAdPay', 'A level-' + ev.tier + ' payout tried to reach your linked wallet and the wallet refused the transfer, so it passed to the next qualified member. This happens with some smart-contract wallets. Link a regular wallet address (MetaMask, SafePal, Phantom) on the Wallet tab so the next payout lands.');
if (ADMIN_EMAIL) mailer.send(ADMIN_EMAIL, 'InstantAdPay: payout send-failed for member #' + ev.skippedId, 'A level-' + ev.tier + ' payout to member #' + ev.skippedId + ' failed at the wallet (send-failed) and passed up. Tx: ' + ev.tx + '\n\nThe member has been emailed to link a regular wallet.').catch(() => {});
}
else if (ev.type === 'PassedUp') await notify(ev.skippedId, 'A payout passed you by on InstantAdPay', 'A level-' + ev.tier + ' payout passed you by because you were not qualified yet. Get qualified so you catch the next one.');
}
// payment-proof Telegram feed (same pattern as the RM Circle proof channel): one compact
// line per event, admin-configured under Settings > Site (telegramBotToken, telegramChatId,
// optional telegramTopicId, telegramEvents = payouts | payouts+purchases | all, telegramCtaUrl)
async function telegramOnEvent(ev) {
const sc = siteConfig();
if (!sc.telegramBotToken || !sc.telegramChatId || !ev) return;
const mode = String(sc.telegramEvents || 'payouts');
const names = await accounts.namesForMembers([ev.recipientId, ev.buyerId, ev.sponsorId, ev.newBuyerId, ev.id].filter(Boolean)).catch(() => ({}));
const who = id => '#' + id + (names[id] ? ' @' + names[id] : '');
const cc = chain.getConfig();
const tx = (cc.explorer ? cc.explorer.replace(/\/+$/, '') : 'https://polygonscan.com') + '/tx/' + ev.tx;
let line = null;
if (ev.type === 'TierPaid') line = '\u{1F4B8} Level ' + ev.tier + ' payout: <b>' + weiToPol(ev.amountWei) + ' POL</b> \u2192 ' + who(ev.recipientId);
else if (ev.type === 'BuyerCounted' && mode !== 'payouts') line = '\u2B50 ' + who(ev.sponsorId) + ' now has <b>' + ev.newCount + '</b> qualifying buyer' + (ev.newCount === 1 ? '' : 's') + (ev.newCount === 2 ? ' \u00b7 level 2 open' : ev.newCount === 5 ? ' \u00b7 level 3 open' : '');
else if (ev.type === 'Purchase' && mode !== 'payouts') line = '\u{1F9FE} ' + who(ev.buyerId) + ' bought a $' + Math.round(ev.priceCents / 100) + ' package';
else if (ev.type === 'MemberActivated' && mode === 'all') line = '\u{1F91D} ' + who(ev.id) + ' switched on payouts';
if (!line) return;
const text = line + ' \u00b7 <a href="' + tx + '">verify</a>' + (sc.telegramCtaUrl ? '\n<a href="' + sc.telegramCtaUrl + '">Join free</a>' : '');
const body = JSON.stringify(Object.assign({ chat_id: sc.telegramChatId, text, parse_mode: 'HTML', disable_web_page_preview: true }, sc.telegramTopicId ? { message_thread_id: Number(sc.telegramTopicId) } : {}));
await new Promise((resolve) => {
const rq = https.request({ hostname: 'api.telegram.org', path: '/bot' + sc.telegramBotToken + '/sendMessage', method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 10000 }, r => { r.resume(); r.on('end', resolve); });
rq.on('error', () => resolve()); rq.on('timeout', () => { rq.destroy(); resolve(); }); rq.end(body);
});
}
// ---- live feed (SSE) ----
const feedClients = new Set();
@@ -458,6 +499,7 @@ const server = http.createServer(async (req, res) => {
const cookies = parseCookies(req);
const angle = String(u.searchParams.get('v') || '').toLowerCase();
const ang = JOIN_ANGLES[angle] || null;
if (req.method === 'GET') coach.recordView(tok, ang ? angle : ''); // link stats per angle
const cookieTail = `; Path=/; SameSite=Lax; Max-Age=${180 * 24 * 3600}${IS_PROD ? '; Secure' : ''}`;
const set = [];
if (!cookies['iap.sponsor']) set.push('iap.sponsor=' + tok + cookieTail);
@@ -835,11 +877,46 @@ const server = http.createServer(async (req, res) => {
const r = await accounts.removePosition(s.email, b.address);
return json(res, r.error ? 400 : 200, r);
}
// -- coaching: every direct's ladder rung, stalled flag, and what to say
if (p === '/api/my/coach' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
return json(res, 200, await coach.coachView(s.email));
}
// -- link stats: views, joins and buyers per angle link
if (p === '/api/my/linkstats' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
return json(res, 200, await coach.linkStats(s.email));
}
// -- prospects: the member's own follow-up list
if (p === '/api/my/prospects' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
return json(res, 200, { prospects: await coach.prospects(s.email), statuses: coach.STATUSES });
}
if (p === '/api/my/prospects' && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const r = await coach.saveProspect(s.email, await readBody(req));
return json(res, r.error ? 400 : 200, r);
}
if (p === '/api/my/prospects/remove' && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const b = await readBody(req);
const r = await coach.removeProspect(s.email, b.id);
return json(res, r.error ? 400 : 200, r);
}
if (p === '/api/my/profile' && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const b = await readBody(req);
const before = await accounts.byEmail(s.email);
// a username is permanent once set: the invite link, the public wall and every
// banner already printed carry it (Marty, 2026-09-10)
if (before && before.username && String(b.username || '').trim().toLowerCase() !== String(before.username).toLowerCase())
return json(res, 400, { error: 'Your username is locked. Your invite link, your public page and any banners you shared all carry @' + before.username + '. Contact support if it truly has to change.' });
const r = await accounts.setUsername(s.email, b.username);
// First time a username is set (onboarding): now there's a real name to
// show, so notify the sponsor here rather than at signup (where it'd just
@@ -1641,6 +1718,42 @@ const server = http.createServer(async (req, res) => {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
return json(res, 200, { site: siteConfig() });
}
// -- profit and loss from the chain index: volume, platform fees, member payouts,
// pass-ups, per period (by block: ~43,200 Polygon blocks a day), plus the fee
// wallets' live balances and an admin-entered fixed monthly cost
if (p === '/api/admin/pnl' && req.method === 'GET') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
const days = Math.max(0, Number(u.searchParams.get('days') || 30));
let latest = 0; try { latest = parseInt(await chain.rpc('eth_blockNumber', []), 16); } catch (e) {}
const fromBlock = days ? latest - Math.round(days * 43200) : 0;
const evs = chain.recentEvents(1e9).filter(e => !days || e.block >= fromBlock);
const sum = (list, f) => list.reduce((n, e) => n + BigInt(f(e) || '0'), 0n);
const purchases = evs.filter(e => e.type === 'Purchase');
const tier = evs.filter(e => e.type === 'TierPaid');
const admin = evs.filter(e => e.type === 'AdminPaid');
const passed = evs.filter(e => e.type === 'PassedUp');
const byTier = {};
for (const t of [1, 2, 3]) byTier[t] = sum(tier.filter(e => e.tier === t), e => e.amountWei).toString();
const byPkg = {};
for (const e of purchases) { const k = '$' + Math.round(e.priceCents / 100); byPkg[k] = (byPkg[k] || 0) + 1; }
let polUsd = 0; try { const cat = await chain.catalog(); const pk = (cat.products || cat).find(x => x.costWei); if (pk) polUsd = (pk.priceCents / 100) / (Number(BigInt(pk.costWei)) / 1e18); } catch (e) {}
const wallets = { feeA: '0x7627fc78876948ac9d95c1c9eb061e7d6d647b70', feeB: '0x8b7d33849a2c4d92c985be31e46dc564ba901ad2', engine: burner.status().address || null };
const balances = {};
for (const [k, a] of Object.entries(wallets)) { if (!a) continue; try { balances[k] = BigInt(await chain.rpc('eth_getBalance', [a, 'latest'])).toString(); } catch (e) { balances[k] = null; } }
return json(res, 200, { days, fromBlock, latest, polUsd,
purchases: { count: purchases.length, volumeWei: sum(purchases, e => e.paidWei).toString(), usdCents: purchases.reduce((n, e) => n + (e.priceCents || 0), 0), byPackage: byPkg },
platformWei: sum(admin, e => e.amountWei).toString(), memberPayoutsWei: sum(tier, e => e.amountWei).toString(), byTier,
passedUp: { count: passed.length, unqualified: passed.filter(e => e.reason === 'unqualified').length, sendFailed: passed.filter(e => e.reason === 'send-failed').length },
wallets, balances, fixedMonthlyUsd: Number(siteConfig().pnlFixedMonthlyUsd) || 0, burner: burner.status() });
}
if (p === '/api/admin/burner' && req.method === 'GET') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
return json(res, 200, burner.status());
}
if (p === '/api/admin/burner/run' && req.method === 'POST') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
return json(res, 200, await burner.tick());
}
// -- admin (Bearer ADMIN_PASSWORD, or the /admin portal session)
if (p === '/api/admin/burns' && req.method === 'GET') {