diff --git a/public/assets/admin.js b/public/assets/admin.js index 96ca472..9b891bc 100644 --- a/public/assets/admin.js +++ b/public/assets/admin.js @@ -796,7 +796,7 @@ })); // site settings: key / value rows; booleans as checkboxes, numbers stay numbers - const SITE_META = { noPayoutIds: 'No-payout positions (member #s, comma): linkage only, no buys from them, no joins routed under them', siteName: 'Site name', tagline: 'Tagline', rehearsal: 'Testnet rehearsal banner', defaultSponsorId: 'Fallback sponsor (member #)', walletConnectProjectId: 'WalletConnect project id', moonpayPublicKey: 'MoonPay public key', telegramBotToken: 'Telegram proof feed: bot token', telegramChatId: 'Telegram proof feed: chat id', telegramTopicId: 'Telegram proof feed: topic id (optional)', telegramEvents: 'Telegram proof feed: events (payouts | payouts+purchases | all)', telegramCtaUrl: 'Telegram proof feed: join link under each post', aiCreditsPerGen: 'AI Copy Engine: credits per generation after the free allowance', aiFreeSurge: 'AI Copy Engine: free generations a month at Surge', aiFreeCircuit: 'AI Copy Engine: free generations a month at Circuit', aiFreeNexus: 'AI Copy Engine: free generations a month at Nexus', pipelineMode: 'Pipeline board: off (coming soon card) | preview (admin account only) | on (everyone)', pipelineEta: 'Pipeline: opening date shown on the coming-soon card (e.g. Sep 28)', memberWeeklyEmail: 'Weekly member email to everyone active (1) or only sponsors with a line (0)', leaderboardWeeklyPrize: 'Leaderboard: weekly prize text (optional; blank shows the credit ladder)', leaderboardMonthlyPrize: 'Leaderboard: monthly prize text (optional)', leaderboardWeeklyCredits: 'Leaderboard: weekly credits for 1st,2nd,3rd… (e.g. 1000,500,250; blank = none)', leaderboardMonthlyCredits: 'Leaderboard: monthly credits for 1st,2nd,3rd… (e.g. 5000,2500,1000)', leaderboardAnnounceGeneral: 'Leaderboard: announce winners in the main group too (1/0)', telegramEchoChatId: 'Telegram echo (shared payments topic): chat id', telegramEchoTopicId: 'Telegram echo: topic id', telegramEchoEvents: 'Telegram echo: events (payouts | payouts+purchases | all)', legacyCreditsAdvertiser: 'Legacy welcome credits: former advertisers', legacyCreditsEarner: 'Legacy welcome credits: former earners', pnlFixedMonthlyUsd: 'P&L: fixed monthly cost (USD)' }; + const SITE_META = { noPayoutIds: 'No-payout positions (member #s, comma): linkage only, no buys from them, no joins routed under them', siteName: 'Site name', tagline: 'Tagline', rehearsal: 'Testnet rehearsal banner', defaultSponsorId: 'Fallback sponsor (member #)', walletConnectProjectId: 'WalletConnect project id', moonpayPublicKey: 'MoonPay public key', telegramBotToken: 'Telegram proof feed: bot token', telegramChatId: 'Telegram proof feed: chat id', telegramTopicId: 'Telegram proof feed: topic id (optional)', telegramEvents: 'Telegram proof feed: events (payouts | payouts+purchases | all)', telegramCtaUrl: 'Telegram proof feed: join link under each post', aiCreditsPerGen: 'AI Copy Engine: credits per generation after the free allowance', aiFreeSurge: 'AI Copy Engine: free generations a month at Surge', aiFreeCircuit: 'AI Copy Engine: free generations a month at Circuit', aiFreeNexus: 'AI Copy Engine: free generations a month at Nexus', snapshotEnabled: 'Daily growth snapshot to Telegram (1/0)', snapshotHourUtc: 'Daily growth snapshot: hour (UTC; 14 = 9 AM Central)', snapshotTargets: 'Daily growth snapshot: targets (feed = proof channel, echo = shared payments topic; comma list)', pipelineMode: 'Pipeline board: off (coming soon card) | preview (admin account only) | on (everyone)', pipelineEta: 'Pipeline: opening date shown on the coming-soon card (e.g. Sep 28)', memberWeeklyEmail: 'Weekly member email to everyone active (1) or only sponsors with a line (0)', leaderboardWeeklyPrize: 'Leaderboard: weekly prize text (optional; blank shows the credit ladder)', leaderboardMonthlyPrize: 'Leaderboard: monthly prize text (optional)', leaderboardWeeklyCredits: 'Leaderboard: weekly credits for 1st,2nd,3rd… (e.g. 1000,500,250; blank = none)', leaderboardMonthlyCredits: 'Leaderboard: monthly credits for 1st,2nd,3rd… (e.g. 5000,2500,1000)', leaderboardAnnounceGeneral: 'Leaderboard: announce winners in the main group too (1/0)', telegramEchoChatId: 'Telegram echo (shared payments topic): chat id', telegramEchoTopicId: 'Telegram echo: topic id', telegramEchoEvents: 'Telegram echo: events (payouts | payouts+purchases | all)', legacyCreditsAdvertiser: 'Legacy welcome credits: former advertisers', legacyCreditsEarner: 'Legacy welcome credits: former earners', pnlFixedMonthlyUsd: 'P&L: fixed monthly cost (USD)' }; function drawSite() { const wrap = $('siteForm'); wrap.innerHTML = Object.entries(siteObj).map(([k, v]) => '
' + esc(SITE_META[k] || humanize(k)) + '' diff --git a/server.js b/server.js index 7128bc4..32a4759 100644 --- a/server.js +++ b/server.js @@ -36,6 +36,7 @@ const updates = require('./updates'); const audit = require('./audit'); // counter audit: views vs delivery logs, charges vs shows (Marty, 2026-09-15) // member update emails from Admin > Releases (Marty, 2026-09-14) const leaderboard = require('./leaderboard'); const toolkit = require('./toolkit'); +const snapshot = require('./snapshot'); // daily growth snapshot -> Telegram payments feed (Marty, 2026-09-15) const pipeline = require('./pipeline'); // sponsor follow-up board (coming soon until site setting pipelineMode = on) (Marty, 2026-09-15) const videomaker = require('./videomaker'); // Circuit tool: promo videos with the member's own end card (ffmpeg in the image) // badge-gated promo toolkit + AI Copy Engine (Surge and up) (Marty, 2026-09-14) // referral contest: /leaderboard, Overview card, weekly + monthly winners (Marty, 2026-09-14) // release notes + roadmap: /whats-new, Overview card, Admin > Releases (Marty, 2026-09-14) // blog -> Blotato -> X + Instagram on publish (Marty, 2026-09-13) // 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', '/whats-new', '/leaderboard']); @@ -371,6 +372,8 @@ async function boot() { // follow-up email sequence: send whatever came due (every 10 min, first pass shortly after boot) coach.init({ dataDir: DATA_DIR, chain, accounts, mailer, ads, tank, lb: () => leaderboard, siteConfig }); pipeline.init({ dataDir: DATA_DIR, accounts, coach, chain }); + snapshot.init({ dataDir: DATA_DIR, db, chain, siteConfig, send: async (c, t, th) => { await telegramSend(c, t, th); return true; } }); + setInterval(() => snapshot.tick().catch(e => console.error('snapshot', e.message)), 10 * 60 * 1000); tank.init({ dataDir: DATA_DIR, chain, accounts, messages, mailer, site: 'https://instantadpay.com' }); legacy.init({ dataDir: DATA_DIR }); traffic.init({ dataDir: DATA_DIR }); @@ -423,6 +426,7 @@ function siteConfig() { telegramAdminChatId: '', // private chat for admin alerts (sign-up guard bursts); falls back to ADMIN_EMAIL launchAt: '', // public launch moment, ISO 8601 with offset (e.g. 2026-09-18T19:00:00-05:00): countdown on /launch + dashboard mark aiCreditsPerGen: 10, aiFreeSurge: 20, aiFreeCircuit: 60, aiFreeNexus: 150, // AI Copy Engine: free generations per month by badge, then credits per generation + snapshotEnabled: '1', snapshotHourUtc: '14', snapshotTargets: 'feed,echo', // daily growth snapshot: 14 UTC = 9 AM Central; feed = proof channel, echo = shared payments topic pipelineMode: 'off', // Pipeline board: off = 'coming soon' card, preview = only the admin account sees the live board, on = everyone (Marty, 2026-09-15) pipelineEta: 'Sep 28', // what the coming-soon card and the roadmap promise memberWeeklyEmail: '0', // 1 = the weekly 'Your week on InstantAdPay' email goes to every active member, not only sponsors with a line @@ -2549,6 +2553,15 @@ const server = http.createServer(async (req, res) => { if (out.length) fs.writeFileSync(file, JSON.stringify(out, null, 2)); else { try { fs.unlinkSync(file); } catch (e) {} } return json(res, 200, { ok: true, ads: out, usingDefaults: !out.length }); } + // -- growth snapshot: preview the post, or send it now + if (p === '/api/admin/snapshot' && req.method === 'GET') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + return json(res, 200, { text: await snapshot.preview() }); + } + if (p === '/api/admin/snapshot/send' && req.method === 'POST') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + const r = await snapshot.post(); return json(res, r.error ? 400 : 200, r); + } if (p === '/api/admin/site' && req.method === 'GET') { if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); return json(res, 200, { site: siteConfig() }); diff --git a/snapshot.js b/snapshot.js new file mode 100644 index 0000000..25234e0 --- /dev/null +++ b/snapshot.js @@ -0,0 +1,89 @@ +// Growth snapshot (Marty, 2026-09-15): once a day, a short "how the site is doing" post in the Telegram +// payments feed (and the shared payments topic), so members watching the payout lines also see the +// whole picture: sign-ups, purchases, POL paid out, campaigns posted, ads viewed. Every number comes +// from the same tables and chain index the dashboards read. Settings: snapshotEnabled (1/0), +// snapshotHourUtc (default 14 = 9 AM Central), snapshotTargets (feed,echo). State: snapshot-state.json. +const fs = require('fs'); +const path = require('path'); + +let X = {}; +const DAY = 86400000; +function init(opts) { X = opts || {}; } +const STATE = () => path.join(X.dataDir, 'snapshot-state.json'); +function state() { try { return JSON.parse(fs.readFileSync(STATE(), 'utf8')); } catch (e) { return {}; } } +function setState(s) { try { fs.writeFileSync(STATE(), JSON.stringify(s)); } catch (e) {} } +const n = v => Number(v || 0).toLocaleString('en-US'); +const pol = w => { try { return (Number(BigInt(w) / (10n ** 16n)) / 100).toLocaleString('en-US', { maximumFractionDigits: 0 }); } catch (e) { return '0'; } }; + +async function gather(now = Date.now()) { + const since = now - DAY, prior = now - 2 * DAY; + const s = { signups: 0, signupsPrior: 0, members: 0, wallets: 0, payoutsOn: 0, seen24h: 0, + campaigns: 0, advertisers: 0, active: 0, activeAdvertisers: 0, imps: 0, viewers: 0, views: 0, + buys: 0, buysPrior: 0, usd: 0, activations: 0, paidWei: 0n, lifeBuys: 0, lifePaidWei: 0n, lifePayouts: 0 }; + if (X.db && X.db.enabled()) { + const q = (a, b) => X.db.q(a, b || []); + const a = (await q('SELECT COUNT(*) total, SUM(created>=?) s24, SUM(created>=? AND created0) payouts, SUM(last_seen>=?) seen FROM accounts', [since, prior, since, since]))[0] || {}; + s.members = Number(a.total || 0); s.signups = Number(a.s24 || 0); s.signupsPrior = Number(a.sPrior || 0); s.wallets = Number(a.wallet || 0); s.payoutsOn = Number(a.payouts || 0); s.seen24h = Number(a.seen || 0); + const c = (await q('SELECT COUNT(*) n, COUNT(DISTINCT owner_email) owners FROM campaigns WHERE house=0 AND created>=?', [since]))[0] || {}; + s.campaigns = Number(c.n || 0); s.advertisers = Number(c.owners || 0); + const ac = (await q("SELECT COUNT(*) n, COUNT(DISTINCT owner_email) owners FROM campaigns WHERE house=0 AND status='active'"))[0] || {}; + s.active = Number(ac.n || 0); s.activeAdvertisers = Number(ac.owners || 0); + // impressions in the last 24 hours: camp_hours is keyed by UTC day + hour + const d0 = new Date(since).toISOString().slice(0, 10), h0 = new Date(since).getUTCHours(), d1 = new Date(now).toISOString().slice(0, 10); + const im = (await q('SELECT SUM(n) imps FROM camp_hours WHERE (day=? AND hour>=?) OR (day>? AND day<=?)', [d0, h0, d0, d1]))[0] || {}; + s.imps = Number(im.imps || 0); + const v = (await q('SELECT COUNT(*) viewers, SUM(views) views FROM daily_views WHERE day=? AND views>0', [d1]))[0] || {}; + s.viewers = Number(v.viewers || 0); s.views = Number(v.views || 0); + } + try { + for (const ev of X.chain.recentEvents(1e9)) { + if (ev.type === 'Purchase') { s.lifeBuys++; if (ev.ts >= since) { s.buys++; s.usd += ev.priceCents / 100; } else if (ev.ts >= prior) s.buysPrior++; } + else if (ev.type === 'MemberActivated' && ev.ts >= since) s.activations++; + else if (ev.type === 'TierPaid' || ev.type === 'AwardPaid') { s.lifePayouts++; s.lifePaidWei += BigInt(ev.amountWei); if (ev.ts >= since) s.paidWei += BigInt(ev.amountWei); } + } + // the index keeps a bounded event list; lifetime totals come from its running tally when present + const t = X.chain.totals ? X.chain.totals() : null; + if (t) { if (t.purchases) s.lifeBuys = Number(t.purchases); if (t.payoutWei) s.lifePaidWei = BigInt(t.payoutWei); if (t.payouts) s.lifePayouts = Number(t.payouts); } + } catch (e) {} + return s; +} + +function compose(s, cta) { + const trend = (cur, prev, what) => cur > prev ? ' (up from ' + n(prev) + ' ' + what + ')' : cur < prev ? ' (' + n(prev) + ' ' + what + ')' : ''; + const L = []; + L.push('\u{1F4C8} InstantAdPay · 24-hour snapshot'); + L.push('\u{1F465} Sign-ups: ' + n(s.signups) + '' + trend(s.signups, s.signupsPrior, 'the day before') + ' · ' + n(s.activations) + ' switched on payouts'); + L.push('\u{1F9FE} Purchases: ' + n(s.buys) + ' for $' + n(Math.round(s.usd)) + trend(s.buys, s.buysPrior, 'the day before') + ' · ' + pol(s.paidWei) + ' POL paid to members in the same transactions'); + L.push('\u{1F4E3} Campaigns: ' + n(s.campaigns) + ' posted by ' + n(s.advertisers) + ' advertiser' + (s.advertisers === 1 ? '' : 's') + ' · ' + n(s.active) + ' running now · ' + n(s.imps) + ' ad impressions served'); + L.push('\u{1F440} Earning: ' + n(s.viewers) + ' members viewed ads today · ' + n(s.seen24h) + ' signed in'); + L.push('\u{1F3C1} So far: ' + n(s.members) + ' members · ' + n(s.wallets) + ' wallets linked · ' + n(s.payoutsOn) + ' with payouts on · ' + n(s.lifeBuys) + ' purchases · ' + pol(s.lifePaidWei) + ' POL paid out in ' + n(s.lifePayouts) + ' payouts, every one on the public ledger'); + L.push('Live ledger' + (cta ? ' · Join free' : '')); + return L.join('\n'); +} + +async function post() { + const sc = X.siteConfig(); + if (!sc.telegramBotToken) return { error: 'No Telegram bot token set.' }; + const s = await gather(); const text = compose(s, sc.telegramCtaUrl); + const targets = String(sc.snapshotTargets || 'feed,echo').split(',').map(x => x.trim()); + let sent = 0; + if (targets.includes('feed') && sc.telegramChatId) { if (await X.send(sc.telegramChatId, text, sc.telegramTopicId)) sent++; } + if (targets.includes('echo') && sc.telegramEchoChatId) { if (await X.send(sc.telegramEchoChatId, text, sc.telegramEchoTopicId)) sent++; } + const st = state(); st.lastPost = Date.now(); st.lastDay = new Date().toISOString().slice(0, 10); setState(st); + return { ok: true, sent, text }; +} + +// every 10 minutes: post once a day at or after snapshotHourUtc (a restart never double-posts) +async function tick() { + const sc = X.siteConfig(); + if (String(sc.snapshotEnabled || '1') !== '1' || !sc.telegramBotToken) return; + const hour = Number(sc.snapshotHourUtc == null || sc.snapshotHourUtc === '' ? 14 : sc.snapshotHourUtc); + const now = new Date(); const day = now.toISOString().slice(0, 10); + if (now.getUTCHours() < hour) return; + if (state().lastDay === day) return; + await post(); +} + +async function preview() { const sc = X.siteConfig(); return compose(await gather(), sc.telegramCtaUrl); } + +module.exports = { init, gather, compose, post, tick, preview };