Admin > Reports: counters audit (views vs delivery logs, login days charged vs shown, featured views, clicks vs views, spend vs budget), daily with Telegram alert

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-15 05:32:13 -05:00
parent 86a7f83874
commit 84a9063bbe
4 changed files with 90 additions and 2 deletions
+63
View File
@@ -0,0 +1,63 @@
// Counter audit (Marty, 2026-09-15: "buyers will lose confidence if they feel cheated"). Every ad format's
// recorded impressions are reconciled against the log that proves delivery, and login/featured charging is
// checked against actual shows. Runs on demand from Admin > Reports and once a day; any issue alerts the admin.
// DB mode only (production); JSON mode reports "not checked".
const db = require('./db');
let R = null; // { notify(text) }
function init(refs) { R = refs; }
async function run() {
const out = { checkedAt: Date.now(), checks: [] };
if (!db.enabled()) { out.checks.push({ name: 'Counters', ok: true, detail: 'JSON mode: not checked', issues: [] }); return out; }
const camps = await db.q("SELECT id, name, type, status, imps, clicks, spent, accrued, budget, created, house FROM campaigns WHERE house=0 OR house IS NULL");
const byType = t => camps.filter(c => c.type === t);
const add = (name, issues, detail) => out.checks.push({ name, ok: !issues.length, detail, issues });
// 1. per-view formats: campaign.imps must equal the rows in the log that paid for them
for (const [type, table, label] of [['video', 'video_seen', 'completed watches'], ['visits', 'visit_seen', 'verified visits'], ['solo', 'solo_inbox', 'inbox deliveries']]) {
const rows = await db.q('SELECT campaign_id, COUNT(*) n FROM ' + table + ' GROUP BY campaign_id'); const logN = {}; for (const r of rows) logN[r.campaign_id] = Number(r.n);
const issues = []; let tot = 0, logTot = 0;
for (const c of byType(type)) { const l = logN[c.id] || 0; tot += Number(c.imps); logTot += l; if (Number(c.imps) !== l) issues.push('#' + c.id + ' ' + c.name.slice(0, 30) + ': ' + c.imps + ' views vs ' + l + ' ' + label); }
add(type + ' views vs ' + label, issues, tot + ' views recorded, ' + logTot + ' ' + label);
}
// 2. banner/text: imps must equal the hourly log (both bumped on serve)
{
const rows = await db.q('SELECT campaign_id, SUM(n) n FROM camp_hours GROUP BY campaign_id'); const h = {}; for (const r of rows) h[r.campaign_id] = Number(r.n);
const issues = [];
for (const c of camps.filter(c => ['banner', 'text'].includes(c.type))) { const l = h[c.id] || 0; if (Math.abs(Number(c.imps) - l) > 2) issues.push('#' + c.id + ' ' + c.name.slice(0, 30) + ': ' + c.imps + ' views vs ' + l + ' in the hourly log'); }
add('banner/text views vs hourly log', issues, byType('banner').length + byType('text').length + ' campaigns compared');
}
// 3. login: days charged vs days shown (a day may only be charged after the ad was shown)
{
const rows = await db.q('SELECT campaign_id, COUNT(DISTINCT day) d FROM camp_hours WHERE n>0 GROUP BY campaign_id'); const shown = {}; for (const r of rows) shown[r.campaign_id] = Number(r.d);
const issues = [];
for (const c of byType('login')) { const charged = Math.round((Number(c.spent) + Number(c.accrued || 0)) / 100), s = shown[c.id] || 0; if (charged > s) issues.push('#' + c.id + ' ' + c.name.slice(0, 30) + ': charged ' + charged + ' day(s), shown on ' + s); }
add('login ads: days charged vs days shown', issues, byType('login').length + ' campaigns compared');
}
// 4. featured: a live booking older than two hours must have views
{
const issues = []; const now = Date.now();
for (const c of byType('featured').filter(c => c.status === 'active' && now - Number(c.created) > 2 * 3600e3)) if (!Number(c.imps)) issues.push('#' + c.id + ' ' + c.name.slice(0, 30) + ': live with 0 views');
add('featured: live bookings have views', issues, byType('featured').filter(c => c.status === 'active').length + ' live bookings');
}
// 5. clicks can only exceed views where a click precedes the counted view (visits, login)
{
const issues = [];
for (const c of camps.filter(c => !['visits', 'login'].includes(c.type) && Number(c.clicks) > Number(c.imps) + 5)) issues.push('#' + c.id + ' ' + c.name.slice(0, 30) + ' (' + c.type + '): ' + c.clicks + ' clicks vs ' + c.imps + ' views');
add('clicks vs views', issues, 'campaigns with more clicks than views, where that cannot happen');
}
// 6. budgets: spent can never pass budget
{
const issues = camps.filter(c => Number(c.spent) + Number(c.accrued || 0) > Number(c.budget) + 1).map(c => '#' + c.id + ' ' + c.name.slice(0, 30) + ': spent ' + (Number(c.spent) + Number(c.accrued || 0)) + ' of ' + c.budget);
add('spend never exceeds budget', issues, camps.length + ' campaigns');
}
return out;
}
let lastAlertDay = '';
async function dailyTick() {
try {
const r = await run(); const bad = r.checks.filter(c => !c.ok); const day = new Date().toISOString().slice(0, 10);
if (bad.length && lastAlertDay !== day && R && R.notify) { lastAlertDay = day; R.notify('⚠️ InstantAdPay counter audit found ' + bad.length + ' issue' + (bad.length === 1 ? '' : 's') + ': ' + bad.map(c => c.name + ' (' + c.issues.length + ')').join('; ') + '. Admin > Reports > Counters audit.'); }
return r;
} catch (e) { console.error('audit', e.message); return null; }
}
module.exports = { init, run, dailyTick };
+7 -1
View File
@@ -270,6 +270,12 @@
</div> </div>
<div class="pane" id="pane-reports" hidden> <div class="pane" id="pane-reports" hidden>
<div class="card">
<div class="card-head"><h3>Counters audit</h3><span class="sub" id="audSub">views vs delivery logs, charges vs shows</span></div>
<p class="small muted" style="margin:0 0 8px">Every format's recorded views are reconciled against the log that proves delivery, login days charged against days shown, and featured bookings checked for views. Runs daily and alerts you on Telegram; run it any time here.</p>
<div style="margin:0 0 8px"><button type="button" class="btn small sec" id="audRun">Run now</button></div>
<div class="tablewrap"><table class="adm-table" id="audTable"></table></div>
</div>
<div class="card"> <div class="card">
<div class="card-head"><h3>Ad reports</h3><span class="sub">members flagging ads</span></div> <div class="card-head"><h3>Ad reports</h3><span class="sub">members flagging ads</span></div>
<div class="tablewrap"><table class="adm-table" id="repTable"></table></div> <div class="tablewrap"><table class="adm-table" id="repTable"></table></div>
@@ -481,6 +487,6 @@
</div> </div>
<script src="/assets/common.js?v=20260914a"></script> <script src="/assets/common.js?v=20260914a"></script>
<script src="/assets/admin.js?v=20260914d"></script> <script src="/assets/admin.js?v=20260915a"></script>
</body> </body>
</html> </html>
+12
View File
@@ -635,7 +635,19 @@
document.querySelectorAll('#pnlPeriods [data-days]').forEach(b => b.addEventListener('click', () => { pnlDays = Number(b.dataset.days); document.querySelectorAll('#pnlPeriods [data-days]').forEach(x => x.classList.toggle('on', x === b)); loadPnl().catch(e => IAP.status(e.message, 'bad')); })); document.querySelectorAll('#pnlPeriods [data-days]').forEach(b => b.addEventListener('click', () => { pnlDays = Number(b.dataset.days); document.querySelectorAll('#pnlPeriods [data-days]').forEach(x => x.classList.toggle('on', x === b)); loadPnl().catch(e => IAP.status(e.message, 'bad')); }));
if ($('pnlFixedSave')) $('pnlFixedSave').addEventListener('click', async () => { try { await api('/api/admin/site', { pnlFixedMonthlyUsd: Number($('pnlFixed').value) || 0 }, 'PATCH'); IAP.status('Saved.', 'ok'); loadPnl(); } catch (e) { IAP.status(e.message, 'bad'); } }); if ($('pnlFixedSave')) $('pnlFixedSave').addEventListener('click', async () => { try { await api('/api/admin/site', { pnlFixedMonthlyUsd: Number($('pnlFixed').value) || 0 }, 'PATCH'); IAP.status('Saved.', 'ok'); loadPnl(); } catch (e) { IAP.status(e.message, 'bad'); } });
if ($('burnerRun')) $('burnerRun').addEventListener('click', async () => { try { const r = await api('/api/admin/burner/run', {}); IAP.status('Burner ran: ' + (r.burned || 0) + ' burned.', 'ok'); loadPnl(); } catch (e) { IAP.status(e.message, 'bad'); } }); if ($('burnerRun')) $('burnerRun').addEventListener('click', async () => { try { const r = await api('/api/admin/burner/run', {}); IAP.status('Burner ran: ' + (r.burned || 0) + ' burned.', 'ok'); loadPnl(); } catch (e) { IAP.status(e.message, 'bad'); } });
async function loadAudit() {
if (!$('audTable')) return;
try {
$('audSub').textContent = 'checking…';
const a = await api('/api/admin/audit');
const bad = a.checks.filter(c => !c.ok).length;
$('audSub').textContent = (bad ? bad + ' issue' + (bad === 1 ? '' : 's') : 'all counters reconcile') + ' · checked ' + new Date(a.checkedAt).toLocaleTimeString();
$('audTable').innerHTML = '<tr><th>Check</th><th>Status</th><th>Detail</th></tr>' + a.checks.map(c => '<tr><td>' + esc(c.name) + '</td><td>' + (c.ok ? '<span class="badge">ok</span>' : '<span class="badge amber">' + c.issues.length + ' issue' + (c.issues.length === 1 ? '' : 's') + '</span>') + '</td><td class="small">' + esc(c.detail) + (c.issues.length ? '<br>' + c.issues.map(esc).join('<br>') : '') + '</td></tr>').join('');
} catch (e) { $('audSub').textContent = e.message; }
}
if ($('audRun')) $('audRun').addEventListener('click', busy($('audRun'), loadAudit));
async function loadReports() { async function loadReports() {
loadAudit();
const [r, b] = await Promise.all([api('/api/admin/reports'), api('/api/admin/burns')]); const [r, b] = await Promise.all([api('/api/admin/reports'), api('/api/admin/burns')]);
const reps = r.reports || []; const reps = r.reports || [];
$('repTable').innerHTML = reps.length ? '<tr><th>When</th><th>Campaign</th><th>Reason</th><th>Note</th><th>By</th><th></th></tr>' $('repTable').innerHTML = reps.length ? '<tr><th>When</th><th>Campaign</th><th>Reason</th><th>Note</th><th>By</th><th></th></tr>'
+8 -1
View File
@@ -32,7 +32,8 @@ const blog = require('./blog');
const adminMember = require('./adminmember'); const adminMember = require('./adminmember');
const syndicate = require('./syndicate'); const syndicate = require('./syndicate');
const releases = require('./releases'); const releases = require('./releases');
const updates = require('./updates'); // member update emails from Admin > Releases (Marty, 2026-09-14) 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 leaderboard = require('./leaderboard');
const toolkit = require('./toolkit'); const toolkit = require('./toolkit');
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 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)
@@ -379,6 +380,8 @@ async function boot() {
releases.init({ dataDir: DATA_DIR }); releases.init({ dataDir: DATA_DIR });
toolkit.init({ dataDir: DATA_DIR, ads, accounts, siteConfig, coach, messages, promos, videomaker, chain }); toolkit.init({ dataDir: DATA_DIR, ads, accounts, siteConfig, coach, messages, promos, videomaker, chain });
updates.init({ dataDir: DATA_DIR, accounts, releases, mailer, drip, sendy, adminEmail: ADMIN_EMAIL }); updates.init({ dataDir: DATA_DIR, accounts, releases, mailer, drip, sendy, adminEmail: ADMIN_EMAIL });
audit.init({ notify: text => { const sc = siteConfig(); if (sc.telegramBotToken && sc.telegramAdminChatId) telegramSend(sc.telegramAdminChatId, text).catch(() => {}); else if (ADMIN_EMAIL && mailer.hasKey()) mailer.send(ADMIN_EMAIL, 'InstantAdPay: counter audit', text).catch(() => {}); } });
setTimeout(() => audit.dailyTick(), 5 * 60 * 1000); setInterval(() => audit.dailyTick(), 24 * 60 * 60 * 1000);
videomaker.init({ dataDir: DATA_DIR, spaces, accounts }); videomaker.init({ dataDir: DATA_DIR, spaces, accounts });
leaderboard.init({ chain, accounts, ads, dataDir: DATA_DIR, siteConfig, pushFeed, adminEmail: ADMIN_EMAIL, leaderboard.init({ chain, accounts, ads, dataDir: DATA_DIR, siteConfig, pushFeed, adminEmail: ADMIN_EMAIL,
notify: async text => { const sc = siteConfig(); if (!sc.telegramBotToken || !sc.telegramEchoChatId) return; await telegramSend(sc.telegramEchoChatId, text, sc.telegramEchoTopicId); if (String(sc.leaderboardAnnounceGeneral || '1') !== '0') await telegramSend(sc.telegramEchoChatId, text, null); } }); notify: async text => { const sc = siteConfig(); if (!sc.telegramBotToken || !sc.telegramEchoChatId) return; await telegramSend(sc.telegramEchoChatId, text, sc.telegramEchoTopicId); if (String(sc.leaderboardAnnounceGeneral || '1') !== '0') await telegramSend(sc.telegramEchoChatId, text, null); } });
@@ -2418,6 +2421,10 @@ const server = http.createServer(async (req, res) => {
const r = await ads.adminSetStatus(m[1], m[2] === 'pause' ? 'paused' : 'active'); const r = await ads.adminSetStatus(m[1], m[2] === 'pause' ? 'paused' : 'active');
return json(res, r.error ? 400 : 200, r); return json(res, r.error ? 400 : 200, r);
} }
if (p === '/api/admin/audit' && req.method === 'GET') { // counters reconciled against delivery logs
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
return json(res, 200, await audit.run());
}
if (p === '/api/admin/reports' && req.method === 'GET') { if (p === '/api/admin/reports' && req.method === 'GET') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
return json(res, 200, { reports: await reports.list(200) }); return json(res, 200, { reports: await reports.list(200) });