Country-tier targeting for campaigns: DB-IP lite lookup, tier lists in Settings, Show-to tiers on every format, geo-restricted ads kept off the network, per-country serve stats

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-11 12:02:40 -05:00
parent 0f855292f5
commit cd26161db5
7 changed files with 158 additions and 9 deletions
+15 -6
View File
@@ -25,6 +25,7 @@ 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 tank = require('./tank'); // holding tank: unsponsored free members, adoptions, pay-it-forward
const geo = require('./geo'); // viewer country -> tier (DB-IP lite), for campaign targeting
const burner = require('./burner'); // automatic on-chain credit burns (inert without ENGINE_KEY)
const PORT = Number(process.env.PORT || 3000);
@@ -126,6 +127,8 @@ const codeHits = new Map(); // ip -> { t: [send timestamps, 24h], passUntil, cha
const codeGlobal = { minute: 0, n: 0 };
const codeAlert = { last: 0, trips: 0, ips: new Set() };
function clientIp(req) { return String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim() || 'unknown'; }
// the viewer's country and tier for ad targeting (null tier = unknown: never matches a restricted campaign)
function viewerGeo(req) { const cc = geo.countryOf(clientIp(req)); return { cc, tier: geo.tierOf(cc, siteConfig()) }; }
function codeChallenge(rec) {
const pick = CAPTCHA.slice().sort(() => Math.random() - 0.5).slice(0, 5);
const answer = Math.floor(Math.random() * pick.length);
@@ -307,6 +310,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 });
tank.init({ dataDir: DATA_DIR, chain, accounts, messages, mailer, site: 'https://instantadpay.com' });
geo.init({ dataDir: DATA_DIR }).catch(e => console.error('geo init', e.message));
setInterval(() => geo.refresh().catch(e => console.error('geo refresh', e.message)), 24 * 3600 * 1000); // monthly file, checked daily
setInterval(() => tank.sweep().catch(e => console.error('tank sweep', e.message)), 60 * 60 * 1000); // adoptions past their 7-day window
burner.init({ chain, ads });
setTimeout(() => coach.nudgeTick().catch(e => console.error('coach', e.message)), 90 * 1000);
@@ -335,6 +340,8 @@ function siteConfig() {
telegramBotToken: '', telegramChatId: '', telegramTopicId: '', telegramEvents: 'payouts', telegramCtaUrl: 'https://instantadpay.com/',
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
geoTier1: '', // comma-separated ISO country codes; empty = built-in default (US, CA, GB, AU, NZ, IE, DE, FR, NL, SE, NO, DK, FI, CH, AT, BE)
geoTier2: '', // empty = built-in default (rest of Western/Central Europe, JP, KR, SG, HK, TW, IL, Gulf, ZA, BR, MX, AR, CL, CO ...); tier 3 = everything else
pnlFixedMonthlyUsd: 0
}, saved);
}
@@ -1093,7 +1100,7 @@ const server = http.createServer(async (req, res) => {
if (status.views >= status.target || status.claimed) return json(res, 200, { ad: null, status });
const type = String(u.searchParams.get('type') || 'banner');
// members never see (or earn from) their own campaigns in the viewer
const ad = await ads.serve(type === 'text' ? 'text' : 'banner', { excludeEmail: s.email });
const ad = await ads.serve(type === 'text' ? 'text' : 'banner', Object.assign({ excludeEmail: s.email }, viewerGeo(req)));
if (!ad) return json(res, 200, { ad: null, status });
const token = crypto.randomBytes(16).toString('hex');
// the viewer tab frames the advertiser's REAL url (no click counted for a paid view)
@@ -1382,7 +1389,7 @@ const server = http.createServer(async (req, res) => {
}
// -- featured rotation: the live featured links + dilution stats
if (p === '/api/featured' && req.method === 'GET') {
const items = await ads.serveFeatured();
const items = await ads.serveFeatured(viewerGeo(req));
const names = await accounts.namesForMembers([...new Set(items.map(i => i.memberId).filter(Boolean))]);
for (const i of items) i.by = (i.memberId && names[i.memberId]) ? '@' + names[i.memberId] : (i.memberId ? 'member #' + i.memberId : null);
return json(res, 200, { items });
@@ -1530,7 +1537,7 @@ const server = http.createServer(async (req, res) => {
const status = await ads.videoStatus(s.email);
if (status.left <= 0) return json(res, 200, { ad: null, status });
const orientation = String(u.searchParams.get('orientation') || ''); // 'portrait' = Shorts reel, 'landscape' = Watch videos tab
const ad = await ads.serveVideo({ excludeEmail: s.email, orientation }); // never your own video
const ad = await ads.serveVideo(Object.assign({ excludeEmail: s.email, orientation }, viewerGeo(req))); // never your own video
if (!ad) return json(res, 200, { ad: null, status });
const token = crypto.randomBytes(16).toString('hex');
videoTokens.set(s.email, { token, ts: Date.now(), id: ad.id, secs: ad.watchSecs });
@@ -1562,7 +1569,7 @@ const server = http.createServer(async (req, res) => {
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const status = await ads.visitStatus(s.email);
if (status.count >= status.cap) return json(res, 200, { ad: null, status });
const ad = await ads.serveVisit(s.email);
const ad = await ads.serveVisit(s.email, viewerGeo(req));
if (!ad) return json(res, 200, { ad: null, status });
const token = crypto.randomBytes(16).toString('hex');
visitTokens.set(s.email, { token, ts: Date.now(), id: ad.id });
@@ -1601,7 +1608,7 @@ const server = http.createServer(async (req, res) => {
if (p === '/api/my/inbox' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const r = await ads.inboxList(s.email);
const r = await ads.inboxList(s.email, viewerGeo(req));
const names = await accounts.namesForMembers([...new Set(r.items.map(i => i.fromMemberId).filter(Boolean))]);
for (const i of r.items) i.fromName = (i.fromMemberId && names[i.fromMemberId]) ? '@' + names[i.fromMemberId]
: i.fromMemberId ? 'member #' + i.fromMemberId : 'a member';
@@ -1656,7 +1663,7 @@ const server = http.createServer(async (req, res) => {
// -- ad engine (spec §8b v1: banners, text, login ads)
if (p === '/api/ads/slot' && req.method === 'GET') {
const t = String(u.searchParams.get('type') || 'banner');
const ad = await ads.serve(t, { width: Number(u.searchParams.get('w')) || 0, height: Number(u.searchParams.get('h')) || 0 });
const ad = await ads.serve(t, Object.assign({ width: Number(u.searchParams.get('w')) || 0, height: Number(u.searchParams.get('h')) || 0 }, viewerGeo(req)));
// login ads: the member clicks "Open Ad" (a real, counted click into a
// new tab) while the countdown runs on our interstitial page
if (ad && t === 'login') ad.dwell = ads.rates().loginDwellSeconds || 10;
@@ -1677,6 +1684,8 @@ const server = http.createServer(async (req, res) => {
const out = { campaigns: await ads.listCampaigns(s.email), rates: ads.rates(), bannerSizes: ads.bannerSizes() };
out.clickSources = await coach.clickSources(out.campaigns.map(c => c.id));
out.hours = await ads.hoursFor(out.campaigns.map(c => c.id)); // on-site views per UTC hour, last 7 days
out.geo = await ads.geoFor(out.campaigns.map(c => c.id)); // on-site serves per viewer country
{ const t = geo.tierLists(siteConfig()); out.tiers = { t1: [...t.t1], t2: [...t.t2] }; out.geoReady = geo.status().loaded; }
const pool = await ads.balances((await myMemberIds(s)).ids, s.email);
out.purchasedCredits = pool.total;
out.largestPosition = pool.best.avail; // a single campaign budget has to fit one position