Files
rm-circle-team-router/suite-traffic.js
T
martbost 59bd4bd4ab Show recorded figures for closed campaigns, not the wiped counter
My own sweep created this. Deactivating an ad ZEROES `remaining` on the
network, and for banners — where that column counts UP — zero reads as
"served nothing". So every campaign the sweep closed flipped to 0 served /
2,500 left, despite the ledger correctly recording served=2500,
completed=true.

Closed campaigns now read from what we recorded at the time rather than
re-deriving from a counter that no longer holds anything. Live campaigns
still compute from the network, per placement type.

Also removed the em-dash placeholder that blanked LEFT for stopped rows —
there is a real number to show — and made stop()'s error path assume fully
served rather than fully unserved, since guessing high would refund
impressions that had actually been delivered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 05:33:55 -05:00

359 lines
16 KiB
JavaScript

// Circle Suite — Traffic Desk. Places member banner ads on the team's own
// NetworkAdSpace network through the signed bridge API (rmc-api on that host).
//
// Two deliberate constraints:
// 1. Creative comes from the TEAM banner kit only. Members never upload art.
// That removes moderation risk entirely — every ad on the network carries
// approved branding — and it means a member can launch in two clicks.
// 2. Allowances scale with contract level and are counted per calendar month
// against a local ledger, so a member cannot spend more than their level.
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const suiteGrants = require('./suite-grants');
let DATA_DIR = null;
function init(opts) { DATA_DIR = opts.dataDir; }
function credsFile() { return path.join(DATA_DIR, 'nas-api.json'); }
function creds() { try { return JSON.parse(fs.readFileSync(credsFile(), 'utf8')); } catch (e) { return null; } }
function configured() { const c = creds(); return !!(c && c.url && c.secret); }
// Monthly impressions by contract level (index = level-1). Marty-approved.
const ALLOWANCE = [2500, 5000, 10000, 20000, 50000, 75000, 100000, 150000];
function allowanceFor(level) {
const n = Math.max(1, Math.min(8, Number(level) || 1));
return ALLOWANCE[n - 1];
}
// Team banner kit — every ad uses approved creative, so nothing needs review.
const CREATIVES = {
'468x60': [ 'rmc-468x60-v1.png', 'rmc-468x60-v2.png', 'rmc-468x60-v3.png', 'rmc-468x60-v4.png',
'rmc-468x60-v5.png', 'rmc-468x60-v6.png', 'rmc-468x60-v7.png', 'rmc-growing-468x60-bluegreen.png' ],
'728x90': [ 'rmc-728x90-v1.png', 'rmc-728x90-v2.png', 'rmc-728x90-v3.png' ],
'300x250': [ 'rmc-300x250-v1.png', 'rmc-300x250-v3.png', 'rmc-300x250-v4.png' ],
'160x600': [ 'rmc-160x600-v1.png', 'rmc-160x600-v2.png', 'rmc-160x600-v3.png' ],
'120x600': [ 'rmc-120x600-v1.png', 'rmc-120x600-v2.png' ],
'125x125': [ 'rmc-banner-125x125.png' ]
};
function sizes() { return Object.keys(CREATIVES); }
// ── local ledger: what each position has been granted this month ────────────
function ledgerFile() { return path.join(DATA_DIR, 'traffic-grants.json'); }
function readLedger() { try { return JSON.parse(fs.readFileSync(ledgerFile(), 'utf8')); } catch (e) { return {}; } }
function writeLedger(v) { try { fs.writeFileSync(ledgerFile(), JSON.stringify(v)); } catch (e) {} }
function monthKey() { const d = new Date(); return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0'); }
function usage(memberId) {
const all = readLedger();
const rows = ((all[monthKey()] || {})[String(memberId)]) || [];
const used = rows.reduce(function (s, r) { return s + (Number(r.impressions) || 0); }, 0);
return { used: used, campaigns: rows };
}
function status(memberId, level) {
const base = allowanceFor(level);
// Impressions an upline granted this month sit on top of the level allowance.
let granted = 0;
try { granted = suiteGrants.receivedBy(memberId, 'traffic') || 0; } catch (e) {}
const limit = base + granted;
const u = usage(memberId);
return {
limit: limit, base: base, granted: granted,
used: u.used, remaining: Math.max(0, limit - u.used),
campaigns: u.campaigns, resets: monthKey(), sizes: sizes(), creatives: CREATIVES
};
}
function record(memberId, entry) {
const all = readLedger();
const mk = monthKey();
if (!all[mk]) all[mk] = {};
const id = String(memberId);
if (!all[mk][id]) all[mk][id] = [];
all[mk][id].push(entry);
// keep three months of history; the file stays small forever
const keep = Object.keys(all).sort().slice(-3);
const trimmed = {};
keep.forEach(function (k) { trimmed[k] = all[k]; });
writeLedger(trimmed);
}
// The ad network counts the SAME `remaining` column in opposite directions for
// the two placement types, which is not documented anywhere and had to be
// measured:
//
// TEXT ads — `remaining` counts DOWN from the purchase to zero as it serves.
// (ad 2689: 1,111 left mid-flight, 0 once complete.)
// BANNER ads — `remaining` counts UP as impressions are delivered, straight
// past the amount purchased. (ad 2707: 3,521 one day, 6,129 the
// next, on a 2,500 buy.)
//
// Reading both the same way made every banner report "0 served" while quietly
// delivering thousands — and made stop() treat delivered impressions as
// unserved and refund them.
//
// Returns what the member should see: served + left always reconciles to what
// they bought, and neither can exceed it.
function interpret(kind, bought, stat) {
const b = Math.max(0, Number(bought) || 0);
const rem = Math.max(0, Number(stat && stat.remaining) || 0);
let served;
if (kind === 'text') {
// count-down: what is gone is what was bought minus what is left
served = b - Math.min(rem, b);
} else {
// count-up: `remaining` IS the delivered count
served = Math.min(rem, b);
}
served = Math.max(0, Math.min(served, b));
return { served: served, left: Math.max(0, b - served) };
}
// ── signed call to the NAS bridge ───────────────────────────────────────────
function callNas(payload) {
return new Promise(function (resolve, reject) {
const c = creds();
if (!c) return reject(new Error('The ad network bridge is not configured yet.'));
const body = JSON.stringify(payload);
const ts = String(Math.floor(Date.now() / 1000));
const sig = crypto.createHmac('sha256', c.secret).update(ts + '.' + body).digest('hex');
const u = new URL(c.url);
const lib = u.protocol === 'https:' ? require('https') : require('http');
const req = lib.request({
hostname: u.hostname, port: u.port || (u.protocol === 'https:' ? 443 : 80),
path: u.pathname + (u.search || ''), method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
'X-RMC-Signature': sig,
'X-RMC-Timestamp': ts,
'User-Agent': 'RMCircleSuite/1.0'
}
}, function (res) {
let data = '';
res.on('data', function (d) { data += d; });
res.on('end', function () {
let j = null;
try { j = JSON.parse(data); } catch (e) { return reject(new Error('The ad network returned something unexpected.')); }
if (!j.ok) return reject(new Error(j.error || 'The ad network rejected that.'));
resolve(j);
});
});
req.on('error', function (e) { reject(new Error('Could not reach the ad network: ' + e.message)); });
req.setTimeout(45000, function () { req.destroy(new Error('The ad network took too long.')); });
req.write(body); req.end();
});
}
async function launch(opts) {
const level = Number(opts.level) || 1;
const isText = opts.kind === 'text';
let size = '', file = '', subject = '', lines = [];
if (isText) {
// Limits measured from live network inventory — see suite-textads.js.
subject = String(opts.subject || '').trim();
lines = (Array.isArray(opts.lines) ? opts.lines : []).slice(0, 3)
.map(function (l) { return String(l || '').replace(/[<>]/g, '').trim(); });
while (lines.length < 3) lines.push('');
if (!subject || !lines[0]) throw new Error('That text ad is missing its headline or first line.');
if (Array.from(subject).length > 20) throw new Error('The headline is longer than the network allows.');
if (lines.some(function (l) { return Array.from(l).length > 24; })) {
throw new Error('One of those lines is longer than the network allows.');
}
} else {
size = String(opts.size || '');
if (!CREATIVES[size]) throw new Error('Pick one of the available banner sizes.');
file = String(opts.creative || '');
if (CREATIVES[size].indexOf(file) === -1) throw new Error('Pick one of the team banner designs.');
}
const st = status(opts.id, level);
const impressions = Math.max(100, Math.min(st.limit, Number(opts.impressions) || 0));
if (impressions > st.remaining) {
throw new Error('That is more than your remaining ' + st.remaining.toLocaleString() + ' impressions this month.');
}
// Server-side guard: only allow the personal page as a destination when one
// actually exists. /p/<id> also redirects to /join/<id> when empty, so a live
// banner can never dead-end — but we shouldn't create that situation at all.
let target = 'https://rmcircle.team/join/' + opts.id + (opts.angle ? '?v=' + opts.angle : '');
if (opts.target === 'page') {
if (!opts.hasPage) throw new Error('Build your personal page first, then you can point ads at it.');
target = 'https://rmcircle.team/p/' + opts.id;
}
const idem = 'rmc-' + opts.id + '-' + monthKey() + '-' + crypto.randomBytes(6).toString('hex');
const payload = {
action: 'create', member_id: Number(opts.id), idem_key: idem,
kind: isText ? 'text' : 'banner',
// 30 days, not 365: the allowance is monthly, so a campaign that outlives the
// month it was paid from makes the allowance meaningless. Belt and braces
// with sweepCompleted() — whichever ends it first.
impressions: impressions, days: 30, target_url: target,
advertiser_name: (opts.name || 'RM Circle member #' + opts.id).slice(0, 60),
advertiser_email: '', catid: 5
};
if (isText) {
payload.subject = subject;
payload.lines = lines;
} else {
payload.size = size;
payload.banner_url = 'https://rmcircle.team/banners/' + file;
}
const res = await callNas(payload);
const entry = {
adId: res.ad_id, impressions: impressions,
size: isText ? 'text' : size, creative: isText ? '' : file,
kind: isText ? 'text' : 'banner',
subject: isText ? subject : undefined,
lines: isText ? lines : undefined,
target: target, at: new Date().toISOString()
};
record(opts.id, entry);
return entry;
}
// Stop a running banner and return the UNSERVED impressions to the member's
// monthly balance. Order matters: read the counters BEFORE deactivating,
// because deactivation zeroes `remaining` and would make it look fully served.
async function stop(memberId, adId) {
const all = readLedger();
const mk = monthKey();
const rows = ((all[mk] || {})[String(memberId)]) || [];
const pre = rows.find(function (r) { return Number(r.adId) === Number(adId); });
if (!pre) throw new Error('That banner is not one of yours from this month.');
if (pre.stopped) throw new Error('That banner is already stopped.');
let served = 0, unserved = 0;
const boughtGuess = Number(pre.bought != null ? pre.bought : pre.impressions) || 0;
try {
const s = await callNas({ action: 'stats', ad_ids: [Number(adId)] });
const st = (s.stats || [])[0];
if (st) {
// Interpret by placement type — banners count up, text counts down.
const r = interpret(pre.kind === 'text' ? 'text' : 'banner', boughtGuess, st);
served = r.served;
unserved = r.left;
}
} catch (e) {
// Stats unavailable: refund nothing rather than guess. Guessing high would
// hand back impressions that were actually delivered.
served = boughtGuess; unserved = 0;
}
await callNas({ action: 'deactivate', ad_id: Number(adId) });
// RE-READ before writing. Everything above this point took two network round
// trips to the ad network, and the ledger object read at the top of this
// function is now seconds stale. Writing it back would silently discard any
// campaign another member launched in the meantime — impressions spent on the
// network with no record that they were. Node's single thread makes the block
// below atomic; the danger was only ever the await gap.
const fresh = readLedger();
const freshRows = ((fresh[mk] || {})[String(memberId)]) || [];
const row2 = freshRows.find(function (r) { return Number(r.adId) === Number(adId); });
if (!row2) throw new Error('That banner is no longer in this month\'s ledger.');
if (row2.stopped) return { adId: Number(adId), served: row2.served || 0, refunded: row2.refunded || 0 };
const all2 = fresh;
const row = row2;
// Charge only what actually served; the rest returns to the allowance.
// `bought` preserves the original order size so the member's history still
// shows what they launched, not just what it ended up costing them.
if (row.bought == null) row.bought = row.impressions;
// interpret() already reconciled these against what was bought.
unserved = Math.min(unserved, row.bought);
served = row.bought - unserved;
row.stopped = true;
row.stoppedAt = new Date().toISOString();
row.served = served;
row.refunded = unserved;
row.impressions = served; // what this campaign counts against the month
writeLedger(all2);
return { adId: Number(adId), served: served, refunded: unserved };
}
async function stats(adIds) {
if (!adIds || !adIds.length) return [];
const r = await callNas({ action: 'stats', ad_ids: adIds.slice(0, 200) });
return r.stats || [];
}
// Deactivate anything that has delivered what was bought.
//
// This exists because banner placements on this network have NO CAP: the
// counter runs past the purchase and the ad keeps rotating until its expiry
// date. Left alone that quietly destroys the whole point of the allowance —
// if 2,500 impressions buys an ad that runs forever, nobody would ever spend
// 50,000, and the level ladder from Scintilla to Corona stops meaning
// anything. The impressions have to be the thing you are actually spending.
//
// Runs on a timer, batches its reads, and is deliberately conservative: it
// only ever touches campaigns that are live, un-stopped, and provably at or
// past their purchased amount. Nothing is refunded — they delivered in full.
async function sweepCompleted() {
if (!configured()) return { checked: 0, closed: 0 };
const all = readLedger();
const mk = monthKey();
const month = all[mk] || {};
const live = [];
Object.keys(month).forEach(function (mid) {
(month[mid] || []).forEach(function (r) {
if (!r.stopped && !r.completed && r.adId) live.push({ mid: mid, row: r });
});
});
if (!live.length) return { checked: 0, closed: 0 };
const byId = {};
for (let i = 0; i < live.length; i += 150) {
try {
const chunk = await stats(live.slice(i, i + 150).map(function (x) { return x.adId || x.row.adId; }));
chunk.forEach(function (st) { byId[st.ad_id] = st; });
} catch (e) { return { checked: live.length, closed: 0, error: e.message }; }
}
let closed = 0;
for (const item of live) {
const r = item.row;
const st = byId[r.adId];
if (!st || !st.live) continue;
const bought = Number(r.bought != null ? r.bought : r.impressions) || 0;
if (!bought) continue;
const got = interpret(r.kind === 'text' ? 'text' : 'banner', bought, st);
if (got.served < bought) continue;
try { await callNas({ action: 'deactivate', ad_id: Number(r.adId) }); }
catch (e) { continue; }
closed++;
}
if (closed) {
// Re-read: the deactivate calls above took real time, and another member
// may have launched in that window.
const fresh = readLedger();
const fm = fresh[mk] || {};
Object.keys(fm).forEach(function (mid) {
(fm[mid] || []).forEach(function (r) {
const hit = live.find(function (x) { return x.row.adId === r.adId; });
if (!hit) return;
const st = byId[r.adId];
if (!st || !st.live) return;
const bought = Number(r.bought != null ? r.bought : r.impressions) || 0;
if (!bought) return;
if (interpret(r.kind === 'text' ? 'text' : 'banner', bought, st).served < bought) return;
r.completed = true;
r.completedAt = new Date().toISOString();
r.served = bought;
});
});
writeLedger(fresh);
}
return { checked: live.length, closed: closed };
}
module.exports = { init, configured, status, launch, stop, stats, interpret, sweepCompleted, allowanceFor, sizes, CREATIVES, ALLOWANCE };