diff --git a/ads.js b/ads.js index 73f07e4..5875491 100644 Binary files a/ads.js and b/ads.js differ diff --git a/db.js b/db.js index 9791057..92e9ca7 100644 --- a/db.js +++ b/db.js @@ -108,6 +108,8 @@ async function bootstrap() { await alterSafe('ALTER TABLE campaigns ADD COLUMN cta_label VARCHAR(40) NULL'); await alterSafe('ALTER TABLE campaigns ADD COLUMN width INT NULL'); // banner size (IAB) → also NAS width 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_served INT NOT NULL DEFAULT 0'); // NAS impressions already reconciled into spend await q(`CREATE TABLE IF NOT EXISTS burns ( id VARCHAR(32) PRIMARY KEY, member_id INT NOT NULL, diff --git a/nas.js b/nas.js new file mode 100644 index 0000000..6e0685c --- /dev/null +++ b/nas.js @@ -0,0 +1,114 @@ +// NAS syndication — pushes IAP campaigns out to Network Ad Space (Marty's own +// EvolutionScript platform; no source, so its MySQL DB IS the API). Mirrors the +// CTB Rewards direct-write pattern: INSERT payments then sponsorads with +// approved:1 (bypasses NAS moderation), and reads `remaining` back to reconcile +// spend into IAP's unified credit pool. +// +// KNOWN NAS TRUTHS (measured across RM Circle + CTB, 1600+ ads): +// - sponsorads.remaining counts DOWN: served = assigned - remaining. +// - pid 1 = text, pid 2 = banner ONLY; banner size lives in width/height. +// - hits = clicks (not impressions). catid 5 = Cryptocurrencies. +// - NAS drifts its own counters upward post-insert → clamp served to assigned. +// +// FEATURE-FLAGGED: inert unless NAS_DB_HOST/USER/PASSWORD/NAME are all set. +// Nothing here runs (no connection, no writes) when the flag is off. +const crypto = require('crypto'); + +let pool = null; +function enabled() { + return !!(process.env.NAS_DB_HOST && process.env.NAS_DB_USER + && process.env.NAS_DB_PASSWORD && process.env.NAS_DB_NAME); +} +function nasPool() { + if (pool) return pool; + const mysql = require('mysql2/promise'); + pool = mysql.createPool({ + host: process.env.NAS_DB_HOST, + port: Number(process.env.NAS_DB_PORT || 3306), + user: process.env.NAS_DB_USER, + password: process.env.NAS_DB_PASSWORD, + database: process.env.NAS_DB_NAME, + charset: 'latin1', // EvolutionScript is latin1 + connectionLimit: 3, + connectTimeout: 10000 + }); + return pool; +} +async function q(sql, args) { const [r] = await nasPool().query(sql, args); return r; } + +const CATID_CRYPTO = Number(process.env.NAS_CATID || 5); +// how many NAS impressions one IAP credit buys, per format. IAP charges credits +// on its own surfaces at these same rates, so NAS delivery draws the same pool. +function impressionsPerCredit(type) { + return type === 'banner' ? 5 : type === 'text' ? 10 : type === 'video' ? 0 : 0; +} +function nasKind(type) { + if (type === 'banner') return { pid: 2, adtype: 'banner' }; + if (type === 'text') return { pid: 1, adtype: 'text' }; + return null; // only banner/text syndicate to NAS in v1 (login/solo/video are IAP-native) +} + +// push one IAP campaign into NAS. `c` is a pubC-shaped campaign. Returns +// { nasAdId, assigned } or { skipped } / throws on a real DB error. +async function pushCampaign(c, opts = {}) { + if (!enabled()) return { skipped: 'flag-off' }; + const kind = nasKind(c.type); + if (!kind) return { skipped: 'type' }; + const budgetLeft = c.budget - (c.spent || 0); + const assigned = Math.max(1, Math.floor(budgetLeft * impressionsPerCredit(c.type))); + const token = 'iap_' + crypto.randomBytes(8).toString('hex'); // manage token = Username + const now = new Date(); + const days = Number(opts.days || 30); + const edate = new Date(now.getTime() + days * 86400000); + const payref = 'iap_campaign:' + c.id; + // payments first (MyISAM, no txn) — hand-rollback the row if sponsorads fails + const pay = await q( + 'INSERT INTO payments (Username, Amount, Currency_code, status, Date, pay_address) VALUES (?,?,?,1,?,?)', + [token, 0, 'IAP_CREDIT', now, payref]); + try { + const ad = await q( + `INSERT INTO sponsorads + (Username, Subject, Body, WebsiteURL, assigned, remaining, hits, approved, Date, adtype, + Name, Email, PaymentDetails, EDate, sp, width, height, BannerURL, pid, ref_by, catid) + VALUES (?,?,?,?,?,?,0,1,?,?,?,?,?,?,'',?,?,?,?,0,?)`, + [token, c.title || null, c.type === 'text' ? (c.body || null) : null, c.targetUrl, + assigned, assigned, now, kind.adtype, + opts.name || 'InstantAdPay member', opts.email || 'ads@instantadpay.com', + 'InstantAdPay campaign #' + c.id, edate, + c.width || '', c.height || '', c.type === 'banner' ? c.imageUrl : null, kind.pid, CATID_CRYPTO]); + return { nasAdId: ad.insertId, manageToken: token, assigned }; + } catch (e) { + try { await q('DELETE FROM payments WHERE ID=?', [pay.insertId]); } catch (e2) {} + throw e; + } +} + +// read served count for a syndicated ad (assigned - remaining, clamped ≥0 and +// ≤ assigned because NAS drifts counters upward post-insert). +async function readServed(nasAdId) { + if (!enabled()) return null; + const rows = await q('SELECT assigned, remaining, hits FROM sponsorads WHERE ID=?', [Number(nasAdId)]); + if (!rows.length) return null; + const assigned = Number(rows[0].assigned) || 0; + const remaining = Number(rows[0].remaining) || 0; + const served = Math.max(0, Math.min(assigned, assigned - remaining)); + return { assigned, remaining, served, clicks: Number(rows[0].hits) || 0 }; +} + +// stop a syndicated ad (budget spent, paused, or expired) — remaining:0 halts serving. +async function deactivate(nasAdId) { + if (!enabled()) return { skipped: 'flag-off' }; + await q('UPDATE sponsorads SET remaining=0, EDate=NOW() WHERE ID=?', [Number(nasAdId)]); + return { ok: true }; +} + +// top up a syndicated ad with more impressions (buy-more-views / reactivate). +async function topUp(nasAdId, addImpressions, days) { + if (!enabled()) return { skipped: 'flag-off' }; + await q(`UPDATE sponsorads SET assigned=assigned+?, remaining=remaining+?, approved=1, + EDate=DATE_ADD(NOW(), INTERVAL ? DAY) WHERE ID=?`, + [Number(addImpressions), Number(addImpressions), Number(days || 30), Number(nasAdId)]); + return { ok: true }; +} + +module.exports = { enabled, pushCampaign, readServed, deactivate, topUp, impressionsPerCredit, nasKind }; diff --git a/public/assets/my.js b/public/assets/my.js index fb6e8bd..dd3c7a6 100644 --- a/public/assets/my.js +++ b/public/assets/my.js @@ -342,7 +342,9 @@ + '