// 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) { // pid encodes text(1)/banner(2); adtype is a separate small int — 1 is the // value the overwhelming majority of live NAS rows use for both formats. const adtype = Number(process.env.NAS_ADTYPE || 1); if (type === 'banner') return { pid: 2, adtype }; if (type === 'text') return { pid: 1, adtype }; 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 window = c.dailyCap ? Math.min(c.dailyCap, budgetLeft) : budgetLeft; // capped campaigns start with one day's allowance const assigned = Math.max(1, Math.floor(window * 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,?)`, // clicks route through our redirect so they count and carry the network site as the referrer [token, c.title || null, c.type === 'text' ? (c.body || null) : null, 'https://linkspin-test.saasy.top/api/ads/click/' + c.id, assigned, assigned, now, kind.adtype, opts.name || 'LinkSpin member', opts.email || 'ads@linkspin-test.saasy.top', 'LinkSpin 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). // point an existing NAS ad at our click redirect (migration for rows pushed before 2026-09-10) async function setClickUrl(nasAdId, campaignId) { if (!enabled()) return { skipped: 'flag-off' }; await q('UPDATE sponsorads SET WebsiteURL=? WHERE ID=?', ['https://linkspin-test.saasy.top/api/ads/click/' + Number(campaignId), Number(nasAdId)]); return { ok: true }; } 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 = { setClickUrl, enabled, pushCampaign, readServed, deactivate, topUp, impressionsPerCredit, nasKind };