Member campaigns now go out as click offers on DripOffers
Third syndication rail. The first two send impressions; this one sends clicks — a real person picks the offer off the offerwall, goes to the member's link, and has to stay the dwell time before anyone is paid. Two things it does that the other rails can't: - It carries geo-targeted campaigns. DripOffers filters on an explicit country list, so a campaign aimed at Tier 1 finally reaches an outside audience instead of staying on our own site. Tier-3-only campaigns are skipped rather than quietly sent worldwide, because an inclusion list can't express "everywhere except the other tiers" and widening it would deliver exactly the traffic the owner chose to exclude. - Delivery is counted, not derived. It reads rows from the platform's permanent click ledger. It never computes delivery as ordered minus remaining, and pausing never zeroes remaining — that pair is what charged members for impressions that never ran on Network Ad Space. Also fixes two live bugs found while wiring it: the AdRevLinks pause was nested inside the Network Ad Space check in both the end sweep and setStatus, so with NAS switched off an ended or paused campaign kept running on AdRevLinks. Each rail is now checked on its own. Caps: 1,000 credits minimum, 10,000 clicks maximum per campaign. Not for cost — Marty owns the platform and these placements are free — but because the whole site delivers around 2,700 clicks a day, and one campaign booking 50,000 would sit in the list for weeks. Inert unless DRIPOFFERS_BRIDGE_URL and _KEY are set. 26 checks green against the live endpoint; member walk clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -120,6 +120,7 @@ async function bootstrap() {
|
|||||||
await alterSafe('ALTER TABLE campaigns ADD COLUMN height INT NULL'); // banner size (IAB) → also NAS height
|
await alterSafe('ALTER TABLE campaigns ADD COLUMN height INT NULL'); // banner size (IAB) → also NAS height
|
||||||
await alterSafe('ALTER TABLE campaigns ADD COLUMN nas_ad_id INT NULL'); // syndicated NAS sponsorads.ID
|
await alterSafe('ALTER TABLE campaigns ADD COLUMN nas_ad_id INT NULL'); // syndicated NAS sponsorads.ID
|
||||||
await alterSafe('ALTER TABLE campaigns ADD COLUMN nas_served INT NOT NULL DEFAULT 0'); // NAS impressions already reconciled into spend
|
await alterSafe('ALTER TABLE campaigns ADD COLUMN nas_served INT NOT NULL DEFAULT 0'); // NAS impressions already reconciled into spend
|
||||||
|
await alterSafe('ALTER TABLE campaigns ADD COLUMN drip_id INT NULL'); // syndicated DripOffers campaigns.id (2026-09-18)
|
||||||
await alterSafe('ALTER TABLE campaigns ADD COLUMN expires BIGINT NULL'); // featured rotation end time
|
await alterSafe('ALTER TABLE campaigns ADD COLUMN expires BIGINT NULL'); // featured rotation end time
|
||||||
await alterSafe('ALTER TABLE campaigns ADD COLUMN starts BIGINT NULL'); // featured run start (booked day); any type: scheduled start
|
await alterSafe('ALTER TABLE campaigns ADD COLUMN starts BIGINT NULL'); // featured run start (booked day); any type: scheduled start
|
||||||
await q(`CREATE TABLE IF NOT EXISTS camp_hours (
|
await q(`CREATE TABLE IF NOT EXISTS camp_hours (
|
||||||
|
|||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
// Syndicate member campaigns to DripOffers as paid-per-click offers.
|
||||||
|
//
|
||||||
|
// Third syndication rail, and the one that behaves least like the other two. Network Ad Space
|
||||||
|
// and AdRevLinks both serve impressions into a rotation. DripOffers serves *clicks*: a real
|
||||||
|
// person picks the offer off an offerwall, visits the target URL, and has to stay the pack's
|
||||||
|
// dwell time before anybody gets paid. So a DripOffers click is worth far more than an
|
||||||
|
// impression, and there are far fewer of them.
|
||||||
|
//
|
||||||
|
// The DripOffers database is not reachable from this server, so we talk to a narrow
|
||||||
|
// authenticated endpoint that runs there (public_html/iap-bridge.php) and does the insert
|
||||||
|
// locally. Six actions, no general query surface.
|
||||||
|
//
|
||||||
|
// FEATURE-FLAGGED: inert unless DRIPOFFERS_BRIDGE_URL and DRIPOFFERS_BRIDGE_KEY are set.
|
||||||
|
//
|
||||||
|
// Three things carried over deliberately from the NAS and AdRevLinks work:
|
||||||
|
//
|
||||||
|
// 1. DELIVERY IS READ, NEVER DERIVED. `status` counts rows in that platform's permanent
|
||||||
|
// per-click ledger. We never compute delivery from "ordered minus remaining", because
|
||||||
|
// remaining is mutable and deriving from it is what charged members for impressions that
|
||||||
|
// never ran on NAS.
|
||||||
|
//
|
||||||
|
// 2. EVERY WRITE IS IDEMPOTENT. Each campaign carries ref "iap:<id>" behind a UNIQUE index
|
||||||
|
// on the far side, so a retry after a timeout cannot book a second campaign.
|
||||||
|
//
|
||||||
|
// 3. A SYNDICATION HICCUP NEVER BLOCKS THE MEMBER. Callers swallow errors; the IAP campaign
|
||||||
|
// goes live either way.
|
||||||
|
//
|
||||||
|
// Capacity note (2026-09-18): the offerwall is a menu, not a rotator - every eligible campaign
|
||||||
|
// is listed at once, ordered by reward, and the member chooses. So adding campaigns does not
|
||||||
|
// starve the others the way the AdRevLinks rotator does; it lengthens the menu. What is finite
|
||||||
|
// is total clicks, running around 2,700 a day across the whole platform. Hence MAX_CLICKS
|
||||||
|
// below: one campaign booking 50,000 clicks would sit in the list for weeks. Cost is not the
|
||||||
|
// reason for the caps - Marty owns the platform and treats these placements as free.
|
||||||
|
'use strict';
|
||||||
|
const https = require('https');
|
||||||
|
const { URL } = require('url');
|
||||||
|
|
||||||
|
const MIN_CREDITS = Number(process.env.DRIPOFFERS_MIN_CREDITS || 1000); // below this, not worth a listing
|
||||||
|
const MAX_CLICKS = Number(process.env.DRIPOFFERS_MAX_CLICKS || 10000);
|
||||||
|
const MIN_CLICKS = 500; // the platform's own floor
|
||||||
|
const PACK_ID = Number(process.env.DRIPOFFERS_PACK_ID || 2); // 2 = a 10-second dwell
|
||||||
|
const USER_ID = Number(process.env.DRIPOFFERS_USER_ID || 1); // the funding account
|
||||||
|
|
||||||
|
// Which IAP formats make sense as a click offer: the ones whose whole point is sending a
|
||||||
|
// person to the target URL. Video and solo are a different product, and featured is a slot
|
||||||
|
// buy on our own site rather than a destination.
|
||||||
|
const KINDS = ['banner', 'text', 'visits'];
|
||||||
|
|
||||||
|
function enabled() {
|
||||||
|
return !!(process.env.DRIPOFFERS_BRIDGE_URL && process.env.DRIPOFFERS_BRIDGE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function call(action, payload, timeoutMs) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!enabled()) return reject(new Error('dripoffers-disabled'));
|
||||||
|
const u = new URL(process.env.DRIPOFFERS_BRIDGE_URL);
|
||||||
|
const body = JSON.stringify(Object.assign({ action }, payload || {}));
|
||||||
|
const req = https.request({
|
||||||
|
hostname: u.hostname, path: u.pathname + u.search, method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body),
|
||||||
|
'X-Bridge-Key': process.env.DRIPOFFERS_BRIDGE_KEY },
|
||||||
|
timeout: timeoutMs || 25000
|
||||||
|
}, res => {
|
||||||
|
let d = '';
|
||||||
|
res.on('data', c => d += c);
|
||||||
|
res.on('end', () => {
|
||||||
|
let j = null;
|
||||||
|
try { j = JSON.parse(d); } catch (e) {}
|
||||||
|
if (!j) return reject(new Error('dripoffers ' + res.statusCode + ': ' + d.slice(0, 120)));
|
||||||
|
if (j.error) return reject(new Error(j.error));
|
||||||
|
resolve(j);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.on('timeout', () => req.destroy(new Error('dripoffers timeout')));
|
||||||
|
req.end(body);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const refFor = campaignId => 'iap:' + Number(campaignId);
|
||||||
|
const kindOk = type => KINDS.includes(String(type || ''));
|
||||||
|
|
||||||
|
// How many clicks a campaign's remaining credits are worth, capped so one campaign cannot
|
||||||
|
// park itself at the top of the offerwall for a month.
|
||||||
|
function clicksFor(credits) {
|
||||||
|
const v = Math.max(0, Math.floor(Number(credits) || 0));
|
||||||
|
return Math.min(v, MAX_CLICKS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Country targeting. Unlike the AdRevLinks popup rail - which only has prices for five
|
||||||
|
// countries and so cannot carry a targeted campaign at all - this platform filters on an
|
||||||
|
// explicit country list, so geo-restricted campaigns CAN syndicate here. That is the point of
|
||||||
|
// including geo support: those campaigns currently reach no external rail at all.
|
||||||
|
//
|
||||||
|
// Tier 3 is the exception. It is defined as "everywhere the other two tiers are not", which an
|
||||||
|
// inclusion list cannot express, so a tier-3-only campaign is skipped rather than sent
|
||||||
|
// worldwide - sending it worldwide would quietly deliver the Tier 1 traffic its owner chose
|
||||||
|
// not to target.
|
||||||
|
function countriesFor(geo, tiers) {
|
||||||
|
const g = String(geo || '').trim();
|
||||||
|
if (!g) return '*';
|
||||||
|
const want = g.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
if (want.length === 3) return '*';
|
||||||
|
const out = [];
|
||||||
|
if (want.includes('1')) out.push(...(tiers && tiers.t1 ? tiers.t1 : []));
|
||||||
|
if (want.includes('2')) out.push(...(tiers && tiers.t2 ? tiers.t2 : []));
|
||||||
|
if (!out.length) return null; // tier 3 only: not expressible, skip
|
||||||
|
return [...new Set(out.map(c => String(c).toUpperCase()))].join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push a campaign. Returns null when it is not worth syndicating (or cannot be targeted
|
||||||
|
// faithfully) rather than throwing, so the caller treats "too small", "wrong format" and
|
||||||
|
// "disabled" the same quiet way.
|
||||||
|
async function push(campaign, opts) {
|
||||||
|
if (!enabled()) return null;
|
||||||
|
if (!kindOk(campaign.type)) return null;
|
||||||
|
const credits = Number((campaign.budget || 0) - (campaign.spent || 0) - (campaign.accrued || 0));
|
||||||
|
if (credits < MIN_CREDITS) return null;
|
||||||
|
const clicks = clicksFor(credits);
|
||||||
|
if (clicks < MIN_CLICKS) return null;
|
||||||
|
|
||||||
|
const url = String(campaign.targetUrl || campaign.target_url || '').trim();
|
||||||
|
if (!/^https?:\/\//i.test(url)) return null;
|
||||||
|
|
||||||
|
const countries = countriesFor(campaign.geo, (opts && opts.tiers) || null);
|
||||||
|
if (countries === null) return null;
|
||||||
|
|
||||||
|
const title = String(campaign.name || campaign.title || 'InstantAdPay').slice(0, 250);
|
||||||
|
// The offerwall shows this line under the title, so it has to read like an offer rather
|
||||||
|
// than like an internal campaign record.
|
||||||
|
const description = String(campaign.title || campaign.body || campaign.name || title).slice(0, 250);
|
||||||
|
|
||||||
|
return await call('create_ptc', {
|
||||||
|
title, description, url, clicks, countries,
|
||||||
|
device: 0,
|
||||||
|
pack_id: (opts && opts.packId) || PACK_ID,
|
||||||
|
user_id: USER_ID,
|
||||||
|
ref: refFor(campaign.id),
|
||||||
|
activate: !(opts && opts.paused)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real delivery, counted from the platform's own click ledger. Never derived.
|
||||||
|
async function readServed(campaignId) {
|
||||||
|
if (!enabled()) return null;
|
||||||
|
try { return await call('status', { ref: refFor(campaignId) }); }
|
||||||
|
catch (e) { if (/not found/i.test(e.message)) return null; throw e; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pause(campaignId) { return enabled() ? call('pause', { ref: refFor(campaignId) }) : null; }
|
||||||
|
async function resume(campaignId) { return enabled() ? call('resume', { ref: refFor(campaignId) }) : null; }
|
||||||
|
async function remove(campaignId) {
|
||||||
|
if (!enabled()) return null;
|
||||||
|
try { return await call('delete', { ref: refFor(campaignId) }); }
|
||||||
|
catch (e) { if (/not found/i.test(e.message)) return null; throw e; }
|
||||||
|
}
|
||||||
|
async function packs() { return enabled() ? call('packs', {}) : null; }
|
||||||
|
|
||||||
|
module.exports = { enabled, push, readServed, pause, resume, remove, packs,
|
||||||
|
clicksFor, countriesFor, kindOk, refFor,
|
||||||
|
MIN_CREDITS, MAX_CLICKS, MIN_CLICKS, PACK_ID, KINDS };
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// DripOffers paid-per-click syndication, driven against the REAL bridge on dripoffers.com.
|
||||||
|
//
|
||||||
|
// Creates a paused test campaign, reads its delivery back, pauses/resumes it, proves a retry
|
||||||
|
// cannot double-book, then deletes it. Nothing is left behind and nothing is ever activated,
|
||||||
|
// so no publisher is ever paid for a test click.
|
||||||
|
//
|
||||||
|
// DRIPOFFERS_BRIDGE_URL=... DRIPOFFERS_BRIDGE_KEY=... node qa/dripoffers-bridge.mjs
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const drip = require('../dripoffers.js');
|
||||||
|
|
||||||
|
const ok = [], bad = [];
|
||||||
|
const t = (n, c, extra) => { (c ? ok : bad).push(n + (c || !extra ? '' : ' -> ' + extra)); };
|
||||||
|
|
||||||
|
// ---- pure logic first: these run with or without a configured bridge ------
|
||||||
|
const tiers = { t1: new Set(['US', 'CA', 'GB']), t2: new Set(['BR', 'PL']) };
|
||||||
|
t('no targeting means worldwide', drip.countriesFor('', tiers) === '*', drip.countriesFor('', tiers));
|
||||||
|
t('all three tiers collapse to worldwide', drip.countriesFor('1,2,3', tiers) === '*', drip.countriesFor('1,2,3', tiers));
|
||||||
|
t('tier 1 becomes a country list', drip.countriesFor('1', tiers) === 'US,CA,GB', drip.countriesFor('1', tiers));
|
||||||
|
t('tier 1+2 merges both lists', drip.countriesFor('1,2', tiers) === 'US,CA,GB,BR,PL', drip.countriesFor('1,2', tiers));
|
||||||
|
// tier 3 is "everywhere the others are not" — an inclusion list cannot say that, and sending it
|
||||||
|
// worldwide would deliver the Tier 1 traffic its owner deliberately excluded
|
||||||
|
t('tier 3 alone is skipped, not silently widened', drip.countriesFor('3', tiers) === null, String(drip.countriesFor('3', tiers)));
|
||||||
|
|
||||||
|
t('credits convert to clicks one for one', drip.clicksFor(4000) === 4000, String(drip.clicksFor(4000)));
|
||||||
|
t('one campaign cannot swallow the platform', drip.clicksFor(500000) === drip.MAX_CLICKS, String(drip.clicksFor(500000)));
|
||||||
|
t('a click format is accepted', drip.kindOk('banner') && drip.kindOk('visits') && drip.kindOk('text'));
|
||||||
|
t('video and solo are not click formats', !drip.kindOk('video') && !drip.kindOk('solo') && !drip.kindOk('featured'));
|
||||||
|
|
||||||
|
if (!drip.enabled()) {
|
||||||
|
console.log('PASS ' + ok.length + ' (pure logic only)');
|
||||||
|
for (const b of bad) console.log('FAIL ' + b);
|
||||||
|
console.log('SKIP: DRIPOFFERS_BRIDGE_URL / _KEY not set — live bridge checks not run');
|
||||||
|
process.exit(bad.length ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- live bridge ---------------------------------------------------------
|
||||||
|
const ID = 999000 + Math.floor(Math.random() * 900);
|
||||||
|
const camp = { id: ID, name: 'QA bridge campaign', title: 'A test offer that is never activated',
|
||||||
|
targetUrl: 'https://instantadpay.com/', budget: 12000, spent: 0, accrued: 0, type: 'banner' };
|
||||||
|
|
||||||
|
// a campaign below the credit floor is skipped quietly rather than taking a listing
|
||||||
|
const small = await drip.push({ ...camp, id: ID + 1, budget: 200 }, { paused: true, tiers });
|
||||||
|
t('a campaign under the credit floor is not syndicated', small === null, JSON.stringify(small));
|
||||||
|
|
||||||
|
// a format that is not a click offer never goes out
|
||||||
|
const wrongKind = await drip.push({ ...camp, id: ID + 2, type: 'video' }, { paused: true, tiers });
|
||||||
|
t('a video campaign is not syndicated as a click offer', wrongKind === null, JSON.stringify(wrongKind));
|
||||||
|
|
||||||
|
// the real push
|
||||||
|
const r = await drip.push(camp, { paused: true, tiers });
|
||||||
|
t('campaign syndicated', !!(r && r.campaign_id), JSON.stringify(r));
|
||||||
|
t('clicks are capped at the per-campaign ceiling', r && r.clicks_booked === drip.MAX_CLICKS, r && String(r.clicks_booked));
|
||||||
|
t('untargeted campaign goes worldwide', r && r.countries === '*', r && String(r.countries));
|
||||||
|
t('created paused, so no publisher is paid by the test', r && r.status === 0, r && String(r.status));
|
||||||
|
t('the funding account was actually debited', r && r.charged === true, r && String(r.charged));
|
||||||
|
|
||||||
|
// idempotency: the guard that stops a retry double-booking
|
||||||
|
const again = await drip.push(camp, { paused: true, tiers });
|
||||||
|
t('a retry returns the SAME campaign, never a second', again && again.campaign_id === r.campaign_id && again.duplicate === true,
|
||||||
|
JSON.stringify(again));
|
||||||
|
|
||||||
|
// delivery is counted from the click ledger, never derived from remaining_visits
|
||||||
|
const st = await drip.readServed(ID);
|
||||||
|
t('delivery reads back from the platform', !!(st && st.ok), JSON.stringify(st).slice(0, 140));
|
||||||
|
t('ordered clicks match what was booked', st && st.ordered_clicks === drip.MAX_CLICKS, st && String(st.ordered_clicks));
|
||||||
|
t('delivered starts at zero and is a counted figure', st && st.delivered_clicks === 0, st && String(st.delivered_clicks));
|
||||||
|
|
||||||
|
// pause / resume mirror — and the reason this matters: a pause must NOT read back as delivered
|
||||||
|
await drip.resume(ID);
|
||||||
|
let s2 = await drip.readServed(ID);
|
||||||
|
t('resume activates it', s2 && s2.status === 1, s2 && String(s2.status));
|
||||||
|
await drip.pause(ID);
|
||||||
|
s2 = await drip.readServed(ID);
|
||||||
|
t('pause deactivates it', s2 && s2.status === 0, s2 && String(s2.status));
|
||||||
|
t('pausing does not fake delivery', s2 && s2.delivered_clicks === 0 && s2.remaining_visits === drip.MAX_CLICKS,
|
||||||
|
s2 && (s2.delivered_clicks + '/' + s2.remaining_visits));
|
||||||
|
|
||||||
|
// cleanup must actually work, or tests litter a production platform
|
||||||
|
const del = await drip.remove(ID);
|
||||||
|
t('the test campaign is deleted', !!(del && del.ok), JSON.stringify(del));
|
||||||
|
const gone = await drip.readServed(ID);
|
||||||
|
t('and it is really gone', gone === null, JSON.stringify(gone));
|
||||||
|
|
||||||
|
// an unknown campaign reads as null rather than throwing
|
||||||
|
const none = await drip.readServed(ID + 12345);
|
||||||
|
t('an unknown campaign reads as null', none === null, JSON.stringify(none));
|
||||||
|
|
||||||
|
console.log('PASS ' + ok.length);
|
||||||
|
for (const b of bad) console.log('FAIL ' + b);
|
||||||
|
process.exit(bad.length ? 1 : 0);
|
||||||
@@ -386,7 +386,7 @@ async function boot() {
|
|||||||
chain.init({ onEvent: ev => { attachNames([ev]).then(a => pushFeed(a[0])).catch(() => pushFeed(ev)); emailOnEvent(ev).catch(() => {}); telegramOnEvent(ev).catch(() => {}); sponsorSyncOnEvent(ev).catch(e => console.error('sponsor sync', e.message)); } });
|
chain.init({ onEvent: ev => { attachNames([ev]).then(a => pushFeed(a[0])).catch(() => pushFeed(ev)); emailOnEvent(ev).catch(() => {}); telegramOnEvent(ev).catch(() => {}); sponsorSyncOnEvent(ev).catch(e => console.error('sponsor sync', e.message)); } });
|
||||||
auth.init({ dataDir: DATA_DIR, chain, isProd: IS_PROD, site: 'instantadpay.com' });
|
auth.init({ dataDir: DATA_DIR, chain, isProd: IS_PROD, site: 'instantadpay.com' });
|
||||||
accounts.init({ dataDir: DATA_DIR });
|
accounts.init({ dataDir: DATA_DIR });
|
||||||
ads.init({ dataDir: DATA_DIR, chain });
|
ads.init({ dataDir: DATA_DIR, chain, tiers: () => geo.tierLists(siteConfig()) });
|
||||||
mailer.init({ dataDir: DATA_DIR });
|
mailer.init({ dataDir: DATA_DIR });
|
||||||
messages.init({ dataDir: DATA_DIR });
|
messages.init({ dataDir: DATA_DIR });
|
||||||
reports.init({ dataDir: DATA_DIR });
|
reports.init({ dataDir: DATA_DIR });
|
||||||
|
|||||||
Reference in New Issue
Block a user