Files

1581 lines
88 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Ad engine v1 (spec §8b types 1-3). Dual-mode like accounts.js:
// MySQL (db.enabled) with guarded UPDATEs for the concurrent serving path,
// JSON volume file fallback for local dev.
// Spend accrues per campaign; burns queue for the engine signer to consume()
// on-chain (/api/admin/burns). All exported functions are async.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const db = require('./db');
const nas = require('./nas'); // NAS syndication (inert unless NAS_DB_* env is set)
let DATA_DIR = null;
let chain = null;
const RATES_FILE = () => path.join(DATA_DIR, 'adrates.json');
// REHEARSAL PLACEHOLDER RATES — admin-adjustable via /api/admin/rates.
// Admin house ads: owned by this pseudo-account, funded by nothing. They serve
// through every normal path but the spend is never charged to anyone; the
// budget only acts as a delivery cap (status flips to 'out' when it's reached).
const HOUSE_OWNER = 'house@linkspin-test.saasy.top';
function rates() {
let saved = {};
try { saved = JSON.parse(fs.readFileSync(RATES_FILE(), 'utf8')); } catch (e) {}
return Object.assign({
bannerBatch: 10, bannerCreditsPerBatch: 2,
textBatch: 10, textCreditsPerBatch: 1,
loginCreditsPerDay: 100,
loginDwellSeconds: 10, // full-screen interstitial after sign-in
burnBatchMin: 50,
welcomeCredits: 25,
dailyViewTarget: 5, // ads to view for the daily claim (spec §8b attention-gated claim)
dailyClaimCredits: 5,
viewDwellSeconds: 10, // Marty 2026-09-09: at least 10s so people actually look
// onsite solo ads: full-message inbox delivery, charged per guaranteed recipient
soloCostPerRecipient: 5,
soloMinRecipients: 10,
soloReadCredits: 2, // earned by the reader per rewarded read
soloReadCapPerDay: 5,
soloReadDwellSeconds: 10,
// watch-to-earn video ads: advertiser picks a required watch length, which
// sets the per-view price; the viewer earns per completed watch
videoTiers: [
{ secs: 10, cost: 3, reward: 1 },
{ secs: 30, cost: 7, reward: 2 },
{ secs: 60, cost: 12, reward: 4 }
],
videoWatchCapPerDay: 8,
// featured rotation: your link runs in the featured strip for N days; the
// dilution (how many links share the rotation) is disclosed before you buy
featuredPerDay: 40, // credits per day
featuredDurations: [1, 2, 7],
featuredSlotsPerDay: 10, // cap on links sharing the rotation on any one day
featuredWindowDays: 7, // how far ahead a day can be booked
// verified visits: buy a pack of guaranteed unique human visits; each is a
// dwelled + captcha-verified visit by a distinct member (never a repeat)
visitCostPerVisit: 3, // credits the advertiser pays per delivered visit
visitMinPack: 20, // smallest pack
visitReward: 1, // credits the viewer earns per verified visit
visitDwellSeconds: 8,
visitCapPerDay: 20, // per-viewer daily cap on rewarded visits
// credit bonuses paid once when a member reaches each milestone (the same
// ladder as the Overview stepper). Keys: payouts / firstBuyer / level2 / level3
milestoneBonus: { payouts: 10, firstBuyer: 25, level2: 50, level3: 100 }
}, saved);
}
function setRates(patch) {
fs.writeFileSync(RATES_FILE(), JSON.stringify(Object.assign(rates(), patch), null, 2));
return rates();
}
const TYPES = ['banner', 'text', 'login', 'solo', 'video', 'featured', 'visits'];
// standard IAB sizes — ids map straight to NAS sponsorads width/height (pid 2)
const BANNER_SIZES = [
{ id: '728x90', w: 728, h: 90, label: 'Leaderboard 728×90' },
{ id: '300x250', w: 300, h: 250, label: 'Medium rectangle 300×250' },
{ id: '468x60', w: 468, h: 60, label: 'Banner 468×60' },
{ id: '160x600', w: 160, h: 600, label: 'Wide skyscraper 160×600' },
{ id: '120x600', w: 120, h: 600, label: 'Skyscraper 120×600' },
{ id: '320x50', w: 320, h: 50, label: 'Mobile leaderboard 320×50' },
{ id: '125x125', w: 125, h: 125, label: 'Square button 125×125' }
];
// solo bodies are member-authored rich text rendered in OTHER members'
// browsers: whitelist-sanitize server-side — known tags only, no attributes
// (except http(s) hrefs, rebuilt clean), every stray angle bracket escaped
const SOLO_TAGS = new Set(['b', 'strong', 'i', 'em', 'u', 's', 'p', 'br',
'ul', 'ol', 'li', 'h2', 'h3', 'h4', 'blockquote', 'div', 'span', 'figure']);
function sanitizeSolo(html) {
const src = String(html || '').replace(//g, '')
.replace(/<(script|style)\b[\s\S]*?<\/\1\s*>/gi, '') // drop script/style content whole
.replace(/<(script|style)\b[^>]*>/gi, '') // and any unclosed opener
.slice(0, 12000).replace(/<!--[\s\S]*?-->/g, '');
const keep = [];
const tokenized = src.replace(/<\s*(\/?)\s*([a-zA-Z0-9]+)((?:[^>"']|"[^"]*"|'[^']*')*)>/g, (m, close, tag, attrs) => {
tag = tag.toLowerCase();
let out = '';
const safeSrc = s => /^(\/uploads\/[a-z0-9]{24}\.(png|jpg|webp|gif|mp4|webm)|https:\/\/[^\s"'<>]+)$/i.test(s);
const srcOf = a => { const m2 = /src\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(a || ''); return (m2 && (m2[1] || m2[2])) || ''; };
if (tag === 'a') {
if (close) out = '</a>';
else {
const hm = /href\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(attrs || '');
const href = (hm && (hm[1] || hm[2])) || '';
out = URL_RE.test(href) ? '<a href="' + href.replace(/"/g, '%22') + '" target="_blank" rel="noopener nofollow">' : '';
}
} else if (tag === 'img') { // inline images: only our uploads or https, no other attrs
const s = srcOf(attrs);
out = (!close && safeSrc(s)) ? '<img src="' + s.replace(/"/g, '%22') + '" alt="" loading="lazy">' : '';
} else if (tag === 'video') {
out = close ? '</video>' : (safeSrc(srcOf(attrs)) ? '<video src="' + srcOf(attrs).replace(/"/g, '%22') + '" controls playsinline>' : '<video controls playsinline>');
} else if (tag === 'source') {
const s = srcOf(attrs);
out = (!close && safeSrc(s)) ? '<source src="' + s.replace(/"/g, '%22') + '">' : '';
} else if (SOLO_TAGS.has(tag)) out = '<' + (close ? '/' : '') + tag + '>';
keep.push(out);
return '' + (keep.length - 1) + '';
});
return tokenized.replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/(\d+)/g, (m, i) => keep[Number(i)]).trim();
}
const URL_RE = /^https?:\/\/[^\s]+$/i;
const bid = () => crypto.randomBytes(8).toString('hex');
function batchFor(type, r) {
return type === 'banner' ? { n: r.bannerBatch, cr: r.bannerCreditsPerBatch }
: type === 'text' ? { n: r.textBatch, cr: r.textCreditsPerBatch } : null;
}
function validate(input) {
const type = String(input.type || '');
if (!TYPES.includes(type)) return { error: 'Unknown ad type.' };
const name = String(input.name || '').trim().slice(0, 60);
if (!name) return { error: 'Give the campaign a name.' };
const targetUrl = String(input.targetUrl || '').trim();
if (!URL_RE.test(targetUrl)) return { error: 'Target URL must start with http(s)://' };
// visits are a flat pack: the budget is the pack price, derived from the count (the form has no budget box)
let budget = Math.floor(Number(input.budget) || 0);
if (type === 'visits' && !budget) budget = Math.floor(Number(input.count) || 0) * (rates().visitCostPerVisit || 3);
if (budget < 10) return { error: 'Minimum budget is 10 credits.' };
const out = { type, name, targetUrl, budget, imageUrl: null, title: null, body: null };
if (type !== 'featured' && (input.startsAt || input.endsAt)) { // optional schedule (featured books whole days instead)
const now = Date.now();
const s = input.startsAt ? Number(input.startsAt) : 0, e = input.endsAt ? Number(input.endsAt) : 0;
if (input.startsAt && !(s > 0)) return { error: 'The start time does not look right.' };
if (input.endsAt && !(e > 0)) return { error: 'The end time does not look right.' };
if (s > now + 366 * 86400000) return { error: 'Pick a start within the next year.' };
if (e && e <= Math.max(now, s) + 30 * 60000) return { error: 'The end needs to be at least 30 minutes after the start.' };
if (s > now) out.starts = s;
if (e) out.expires = e;
}
if (input.geo !== undefined && input.geo !== null && String(input.geo).trim() !== '') { // country tiers: '1', '1,2', '2,3' ...; all three = everyone
const tiers = [...new Set(String(input.geo).split(/[\s,]+/).filter(t => ['1', '2', '3'].includes(t)))].sort();
if (!tiers.length) return { error: 'Pick at least one country tier, or leave it on everyone.' };
if (tiers.length < 3) out.geo = tiers.join(',');
}
if ((type === 'banner' || type === 'text') && input.dailyCap) { // optional pacing: credits per day
const cap = Math.floor(Number(input.dailyCap) || 0);
if (cap < 10) return { error: 'A daily cap needs to be at least 10 credits.' };
if (cap > budget) return { error: 'The daily cap cannot exceed the budget.' };
out.dailyCap = cap;
}
if (type === 'banner') {
out.imageUrl = String(input.imageUrl || '').trim();
if (!URL_RE.test(out.imageUrl)) return { error: 'Banner ads need an image URL starting with http(s)://' };
const size = BANNER_SIZES.find(s => s.id === String(input.size || ''));
if (!size) return { error: 'Pick a banner size.' };
out.width = size.w; out.height = size.h;
}
// login ads are full-screen interstitials of the target URL itself: no creative needed
if (type === 'text') {
out.title = String(input.title || '').trim().slice(0, 60);
out.body = String(input.body || '').trim().slice(0, 140);
if (!out.title) return { error: 'Text ads need a headline.' };
}
if (type === 'solo') {
const r = rates();
out.title = String(input.title || '').trim().slice(0, 80);
out.body = sanitizeSolo(input.body);
const plain = out.body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
if (!out.title) return { error: 'Solo ads need a subject line.' };
if (plain.length < 40) return { error: 'Write the message — at least 40 characters.' };
if (plain.length > 2000) return { error: 'Keep the message under 2000 characters of text.' };
const mu = String(input.mediaUrl || '').trim();
if (mu) {
if (!/^\/uploads\/[a-z0-9]{24}\.(png|jpg|webp|gif|mp4|webm)$/.test(mu))
return { error: 'Attach the image or video through the uploader.' };
out.imageUrl = mu; // solo media rides the image_url column
}
out.ctaLabel = String(input.ctaLabel || '').trim().slice(0, 30) || null;
const min = (r.soloCostPerRecipient || 5) * (r.soloMinRecipients || 10);
if (budget < min) return { error: 'Solo ads start at ' + min + ' credits (' + (r.soloMinRecipients || 10) + ' guaranteed deliveries).' };
}
if (type === 'featured') {
const r = rates();
out.title = String(input.title || '').trim().slice(0, 70);
if (!out.title) return { error: 'Featured links need a headline.' };
const days = Number(input.days);
if (!(r.featuredDurations || []).includes(days)) return { error: 'Pick a duration (' + (r.featuredDurations || []).join(', ') + ' days).' };
const start = dayStart(input.startDay); // start-of-day UTC of the chosen booking day
if (start === null) return { error: 'Pick a valid start day.' };
const todayStart = dayStart(0);
if (start < todayStart) return { error: 'That day has already started — pick today or later.' };
if (start > todayStart + (r.featuredWindowDays || 7) * 86400000) return { error: 'You can book up to ' + (r.featuredWindowDays || 7) + ' days ahead.' };
out.body = String(days);
out.starts = start;
out.expires = start + days * 86400000;
out.featuredRun = { start, days }; // checked for slot availability in createCampaign
const cost = days * (r.featuredPerDay || 40);
if (budget < cost) return { error: 'A ' + days + '-day featured run is ' + cost + ' credits.' };
out.budget = cost; // flat buy
}
if (type === 'visits') {
const r = rates();
out.title = String(input.title || '').trim().slice(0, 80) || null;
const count = Math.floor(Number(input.count) || 0);
if (count < (r.visitMinPack || 20)) return { error: 'Verified-visit packs start at ' + (r.visitMinPack || 20) + ' visits.' };
out.body = String(count); // target visit count rides the body column
const cost = count * (r.visitCostPerVisit || 3);
if (budget < cost) return { error: count + ' verified visits is ' + cost + ' credits.' };
out.budget = cost; // flat up-front buy
}
if (type === 'video') {
const r = rates();
const src = String(input.videoUrl || '').trim();
if (!/^(\/uploads\/[a-z0-9]{24}\.(mp4|webm)|https:\/\/[^\s]+\.(mp4|webm)(\?[^\s]*)?)$/i.test(src))
return { error: 'Upload an MP4/WebM, or paste a direct https link ending in .mp4 or .webm.' };
out.imageUrl = src; // video source rides the image_url column
// client-detected pixel dimensions decide the surface: portrait -> Shorts reel, landscape -> Watch videos
out.width = Number(input.videoW) > 0 ? Math.round(Number(input.videoW)) : null;
out.height = Number(input.videoH) > 0 ? Math.round(Number(input.videoH)) : null;
out.title = String(input.title || '').trim().slice(0, 80) || null;
out.ctaLabel = String(input.ctaLabel || '').trim().slice(0, 30) || null;
const tier = (r.videoTiers || []).find(t => t.secs === Number(input.watchSecs));
if (!tier) return { error: 'Pick a watch length (' + (r.videoTiers || []).map(t => t.secs + 's').join(', ') + ').' };
out.body = String(tier.secs); // required watch seconds ride the body column
const min = tier.cost * 10; // at least 10 views' worth
if (budget < min) return { error: 'A ' + tier.secs + 's video ad starts at ' + min + ' credits (' + tier.cost + ' per view, 10 views).' };
}
return { ok: true, c: out };
}
const pubC = c => ({ id: c.id, type: c.type, name: c.name, owner: c.owner || null, house: !!c.house, targetUrl: c.targetUrl, imageUrl: c.imageUrl || null,
title: c.title || null, body: c.body || null, budget: c.budget, spent: (c.spent || 0) + (c.accrued || 0),
width: c.width || null, height: c.height || null, ctaLabel: c.ctaLabel || null,
expires: c.expires || null, starts: c.starts || null,
imps: c.imps || 0, impsNas: c.nasServed || 0, clicks: c.clicks || 0, status: c.status, created: c.created, scheduled: !!(c.starts && c.starts > Date.now() && c.status === 'active'),
dailyCap: c.dailyCap || null, daySpent: (c.dayKey === dayKeyNow()) ? (c.daySpent || 0) : 0, geo: c.geo || null });
const dayKeyNow = () => new Date().toISOString().slice(0, 10);
// scheduled window: a campaign serves only between its start (if set) and its end (if set)
const inWindow = (c, now) => (!c.starts || c.starts <= (now || Date.now())) && (!c.expires || c.expires > (now || Date.now()));
const SCHED_SQL = ' AND (starts IS NULL OR starts<=?) AND (expires IS NULL OR expires>?)';
// country tiers: a restricted campaign shows only to viewers whose tier is listed; an unknown
// country (no tier) never matches a restricted campaign. FIND_IN_SET('', geo) is 0, so the SQL agrees.
const geoOk = (c, tier) => !c.geo || (!!tier && String(c.geo).split(',').includes(String(tier)));
const GEO_SQL = " AND (geo IS NULL OR geo='' OR FIND_IN_SET(?, geo))";
async function bumpGeo(id, cc) {
if (!cc) return;
if (db.enabled()) { try { await db.q('INSERT INTO camp_geo (campaign_id,cc,n) VALUES (?,?,1) ON DUPLICATE KEY UPDATE n=n+1', [Number(id), cc]); } catch (e) {} return; }
J.db.geo = J.db.geo || {}; const k = id + ':' + cc; J.db.geo[k] = (J.db.geo[k] || 0) + 1;
}
async function geoFor(ids) {
const out = {}; const list = (ids || []).map(Number).filter(Boolean); if (!list.length) return out;
if (db.enabled()) {
const rows = await db.q('SELECT campaign_id, cc, n FROM camp_geo WHERE campaign_id IN (' + list.map(() => '?').join(',') + ')', list);
for (const r of rows) (out[r.campaign_id] = out[r.campaign_id] || []).push({ cc: r.cc, n: Number(r.n) });
} else {
for (const [k, n] of Object.entries(J.db.geo || {})) { const [id, cc] = k.split(':'); if (list.includes(Number(id))) (out[id] = out[id] || []).push({ cc, n }); }
}
for (const id of Object.keys(out)) out[id] = out[id].sort((a, b) => b.n - a.n).slice(0, 5);
return out;
}
// on-site views per UTC hour (last 7 days feed the by-hour chart)
const hourKey = () => { const d = new Date(); return { day: d.toISOString().slice(0, 10), hour: d.getUTCHours() }; };
async function bumpHour(id) {
const { day, hour } = hourKey();
if (db.enabled()) { try { await db.q('INSERT INTO camp_hours (campaign_id,day,hour,n) VALUES (?,?,?,1) ON DUPLICATE KEY UPDATE n=n+1', [Number(id), day, hour]); } catch (e) {} return; }
J.db.hours = J.db.hours || {};
const k = id + ':' + day + ':' + hour; J.db.hours[k] = (J.db.hours[k] || 0) + 1;
}
async function hoursFor(ids) {
const out = {}; const list = (ids || []).map(Number).filter(Boolean); if (!list.length) return out;
const since = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10);
for (const id of list) out[id] = new Array(24).fill(0);
if (db.enabled()) {
const rows = await db.q('SELECT campaign_id, hour, SUM(n) n FROM camp_hours WHERE campaign_id IN (' + list.map(() => '?').join(',') + ') AND day>=? GROUP BY campaign_id, hour', [...list, since]);
for (const r of rows) if (out[r.campaign_id]) out[r.campaign_id][r.hour] = Number(r.n);
return out;
}
for (const [k, n] of Object.entries(J.db.hours || {})) { const [id, day, hour] = k.split(':'); if (out[id] && day >= since) out[id][Number(hour)] += n; }
return out;
}
const capped = c => !!(c.dailyCap && c.dayKey === dayKeyNow() && (c.daySpent || 0) >= c.dailyCap);
const served = c => ({ id: c.id, type: c.type, targetUrl: '/api/ads/click/' + c.id,
imageUrl: c.imageUrl || null, title: c.title || null, body: c.body || null,
width: c.width || null, height: c.height || null });
// ---- JSON fallback ----
const J = {
db: { v: 1, nextId: 1, campaigns: [], burnsPending: [] },
FILE: () => path.join(DATA_DIR, 'campaigns.json'),
load() {
try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) {}
if (!this.db || this.db.v !== 1) this.db = { v: 1, nextId: 1, campaigns: [], burnsPending: [] };
},
save() {
try {
const tmp = this.FILE() + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(this.db));
fs.renameSync(tmp, this.FILE());
} catch (e) { console.error('ads save failed', e.message); }
},
async unburned(memberId) {
let s = 0;
for (const b of this.db.burnsPending) if (b.memberId === memberId && !b.burnedTx) s += b.amount;
for (const c of this.db.campaigns) if (c.memberId === memberId) s += c.accrued || 0;
return s;
},
async committed(memberId) { // unspent budget of live campaigns: reserved, not spendable twice
let s = 0;
for (const c of this.db.campaigns) if (c.memberId === memberId && ['active', 'paused'].includes(c.status)) s += Math.max(0, (c.budget || 0) - (c.spent || 0) - (c.accrued || 0));
return s;
},
async create(owner, memberId, c) {
const row = Object.assign({ id: this.db.nextId++, owner, memberId, spent: 0, accrued: 0,
imps: 0, clicks: 0, batchImps: 0, status: 'active', created: Date.now() }, c);
this.db.campaigns.push(row);
this.save();
return pubC(row);
},
async list(owner) { return this.db.campaigns.filter(c => c.owner === owner).map(pubC); },
async setStatus(owner, id, status) {
const c = this.db.campaigns.find(x => x.id === Number(id) && x.owner === owner);
if (!c) return { error: 'No such campaign.' };
c.status = status;
this.save();
return { ok: true, campaign: pubC(c) };
},
async serve(type, opts) {
const r = rates();
const ex = opts && opts.excludeEmail;
const w = opts && Number(opts.width), h = opts && Number(opts.height);
const tier = opts && opts.tier;
const pool = this.db.campaigns.filter(c => c.type === type && c.status === 'active' && (!ex || c.owner !== ex) && !capped(c) && inWindow(c) && geoOk(c, tier)
&& (!w || (Number(c.width) === w && Number(c.height) === h)));
if (!pool.length) return null;
const c = pool[Math.floor(Math.random() * pool.length)];
c.imps += 1; bumpHour(c.id); bumpGeo(c.id, opts && opts.cc);
if (type === 'login') { c.lastShownDay = new Date().toISOString().slice(0, 10); this.save(); } // a login day is only charged once it was shown
const b = batchFor(type, r);
if (b) {
c.batchImps += 1;
if (c.batchImps >= b.n) {
c.batchImps = 0;
// never charge past the budget: on-site batches and network reconciles can land in
// the same window, so the last batch is capped at what is left (any extra views are free)
const cr = Math.min(b.cr, Math.max(0, c.budget - c.spent - (c.accrued || 0)));
if (cr) {
bumpDayJ(c, cr);
// earned pool pays first (spec §8b); only the remainder burns on-chain
if (await spendEarned(c.owner, cr)) {
c.spent += cr;
} else if (!c.memberId) {
c.status = 'out'; // earned-only advertiser ran dry: no chain pool to fall to
} else {
c.accrued = (c.accrued || 0) + cr;
if (c.accrued >= r.burnBatchMin) {
this.db.burnsPending.push({ id: bid(), memberId: c.memberId, amount: c.accrued, ref: 'campaign-' + c.id, ts: Date.now() });
c.spent += c.accrued; c.accrued = 0;
}
}
}
if (c.spent + c.accrued >= c.budget) c.status = 'out';
}
}
this.save();
return served(c);
},
async click(id) {
const c = this.db.campaigns.find(x => x.id === Number(id));
if (!c) return null;
c.clicks += 1;
this.save();
return c.targetUrl;
},
async targetOf(id) { // resolve without counting a click (earn-view frames the real url)
const c = this.db.campaigns.find(x => x.id === Number(id));
return c ? c.targetUrl : null;
},
async dailySweep() {
const r = rates();
const today = new Date().toISOString().slice(0, 10);
let n = 0;
for (const c of this.db.campaigns) {
if (c.type !== 'login' || c.status !== 'active' || c.lastDayCharged === today || !inWindow(c)) continue;
if (c.lastShownDay !== today) continue; // 2026-09-15: eleven login ads were sharing a handful of sign-ins and some paid for days with zero shows
c.lastDayCharged = today;
const fee = Math.min(r.loginCreditsPerDay, Math.max(0, c.budget - c.spent - (c.accrued || 0))); // never charge past the budget (Shift AI was charged 200 of 125)
if (!fee) { c.status = 'out'; continue; }
if (c.owner === HOUSE_OWNER) c.spent += fee; // house: cap only, no burn
else if (await spendEarned(c.owner, fee, { purchasedOnly: true })) c.spent += fee; // credited purchased money first
else c.accrued = (c.accrued || 0) + fee;
if (c.accrued >= r.burnBatchMin) {
this.db.burnsPending.push({ id: bid(), memberId: c.memberId, amount: c.accrued, ref: 'campaign-' + c.id, ts: Date.now() });
c.spent += c.accrued; c.accrued = 0;
}
if (c.spent + c.accrued >= c.budget) c.status = 'out';
n += 1;
}
if (n) this.save();
return n;
},
async pendingBurns() { return this.db.burnsPending.filter(b => !b.burnedTx); },
async markBurned(id, tx) {
const b = this.db.burnsPending.find(x => x.id === id);
if (!b) return { error: 'No such burn.' };
b.burnedTx = tx; b.burnedAt = Date.now();
this.save();
return { ok: true };
}
};
// ---- MySQL mode: guarded UPDATEs keep the serving path concurrency-safe ----
const rowC = r => ({ id: r.id, owner: r.owner_email, memberId: r.member_id, house: !!r.house, type: r.type, name: r.name,
targetUrl: r.target_url, imageUrl: r.image_url, title: r.title, body: r.body, budget: r.budget,
spent: r.spent, accrued: r.accrued, imps: r.imps, clicks: r.clicks, batchImps: r.batch_imps,
width: r.width || null, height: r.height || null, ctaLabel: r.cta_label || null,
nasAdId: r.nas_ad_id || null, nasServed: r.nas_served || 0, dailyCap: r.daily_cap || null, daySpent: r.day_spent || 0, dayKey: r.day_key || null, geo: r.geo || null,
expires: r.expires ? Number(r.expires) : null, starts: r.starts ? Number(r.starts) : null,
status: r.status, created: Number(r.created) });
const D = {
async unburned(memberId) {
const a = await db.q('SELECT COALESCE(SUM(amount),0) s FROM burns WHERE member_id=? AND burned_tx IS NULL', [memberId]);
const b = await db.q('SELECT COALESCE(SUM(accrued),0) s FROM campaigns WHERE member_id=?', [memberId]);
return Number(a[0].s) + Number(b[0].s);
},
async committed(memberId) { // unspent budget of live campaigns: reserved, not spendable twice
const r = await db.q("SELECT COALESCE(SUM(GREATEST(budget-spent-accrued,0)),0) s FROM campaigns WHERE member_id=? AND status IN ('active','paused')", [memberId]);
return Number(r[0].s);
},
async create(owner, memberId, c) {
const r = await db.q(`INSERT INTO campaigns (owner_email,member_id,type,name,target_url,image_url,title,body,budget,created,cta_label,width,height,expires,starts,house,daily_cap,geo)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[owner, memberId, c.type, c.name, c.targetUrl, c.imageUrl, c.title, c.body, c.budget, Date.now(), c.ctaLabel || null, c.width || null, c.height || null, c.expires || null, c.starts || null, c.house ? 1 : 0, c.dailyCap || null, c.geo || null]);
const rows = await db.q('SELECT * FROM campaigns WHERE id=?', [r.insertId]);
return pubC(rowC(rows[0]));
},
async list(owner) {
const rows = await db.q('SELECT * FROM campaigns WHERE owner_email=? ORDER BY id DESC', [owner]);
return rows.map(r => pubC(rowC(r)));
},
async setStatus(owner, id, status) {
const r = await db.q('UPDATE campaigns SET status=? WHERE id=? AND owner_email=?', [status, Number(id), owner]);
if (!r.affectedRows) return { error: 'No such campaign.' };
const rows = await db.q('SELECT * FROM campaigns WHERE id=?', [Number(id)]);
return { ok: true, campaign: pubC(rowC(rows[0])) };
},
async serve(type, opts) {
const r = rates();
const ex = (opts && opts.excludeEmail) || '';
const w = opts && Number(opts.width), h = opts && Number(opts.height);
const sz = w ? ' AND width=? AND height=?' : '';
const params = w ? [type, ex, w, h] : [type, ex];
const nowMs = Date.now(); params.push(nowMs, nowMs, String((opts && opts.tier) || ''));
const rows = await db.q('SELECT * FROM campaigns WHERE type=? AND status=\'active\' AND owner_email<>?' + sz + " AND (daily_cap IS NULL OR day_key IS NULL OR day_key<>'" + dayKeyNow() + "' OR day_spent<daily_cap)" + SCHED_SQL + GEO_SQL + ' ORDER BY RAND() LIMIT 1', params);
if (!rows.length) return null;
const c = rowC(rows[0]);
await db.q('UPDATE campaigns SET imps=imps+1, batch_imps=batch_imps+1 WHERE id=?', [c.id]); bumpHour(c.id); bumpGeo(c.id, opts && opts.cc);
if (type === 'login') await db.q('UPDATE campaigns SET last_shown_day=? WHERE id=?', [new Date().toISOString().slice(0, 10), c.id]); // a login day is only charged once it was shown
const b = batchFor(type, r);
if (b) {
// atomic batch rollover: only one worker wins the WHERE guard
const won = await db.q('UPDATE campaigns SET batch_imps=batch_imps-? WHERE id=? AND batch_imps>=?',
[b.n, c.id, b.n]);
if (won.affectedRows) {
// never charge past the budget (on-site batches and network reconciles can overlap)
const cr = Math.min(b.cr, Math.max(0, c.budget - (c.spent || 0) - (c.accrued || 0)));
if (cr) {
await bumpDayD(c.id, cr);
// earned pool pays first (spec §8b); only the remainder burns on-chain
if (await spendEarned(c.owner, cr)) {
await db.q('UPDATE campaigns SET spent=spent+? WHERE id=?', [cr, c.id]);
} else if (!c.memberId) {
await db.q('UPDATE campaigns SET status=\'out\' WHERE id=?', [c.id]); // earned-only ran dry
} else {
await db.q('UPDATE campaigns SET accrued=accrued+? WHERE id=?', [cr, c.id]);
await this.rollBurn(c.id, r);
}
}
await db.q('UPDATE campaigns SET status=\'out\' WHERE id=? AND status=\'active\' AND spent+accrued>=budget', [c.id]);
}
}
return served(c);
},
async rollBurn(id, r) {
const rows = await db.q('SELECT * FROM campaigns WHERE id=?', [id]);
if (!rows.length) return;
const c = rowC(rows[0]);
if (c.owner === HOUSE_OWNER) { // house ads never burn: fold accrual into spent, no burn row
if (c.accrued > 0) await db.q('UPDATE campaigns SET spent=spent+accrued, accrued=0 WHERE id=?', [id]);
await db.q("UPDATE campaigns SET status='out' WHERE id=? AND status='active' AND spent+accrued>=budget", [id]);
return;
}
if (c.accrued >= r.burnBatchMin) {
const upd = await db.q('UPDATE campaigns SET spent=spent+?, accrued=accrued-? WHERE id=? AND accrued>=?',
[c.accrued, c.accrued, id, c.accrued]);
if (upd.affectedRows) {
await db.q('INSERT INTO burns (id,member_id,amount,ref,ts) VALUES (?,?,?,?,?)',
[bid(), c.memberId, c.accrued, 'campaign-' + id, Date.now()]);
}
}
await db.q('UPDATE campaigns SET status=\'out\' WHERE id=? AND status=\'active\' AND spent+accrued>=budget', [id]);
},
async click(id) {
const rows = await db.q('SELECT target_url FROM campaigns WHERE id=?', [Number(id)]);
if (!rows.length) return null;
await db.q('UPDATE campaigns SET clicks=clicks+1 WHERE id=?', [Number(id)]);
return rows[0].target_url;
},
async targetOf(id) { // resolve without counting a click (earn-view frames the real url)
const rows = await db.q('SELECT target_url FROM campaigns WHERE id=?', [Number(id)]);
return rows.length ? rows[0].target_url : null;
},
async dailySweep() {
const r = rates();
const today = new Date().toISOString().slice(0, 10);
const due = await db.q(`SELECT id, owner_email, budget, spent, accrued FROM campaigns WHERE type='login' AND status='active' AND (last_day_charged IS NULL OR last_day_charged<>?) AND last_shown_day=?` + SCHED_SQL, [today, today, Date.now(), Date.now()]); // only days the ad was actually shown (2026-09-15)
let n = 0;
for (const row of due) {
const fee = Math.min(r.loginCreditsPerDay, Math.max(0, Number(row.budget) - Number(row.spent || 0) - Number(row.accrued || 0))); // never charge past the budget
if (!fee) { await db.q("UPDATE campaigns SET status='out' WHERE id=?", [row.id]); continue; }
// credited purchased money (refunds, comps) pays first; otherwise accrue toward an on-chain burn
const fromPool = row.owner_email !== HOUSE_OWNER && await spendEarned(row.owner_email, fee, { purchasedOnly: true });
const upd = fromPool
? await db.q('UPDATE campaigns SET last_day_charged=?, spent=spent+? WHERE id=? AND (last_day_charged IS NULL OR last_day_charged<>?)', [today, fee, row.id, today])
: await db.q('UPDATE campaigns SET last_day_charged=?, accrued=accrued+? WHERE id=? AND (last_day_charged IS NULL OR last_day_charged<>?)', [today, fee, row.id, today]);
if (!upd.affectedRows) continue;
n += 1;
if (fromPool) await db.q("UPDATE campaigns SET status='out' WHERE id=? AND spent+accrued>=budget", [row.id]);
else await this.rollBurn(row.id, r);
}
return n;
},
async pendingBurns() {
const rows = await db.q('SELECT * FROM burns WHERE burned_tx IS NULL ORDER BY ts');
return rows.map(b => ({ id: b.id, memberId: b.member_id, amount: b.amount, ref: b.ref, ts: Number(b.ts) }));
},
async markBurned(id, tx) {
const r = await db.q('UPDATE burns SET burned_tx=?, burned_at=? WHERE id=? AND burned_tx IS NULL', [String(tx), Date.now(), String(id)]);
return r.affectedRows ? { ok: true } : { error: 'No such burn.' };
}
};
// ---- earned/welcome credits (spec §8b second pool: engine-side, never
// on-chain; welcome grant is idempotent and lazy so existing accounts get
// theirs on next dashboard load) ----
const EJ = {
db: null,
FILE: () => path.join(DATA_DIR, 'earned.json'),
load() { try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) { this.db = {}; } },
save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} }
};
async function earnedBalance(email) {
const e = String(email || '').toLowerCase();
if (!e) return 0;
if (db.enabled()) {
const r = await db.q('SELECT balance FROM earned_credits WHERE email=?', [e]);
return r.length ? r[0].balance : 0;
}
if (!EJ.db) EJ.load();
return (EJ.db[e] && EJ.db[e].balance) || 0;
}
async function welcomeGranted(email) { // read-only check, never grants
const e = String(email || '').toLowerCase();
if (!e) return true;
if (db.enabled()) {
const r = await db.q('SELECT granted_welcome FROM earned_credits WHERE email=?', [e]);
return r.length ? !!r[0].granted_welcome : false;
}
if (!EJ.db) EJ.load();
return !!(EJ.db[e] && EJ.db[e].welcomed);
}
// idempotent milestone bonuses: grant the credit bonus for each newly-reached
// milestone once. `reached` is the list of currently-true milestone keys.
// Returns [{ key, credited }] for any that were newly granted this call.
// which milestone badges this member has been granted (payouts, firstBuyer, level2, level3)
async function milestonesOf(email) {
const e = String(email || '').toLowerCase(); if (!e) return [];
if (db.enabled()) { const r = await db.q('SELECT milestones FROM earned_credits WHERE email=?', [e]); return (r.length && r[0].milestones ? r[0].milestones : '').split(',').filter(Boolean); }
if (!EJ.db) EJ.load(); return (EJ.db[e] && EJ.db[e].milestones) || [];
}
async function grantMilestones(email, reached) {
const e = String(email || '').toLowerCase();
if (!e || !reached || !reached.length) return [];
const bonus = rates().milestoneBonus || {};
let have = [];
if (db.enabled()) {
const r = await db.q('SELECT milestones FROM earned_credits WHERE email=?', [e]);
have = (r.length && r[0].milestones ? r[0].milestones : '').split(',').filter(Boolean);
} else {
if (!EJ.db) EJ.load();
have = (EJ.db[e] && EJ.db[e].milestones) || [];
}
const granted = [];
for (const key of reached) {
if (have.includes(key) || !(bonus[key] > 0)) continue;
await addEarned(e, bonus[key]);
have.push(key);
granted.push({ key, credited: bonus[key] });
}
if (granted.length) {
if (db.enabled()) {
await db.q(`INSERT INTO earned_credits (email,balance,granted_welcome,updated,milestones) VALUES (?,0,0,?,?)
ON DUPLICATE KEY UPDATE milestones=VALUES(milestones), updated=VALUES(updated)`, [e, Date.now(), have.join(',')]);
} else {
if (!EJ.db) EJ.load();
EJ.db[e] = EJ.db[e] || { balance: 0 };
EJ.db[e].milestones = have;
EJ.save();
}
}
return granted;
}
async function grantWelcome(email) {
const e = String(email || '').toLowerCase();
if (!e) return 0;
const amount = rates().welcomeCredits || 0;
if (db.enabled()) {
await db.q(`INSERT INTO earned_credits (email,balance,granted_welcome,updated) VALUES (?,?,1,?)
ON DUPLICATE KEY UPDATE balance = balance + IF(granted_welcome=0, ?, 0),
granted_welcome = 1, updated = VALUES(updated)`, [e, amount, Date.now(), amount]);
return earnedBalance(e);
}
if (!EJ.db) EJ.load();
if (!EJ.db[e] || !EJ.db[e].welcomed) {
EJ.db[e] = { balance: ((EJ.db[e] && EJ.db[e].balance) || 0) + amount, welcomed: true };
EJ.save();
}
return (EJ.db[e] && EJ.db[e].balance) || 0;
}
// spend from the earned pool first (banner/text only); true = fully covered
// The engine-side pool has two grades. `viewing` credits were earned by attention
// (welcome, daily sets, milestones) and fund the baseline formats. `purchase_grade`
// is refunded or credited PURCHASED money (a burner mistake, a comp): it shows as
// purchased on the dashboard and can fund anything, login ads included. Viewing
// credits always spend first so the purchased-grade part is the last to go.
async function earnedSplit(email) {
const e = String(email || '').toLowerCase();
if (!e) return { total: 0, grade: 0, viewing: 0 };
let total = 0, grade = 0;
if (db.enabled()) {
const r = await db.q('SELECT balance, purchase_grade FROM earned_credits WHERE email=?', [e]);
if (r.length) { total = r[0].balance; grade = r[0].purchase_grade || 0; }
} else {
if (!EJ.db) EJ.load();
const rec = EJ.db[e]; if (rec) { total = rec.balance || 0; grade = rec.grade || 0; }
}
grade = Math.max(0, Math.min(grade, total));
return { total, grade, viewing: total - grade };
}
// opts.purchasedOnly: take the amount from the purchased-grade part only (login ads)
async function spendEarned(email, amount, opts) {
const e = String(email || '').toLowerCase();
if (!e || !amount) return false;
if (e === HOUSE_OWNER) return true; // house ads: bottomless pool, nobody is charged
const po = !!(opts && opts.purchasedOnly);
if (db.enabled()) {
const r = po
? await db.q('UPDATE earned_credits SET balance=balance-?, purchase_grade=purchase_grade-? WHERE email=? AND purchase_grade>=? AND balance>=?', [amount, amount, e, amount, amount])
: await db.q('UPDATE earned_credits SET purchase_grade=LEAST(purchase_grade, balance-?), balance=balance-? WHERE email=? AND balance>=?', [amount, amount, e, amount]);
return !!r.affectedRows;
}
if (!EJ.db) EJ.load();
const rec = EJ.db[e];
if (!rec || rec.balance < amount) return false;
if (po && (rec.grade || 0) < amount) return false;
rec.balance -= amount;
rec.grade = po ? (rec.grade || 0) - amount : Math.min(rec.grade || 0, rec.balance);
EJ.save();
return true;
}
// opts.purchased: credit as purchased-grade (refunds, comps) instead of viewing credits
function addEarned(email, amount, opts) {
const e = String(email || '').toLowerCase();
const g = (opts && opts.purchased) ? amount : 0;
if (db.enabled()) {
return db.q(`INSERT INTO earned_credits (email,balance,purchase_grade,granted_welcome,updated) VALUES (?,?,?,0,?)
ON DUPLICATE KEY UPDATE balance=balance+VALUES(balance), purchase_grade=purchase_grade+VALUES(purchase_grade), updated=VALUES(updated)`, [e, amount, g, Date.now()]);
}
if (!EJ.db) EJ.load();
EJ.db[e] = EJ.db[e] || { balance: 0 };
EJ.db[e].balance += amount;
if (g) EJ.db[e].grade = (EJ.db[e].grade || 0) + g;
EJ.save();
}
// ---- onsite solo ads: full-message ads delivered into member inboxes,
// charged per guaranteed delivery; readers earn credits for dwelled reads ----
const SJ = {
db: null,
FILE: () => path.join(DATA_DIR, 'inbox.json'),
load() { try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) { this.db = { nextId: 1, items: [] }; } },
save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} }
};
// lazy guaranteed delivery: whenever a member touches their inbox (or the
// dashboard asks for their unread count), pending solos fill toward their
// recipient guarantee — never the sender's own, never twice to one member,
// and the advertiser is charged per delivery through the same earned-first
// then burn-accrual path every other format uses
async function deliverSolos(email, max = 3, viewer) {
const tier = viewer && viewer.tier, cc = viewer && viewer.cc;
const e = String(email || '').toLowerCase();
if (!e) return 0;
const r = rates();
const cost = r.soloCostPerRecipient || 5;
let n = 0;
if (db.enabled()) {
const rows = await db.q(`SELECT c.* FROM campaigns c
WHERE c.type='solo' AND c.status='active' AND c.owner_email<>?
AND c.budget - c.spent - c.accrued >= ?
AND NOT EXISTS (SELECT 1 FROM solo_inbox s WHERE s.campaign_id=c.id AND s.email=?)
AND (c.starts IS NULL OR c.starts<=?) AND (c.expires IS NULL OR c.expires>?)
AND (c.geo IS NULL OR c.geo='' OR FIND_IN_SET(?, c.geo))
ORDER BY c.created LIMIT ?`, [e, cost, e, Date.now(), Date.now(), String(tier || ''), max]);
for (const row of rows) {
try { await db.q('INSERT INTO solo_inbox (campaign_id,email,delivered) VALUES (?,?,?)', [row.id, e, Date.now()]); }
catch (er) { continue; } // unique key lost a race: already delivered
if (await spendEarned(row.owner_email, cost)) {
await db.q('UPDATE campaigns SET spent=spent+?, imps=imps+1 WHERE id=?', [cost, row.id]);
} else if (row.member_id) {
await db.q('UPDATE campaigns SET accrued=accrued+?, imps=imps+1 WHERE id=?', [cost, row.id]);
await D.rollBurn(row.id, r);
} else { // earned-only advertiser ran dry: undo the delivery, close the campaign
await db.q('DELETE FROM solo_inbox WHERE campaign_id=? AND email=?', [row.id, e]);
await db.q('UPDATE campaigns SET status=\'out\' WHERE id=?', [row.id]);
continue;
}
await db.q('UPDATE campaigns SET status=\'out\' WHERE id=? AND status=\'active\' AND spent+accrued>=budget', [row.id]);
bumpHour(row.id); bumpGeo(row.id, cc);
n++;
}
} else {
if (!SJ.db) SJ.load();
const have = new Set(SJ.db.items.filter(i => i.email === e).map(i => i.cid));
for (const c of J.db.campaigns) {
if (n >= max) break;
if (c.type !== 'solo' || c.status !== 'active' || c.owner === e || have.has(c.id) || !inWindow(c) || !geoOk(c, tier)) continue;
if (c.budget - c.spent - (c.accrued || 0) < cost) continue;
if (await spendEarned(c.owner, cost)) c.spent += cost;
else if (c.memberId) {
c.accrued = (c.accrued || 0) + cost;
if (c.accrued >= r.burnBatchMin) {
J.db.burnsPending.push({ id: bid(), memberId: c.memberId, amount: c.accrued, ref: 'campaign-' + c.id, ts: Date.now() });
c.spent += c.accrued; c.accrued = 0;
}
} else { c.status = 'out'; continue; } // earned-only ran dry
c.imps += 1; bumpHour(c.id); bumpGeo(c.id, cc);
if (c.spent + (c.accrued || 0) >= c.budget) c.status = 'out';
SJ.db.items.push({ id: SJ.db.nextId++, cid: c.id, email: e, delivered: Date.now(), readTs: 0, rewarded: 0, rewardedDay: null });
n++;
}
if (n) { J.save(); SJ.save(); }
}
return n;
}
async function inboxList(email, viewer) {
const e = String(email || '').toLowerCase();
await deliverSolos(e, 3, viewer);
const r = rates();
let items = [];
if (db.enabled()) {
const rows = await db.q(`SELECT s.id, s.campaign_id cid, s.delivered, s.read_ts, s.rewarded,
c.title, c.member_id mid FROM solo_inbox s JOIN campaigns c ON c.id = s.campaign_id
WHERE s.email=? ORDER BY s.delivered DESC LIMIT 100`, [e]);
items = rows.map(x => ({ id: x.id, cid: x.cid, subject: x.title, fromMemberId: x.mid,
delivered: Number(x.delivered), read: !!x.read_ts, rewarded: !!x.rewarded }));
} else {
if (!SJ.db) SJ.load();
items = SJ.db.items.filter(i => i.email === e).sort((a, b) => b.delivered - a.delivered).slice(0, 100)
.map(i => {
const c = J.db.campaigns.find(x => x.id === i.cid) || {};
return { id: i.id, cid: i.cid, subject: c.title || c.name, fromMemberId: c.memberId || 0,
delivered: i.delivered, read: !!i.readTs, rewarded: !!i.rewarded };
});
}
return { items, unread: items.filter(i => !i.read).length,
readCredits: r.soloReadCredits || 2, readDwell: r.soloReadDwellSeconds || 10, readCap: r.soloReadCapPerDay || 5 };
}
async function inboxOpen(email, id) {
const e = String(email || '').toLowerCase();
const r = rates();
if (db.enabled()) {
const rows = await db.q(`SELECT s.*, c.title, c.body, c.member_id mid, c.image_url media, c.cta_label cta
FROM solo_inbox s JOIN campaigns c ON c.id = s.campaign_id WHERE s.id=? AND s.email=?`, [Number(id), e]);
if (!rows.length) return { error: 'No such message.' };
const x = rows[0];
if (!x.read_ts) await db.q('UPDATE solo_inbox SET read_ts=? WHERE id=? AND read_ts IS NULL', [Date.now(), x.id]);
return { id: x.id, cid: x.campaign_id, subject: x.title, body: x.body || '', fromMemberId: x.mid,
mediaUrl: x.media || null, mediaType: x.media ? (/\.(mp4|webm)$/.test(x.media) ? 'video' : 'image') : null,
ctaLabel: x.cta || null, visited: !!x.visited_ts,
url: '/api/ads/click/' + x.campaign_id, delivered: Number(x.delivered),
rewarded: !!x.rewarded, dwell: r.soloReadDwellSeconds || 10, reward: r.soloReadCredits || 2 };
}
if (!SJ.db) SJ.load();
const i = SJ.db.items.find(x => x.id === Number(id) && x.email === e);
if (!i) return { error: 'No such message.' };
if (!i.readTs) { i.readTs = Date.now(); SJ.save(); }
const c = J.db.campaigns.find(x => x.id === i.cid) || {};
return { id: i.id, cid: i.cid, subject: c.title || c.name, body: c.body || '', fromMemberId: c.memberId || 0,
mediaUrl: c.imageUrl || null, mediaType: c.imageUrl ? (/\.(mp4|webm)$/.test(c.imageUrl) ? 'video' : 'image') : null,
ctaLabel: c.ctaLabel || null, visited: !!i.visitedTs,
url: '/api/ads/click/' + i.cid, delivered: i.delivered,
rewarded: !!i.rewarded, dwell: r.soloReadDwellSeconds || 10, reward: r.soloReadCredits || 2 };
}
// the reader clicked the CTA and the click relay opened the advertiser: record
// it so the read reward requires an actual visit, not just sitting on the page
async function markSoloVisit(email, id) {
const e = String(email || '').toLowerCase();
if (db.enabled()) {
const r = await db.q('UPDATE solo_inbox SET visited_ts=? WHERE id=? AND email=? AND visited_ts IS NULL', [Date.now(), Number(id), e]);
return { ok: true, changed: !!r.affectedRows };
}
if (!SJ.db) SJ.load();
const i = SJ.db.items.find(x => x.id === Number(id) && x.email === e);
if (!i) return { error: 'No such message.' };
if (!i.visitedTs) { i.visitedTs = Date.now(); SJ.save(); }
return { ok: true };
}
async function claimSoloRead(email, id) {
const e = String(email || '').toLowerCase();
const r = rates();
const dwellMs = (r.soloReadDwellSeconds || 10) * 1000;
const cap = r.soloReadCapPerDay || 5;
const reward = r.soloReadCredits || 2;
const day = today();
if (db.enabled()) {
const rows = await db.q('SELECT * FROM solo_inbox WHERE id=? AND email=?', [Number(id), e]);
if (!rows.length) return { error: 'No such message.' };
const x = rows[0];
if (x.rewarded) return { error: 'Already claimed for this one.' };
if (!x.read_ts || Date.now() - Number(x.read_ts) < dwellMs - 400) return { error: 'Give it a real read first.' };
if (!x.visited_ts) return { error: 'Click through to the advertiser first — that visit is what earns the credits.' };
const cnt = await db.q('SELECT COUNT(*) n FROM solo_inbox WHERE email=? AND rewarded=1 AND rewarded_day=?', [e, day]);
if (cnt[0].n >= cap) return { error: 'Daily read-reward cap reached (' + cap + '). Reading still works; rewards resume tomorrow.' };
const upd = await db.q('UPDATE solo_inbox SET rewarded=1, rewarded_day=? WHERE id=? AND rewarded=0', [day, x.id]);
if (!upd.affectedRows) return { error: 'Already claimed for this one.' };
await addEarned(e, reward);
return { ok: true, credited: reward };
}
if (!SJ.db) SJ.load();
const i = SJ.db.items.find(x => x.id === Number(id) && x.email === e);
if (!i) return { error: 'No such message.' };
if (i.rewarded) return { error: 'Already claimed for this one.' };
if (!i.readTs || Date.now() - i.readTs < dwellMs - 400) return { error: 'Give it a real read first.' };
if (!i.visitedTs) return { error: 'Click through to the advertiser first — that visit is what earns the credits.' };
const nToday = SJ.db.items.filter(x => x.email === e && x.rewarded && x.rewardedDay === day).length;
if (nToday >= cap) return { error: 'Daily read-reward cap reached (' + cap + '). Reading still works; rewards resume tomorrow.' };
i.rewarded = 1;
i.rewardedDay = day;
SJ.save();
addEarned(e, reward);
return { ok: true, credited: reward };
}
async function unreadCount(email) {
const e = String(email || '').toLowerCase();
if (!e) return 0;
await deliverSolos(e);
if (db.enabled()) {
const r = await db.q('SELECT COUNT(*) n FROM solo_inbox WHERE email=? AND read_ts IS NULL', [e]);
return r[0].n;
}
if (!SJ.db) SJ.load();
return SJ.db.items.filter(i => i.email === e && !i.readTs).length;
}
// ---- attention-gated daily claim (view N real ads -> claim earned credits) ----
const VJ = {
db: null,
FILE: () => path.join(DATA_DIR, 'dailyviews.json'),
load() { try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) { this.db = {}; } },
save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} }
};
const today = () => new Date().toISOString().slice(0, 10);
// Claim streak (Marty, 2026-09-12): consecutive days the daily set was claimed. Day 1 pays the
// base claim, day 2 pays 7, day 3+ pays 10, and every 7th day pays 25. Rewards coming back.
function claimBonus(day) {
const base = rates().dailyClaimCredits || 5;
if (day >= 7 && day % 7 === 0) return 25;
if (day >= 3) return 10;
if (day === 2) return Math.max(base, 7);
return base;
}
// consecutive claimed days ending YESTERDAY (today is handled by the caller)
async function claimStreakBefore(email) {
const e = String(email || '').toLowerCase();
if (!db.enabled()) return 0;
const since = new Date(Date.now() - 60 * 86400000).toISOString().slice(0, 10);
const rows = await db.q('SELECT day FROM daily_views WHERE email=? AND claimed=1 AND day>=? AND day<? ORDER BY day DESC', [e, since, today()]);
let n = 0; let expect = new Date(Date.now() - 86400000);
for (const r of rows) {
if (String(r.day) !== expect.toISOString().slice(0, 10)) break;
n++; expect = new Date(expect.getTime() - 86400000);
}
return n;
}
async function viewStatus(email) {
const e = String(email || '').toLowerCase();
const r = rates();
let views = 0, claimed = false;
if (db.enabled()) {
const rows = await db.q('SELECT views, claimed FROM daily_views WHERE email=? AND day=?', [e, today()]);
if (rows.length) { views = rows[0].views; claimed = !!rows[0].claimed; }
} else {
if (!VJ.db) VJ.load();
const rec = VJ.db[e];
if (rec && rec.day === today()) { views = rec.views; claimed = !!rec.claimed; }
}
const sp = await earnedSplit(e);
const earned = sp.viewing;
const reserved = Math.min(earned, (await liveBudgets(e)).filter(c => c.type !== 'login').reduce((n, c) => n + c.left, 0));
const before = await claimStreakBefore(e);
const streakDay = claimed ? before + 1 : before + 1; // the day number today's claim is / would be
let visitsLeft = 0; try { const vs = await visitStatus(e); visitsLeft = Math.max(0, vs.cap - vs.count); } catch (err) {}
return { views, target: r.dailyViewTarget, claimCredits: claimBonus(streakDay), streakDay, nextClaim: claimBonus(streakDay + 1), visitsLeft,
dwell: r.viewDwellSeconds, claimed, earned, earnedAvailable: earned - reserved, reserved, credited: sp.grade };
}
async function recordView(email) {
const e = String(email || '').toLowerCase();
const r = rates();
const minGap = Math.max(2, r.viewDwellSeconds - 1) * 1000;
const now = Date.now();
if (db.enabled()) {
await db.q(`INSERT INTO daily_views (email,day,views,claimed,last_ts) VALUES (?,?,0,0,0)
ON DUPLICATE KEY UPDATE email=email`, [e, today()]);
const upd = await db.q(`UPDATE daily_views SET views=views+1, last_ts=? WHERE email=? AND day=? AND views<? AND last_ts<=?`,
[now, e, today(), r.dailyViewTarget, now - minGap]);
if (!upd.affectedRows) return Object.assign(await viewStatus(e), { tooFast: true });
} else {
if (!VJ.db) VJ.load();
let rec = VJ.db[e];
if (!rec || rec.day !== today()) rec = VJ.db[e] = { day: today(), views: 0, claimed: false, last: 0 };
if (rec.views >= r.dailyViewTarget || now - rec.last < minGap) return Object.assign(await viewStatus(e), { tooFast: true });
rec.views += 1; rec.last = now;
VJ.save();
}
return viewStatus(e);
}
// ---- watch-to-earn video ads: serve a video, enforce watch on server clock,
// charge the advertiser the tier cost per completed watch, credit the viewer ----
function videoTierFor(secs) { return (rates().videoTiers || []).find(t => t.secs === Number(secs)); }
function servedVideo(c) {
const t = videoTierFor(c.body) || { secs: 10, reward: 1 };
return { id: c.id, videoUrl: c.imageUrl, title: c.title || null, ctaUrl: '/api/ads/click/' + c.id,
ctaLabel: c.ctaLabel || null, watchSecs: t.secs, reward: t.reward,
width: c.width || null, height: c.height || null };
}
// orientation: 'portrait' (Shorts reel) = height>width; 'landscape' (Watch videos) = anything else,
// INCLUDING unknown dimensions so legacy/undetected videos still play in the standard tab.
async function serveVideo(opts) {
const ex = (opts && opts.excludeEmail) || '';
const ori = (opts && opts.orientation) || '';
if (db.enabled()) {
let oc = '';
if (ori === 'portrait') oc = ' AND width IS NOT NULL AND height IS NOT NULL AND height>width';
else if (ori === 'landscape') oc = ' AND (width IS NULL OR height IS NULL OR width>=height)';
const params = [ex];
let seen = '';
if (ex && !(opts && opts.ignoreSeen)) { seen = ' AND id NOT IN (SELECT campaign_id FROM video_seen WHERE email=? AND day=?)'; params.push(ex, today()); }
params.push(Date.now(), Date.now(), String((opts && opts.tier) || ''));
const rows = await db.q(`SELECT * FROM campaigns WHERE type='video' AND status='active'
AND owner_email<>? AND spent+accrued<budget` + oc + seen + SCHED_SQL + GEO_SQL + ` ORDER BY RAND() LIMIT 1`, params);
if (rows.length) bumpGeo(rows[0].id, opts && opts.cc);
return rows.length ? servedVideo(rowC(rows[0])) : null;
}
const portrait = c => c.width && c.height && c.height > c.width;
const oriOk = c => ori === 'portrait' ? portrait(c) : ori === 'landscape' ? !portrait(c) : true;
if (!VJ.db) VJ.load();
const seenIds = (ex && !(opts && opts.ignoreSeen) && VJ.db[ex] && VJ.db[ex].day === today()) ? (VJ.db[ex].videosSeen || []) : [];
const pool = J.db.campaigns.filter(c => c.type === 'video' && c.status === 'active' && inWindow(c) && geoOk(c, opts && opts.tier)
&& (!ex || c.owner !== ex) && (c.spent + (c.accrued || 0) < c.budget) && oriOk(c) && !seenIds.includes(c.id));
const pick = pool.length ? pool[Math.floor(Math.random() * pool.length)] : null;
if (pick) bumpGeo(pick.id, opts && opts.cc);
return pick ? servedVideo(pick) : null;
}
async function chargeVideoView(id) {
const r = rates();
if (db.enabled()) {
const rows = await db.q("SELECT * FROM campaigns WHERE id=? AND type='video' AND status='active'", [Number(id)]);
if (!rows.length) return null;
const c = rowC(rows[0]); const t = videoTierFor(c.body); if (!t) return null;
if (await spendEarned(c.owner, t.cost)) {
await db.q('UPDATE campaigns SET spent=spent+?, imps=imps+1 WHERE id=?', [t.cost, c.id]);
} else if (c.memberId) {
await db.q('UPDATE campaigns SET accrued=accrued+?, imps=imps+1 WHERE id=?', [t.cost, c.id]);
await D.rollBurn(c.id, r);
} else { await db.q("UPDATE campaigns SET status='out' WHERE id=?", [c.id]); return null; }
await db.q("UPDATE campaigns SET status='out' WHERE id=? AND status='active' AND spent+accrued>=budget", [c.id]);
bumpHour(c.id);
return t;
}
const c = J.db.campaigns.find(x => x.id === Number(id) && x.type === 'video');
if (!c || c.status !== 'active') return null;
const t = videoTierFor(c.body); if (!t) return null;
if (await spendEarned(c.owner, t.cost)) { c.spent += t.cost; }
else if (c.memberId) {
c.accrued = (c.accrued || 0) + t.cost;
if (c.accrued >= r.burnBatchMin) {
J.db.burnsPending.push({ id: bid(), memberId: c.memberId, amount: c.accrued, ref: 'campaign-' + c.id, ts: Date.now() });
c.spent += c.accrued; c.accrued = 0;
}
} else { c.status = 'out'; J.save(); return null; }
c.imps += 1; bumpHour(c.id);
if (c.spent + (c.accrued || 0) >= c.budget) c.status = 'out';
J.save();
return t;
}
async function videoStatus(email) {
const e = String(email || '').toLowerCase(); const r = rates(); const cap = r.videoWatchCapPerDay || 8;
let count = 0;
if (db.enabled()) {
const rows = await db.q('SELECT video_count FROM daily_views WHERE email=? AND day=?', [e, today()]);
if (rows.length) count = rows[0].video_count || 0;
} else {
if (!VJ.db) VJ.load();
const rec = VJ.db[e];
if (rec && rec.day === today()) count = rec.videoCount || 0;
}
return { count, cap, left: Math.max(0, cap - count) };
}
async function recordVideoWatch(email) {
const e = String(email || '').toLowerCase();
if (db.enabled()) {
await db.q(`INSERT INTO daily_views (email,day,views,claimed,last_ts,video_count) VALUES (?,?,0,0,0,1)
ON DUPLICATE KEY UPDATE video_count=video_count+1`, [e, today()]);
} else {
if (!VJ.db) VJ.load();
let rec = VJ.db[e];
if (!rec || rec.day !== today()) rec = VJ.db[e] = { day: today(), views: 0, claimed: false, last: 0, videoCount: 0 };
rec.videoCount = (rec.videoCount || 0) + 1;
VJ.save();
}
return videoStatus(e);
}
// once-per-day-per-video dedup so a member can't re-earn from the same video today
async function markVideoSeen(email, campaignId) {
const e = String(email || '').toLowerCase(), id = Number(campaignId) || 0;
if (db.enabled()) { try { await db.q('INSERT IGNORE INTO video_seen (email,campaign_id,day,ts) VALUES (?,?,?,?)', [e, id, today(), Date.now()]); } catch (x) {} return; }
if (!VJ.db) VJ.load();
let rec = VJ.db[e]; if (!rec || rec.day !== today()) rec = VJ.db[e] = { day: today(), views: 0, claimed: false, last: 0, videoCount: 0 };
rec.videosSeen = rec.videosSeen || []; if (!rec.videosSeen.includes(id)) rec.videosSeen.push(id); VJ.save();
}
async function hasWatchedVideoToday(email, campaignId) {
const e = String(email || '').toLowerCase(), id = Number(campaignId) || 0;
if (db.enabled()) { const r = await db.q('SELECT 1 FROM video_seen WHERE email=? AND campaign_id=? AND day=? LIMIT 1', [e, id, today()]); return r.length > 0; }
if (!VJ.db) VJ.load();
const rec = VJ.db[e]; return !!(rec && rec.day === today() && (rec.videosSeen || []).includes(id));
}
async function claimDaily(email) {
const e = String(email || '').toLowerCase();
const r = rates();
if (db.enabled()) {
const upd = await db.q('UPDATE daily_views SET claimed=1 WHERE email=? AND day=? AND claimed=0 AND views>=?',
[e, today(), r.dailyViewTarget]);
if (!upd.affectedRows) return { error: 'View todays ads first, then claim.' };
} else {
if (!VJ.db) VJ.load();
const rec = VJ.db[e];
if (!rec || rec.day !== today() || rec.claimed || rec.views < r.dailyViewTarget)
return { error: 'View todays ads first, then claim.' };
rec.claimed = true;
VJ.save();
}
const day = (await claimStreakBefore(e)) + 1;
const amount = claimBonus(day);
await addEarned(e, amount);
return { ok: true, credited: amount, streakDay: day, nextClaim: claimBonus(day + 1), status: await viewStatus(e) };
}
const impl = () => db.enabled() ? D : J;
function init(opts) { DATA_DIR = opts.dataDir; chain = opts.chain; J.load(); }
async function availableCredits(memberId) {
const onchain = await chain.creditBalance(memberId, 0);
// on-chain balance, minus spend not yet burned, minus budget already promised to live campaigns
return Math.max(0, onchain - await impl().unburned(memberId) - await impl().committed(memberId));
}
// budget still to deliver on an owner's live campaigns. This is what the dashboard
// shows as "in campaigns" and holds back from the balances up front, so a balance
// only moves when a campaign is created, topped up or paused, never per impression
async function liveBudgets(owner) {
const o = String(owner || '').toLowerCase();
if (!o) return [];
const rows = db.enabled()
? (await db.q("SELECT id, member_id, type, GREATEST(budget-spent-accrued,0) lft FROM campaigns WHERE owner_email=? AND status IN ('active','paused')", [o]))
.map(r => ({ id: r.id, memberId: r.member_id || 0, type: r.type, left: Number(r.lft) }))
: J.db.campaigns.filter(c => c.owner === o && ['active', 'paused'].includes(c.status))
.map(c => ({ id: c.id, memberId: c.memberId || 0, type: c.type, left: Math.max(0, (c.budget || 0) - (c.spent || 0) - (c.accrued || 0)) }));
return rows.filter(r => r.left > 0);
}
// the balances a member sees. One rule, stated on the dashboard: a balance is
// what is NOT committed to a live campaign. Live budgets are reserved from the
// earned pool first, then from purchased credits (the same order delivery spends
// in, so the two numbers never move against each other while an ad serves).
// `per` = purchased credits free on each on-chain member the account controls
// (main + linked positions); `best` = the one a new campaign is charged to
// (each campaign burns from exactly one member id, so a budget has to fit inside one)
async function balances(ids, owner) {
const list = [...new Set((ids || []).filter(Boolean))];
const live = await liveBudgets(owner);
const sp = owner ? await earnedSplit(owner) : { total: 0, grade: 0, viewing: 0 };
let cover = sp.viewing, grade = sp.grade;
const coverBy = {}; // how much of each member's committed budget the engine-side pool already covers
// login budgets draw on credited purchased money first
for (const c of live.filter(c => c.type === 'login')) { const y = Math.min(grade, c.left); grade -= y; coverBy[c.memberId] = (coverBy[c.memberId] || 0) + y; }
// everything else: viewing credits first, then credited purchased money; campaigns with no position first (they have no other source)
for (const c of live.filter(c => c.type !== 'login').sort((a, b) => (a.memberId ? 1 : 0) - (b.memberId ? 1 : 0))) {
const x = Math.min(cover, c.left); cover -= x;
const y = Math.min(grade, c.left - x); grade -= y;
coverBy[c.memberId] = (coverBy[c.memberId] || 0) + x + y;
}
const per = [];
for (const id of list) {
let avail = 0;
try {
const onchain = await chain.creditBalance(id, 0);
const comm = owner ? live.filter(c => c.memberId === id).reduce((n, c) => n + c.left, 0) : await impl().committed(id);
avail = Math.max(0, onchain - await impl().unburned(id) - Math.max(0, comm - (coverBy[id] || 0)));
} catch (e) {}
per.push({ memberId: id, avail });
}
const onchainTotal = per.reduce((n, p) => n + p.avail, 0);
const best = per.slice().sort((a, b) => b.avail - a.avail)[0] || { memberId: 0, avail: 0 };
const inCampaigns = live.reduce((n, c) => n + c.left, 0);
// `credited` = purchased-grade pool still free: counts as purchased, spends from any position
return { total: onchainTotal + grade, onchain: onchainTotal, credited: grade, best, per, earned: cover, earnedRaw: sp.total,
earnedReserved: sp.total - cover - grade, inCampaigns, available: onchainTotal + grade + cover, live };
}
const pooledCredits = balances;
async function createCampaign(owner, memberId, input, ids) {
const v = validate(input);
if (v.error) return v;
const bal = await balances(ids && ids.length ? ids : [memberId], owner);
const purchased = (bal.per.find(p => p.memberId === memberId) || { avail: 0 }).avail + bal.credited;
const earned = v.c.type !== 'login' ? bal.earned : 0; // viewing credits: baseline formats only, and only the part not already in a campaign
const avail = purchased + earned;
if (v.c.type === 'login' && v.c.budget > avail) { if (bal.earned > 0) return { error: 'Login ads spend purchased credits only, and you have ' + purchased + ' purchased not already in a campaign. Your ' + bal.earned + ' earned credits can fund banner, text, solo, video, featured and verified-visit campaigns.' }; }
if (v.c.budget > avail) return { error: 'Budget exceeds your available credits (' + avail
+ (earned ? ', including ' + earned + ' earned' : '') + ').' };
// featured runs book specific day-slots — verify every covered day has room
if (v.c.type === 'featured' && v.c.featuredRun) {
const av = await featuredAvailable(v.c.featuredRun.start, v.c.featuredRun.days);
if (!av.ok) return { error: 'That run is full on ' + av.fullDay + '. Pick a different start day or duration.' };
}
delete v.c.featuredRun; // transient, not a stored field
const campaign = await impl().create(owner, memberId, v.c);
// verified visits: flat up-front buy; keep active until the pack is delivered
if (campaign.type === 'visits') {
await chargeCredits(campaign.id, v.c.budget);
if (db.enabled()) await db.q("UPDATE campaigns SET status='active' WHERE id=?", [campaign.id]);
else { const cc = J.db.campaigns.find(x => x.id === campaign.id); if (cc) { cc.status = 'active'; J.save(); } }
campaign.status = 'active';
}
// featured is a flat up-front buy (not metered) — charge the full price now,
// then keep it active (it runs until `expires`, not until budget is "spent")
if (campaign.type === 'featured') {
await chargeCredits(campaign.id, v.c.budget);
if (db.enabled()) await db.q("UPDATE campaigns SET status='active' WHERE id=?", [campaign.id]);
else { const cc = J.db.campaigns.find(x => x.id === campaign.id); if (cc) { cc.status = 'active'; J.save(); } }
campaign.status = 'active';
}
// syndicate banner/text out to NAS (inert unless NAS_DB_* is configured);
// never let a NAS hiccup block the IAP campaign from going live
if (nas.enabled() && nas.nasKind(campaign.type) && !campaign.geo && !(campaign.starts && campaign.starts > Date.now())) { // scheduled ones go out at start time (scheduleSweep); geo-restricted ones never syndicate (the network is worldwide)
try {
const r = await nas.pushCampaign(campaign, { email: owner, name: input.advName || 'LinkSpin member' });
if (r && r.nasAdId) await setNasLink(campaign.id, r.nasAdId);
} catch (e) { console.error('nas push', campaign.id, e.message); }
}
return { ok: true, campaign };
}
// every few minutes: close campaigns past their end (unspent budget returns to
// Available), pull ended banners/text off the network, and push scheduled
// banners/text to the network once their start time arrives
async function scheduleSweep() {
const now = Date.now();
let ended = [], starting = [];
if (db.enabled()) {
ended = (await db.q("SELECT * FROM campaigns WHERE status IN ('active','paused') AND expires IS NOT NULL AND expires<=?", [now])).map(rowC);
for (const c of ended) await db.q("UPDATE campaigns SET status='done' WHERE id=?", [c.id]);
starting = (await db.q("SELECT * FROM campaigns WHERE status='active' AND nas_ad_id IS NULL AND type IN ('banner','text') AND (geo IS NULL OR geo='') AND starts IS NOT NULL AND starts<=? AND (expires IS NULL OR expires>?)", [now, now])).map(rowC);
} else {
if (!J.db) J.load();
ended = J.db.campaigns.filter(c => ['active', 'paused'].includes(c.status) && c.expires && c.expires <= now);
for (const c of ended) c.status = 'done';
starting = J.db.campaigns.filter(c => c.status === 'active' && !c.nasAdId && !c.geo && ['banner', 'text'].includes(c.type) && c.starts && c.starts <= now && (!c.expires || c.expires > now));
if (ended.length) J.save();
}
if (nas.enabled()) {
for (const c of ended) if (c.nasAdId) { try { await nas.deactivate(c.nasAdId); } catch (e) {} }
for (const c of starting) {
try { const r = await nas.pushCampaign(pubC(c), { email: c.owner, name: 'LinkSpin member' }); if (r && r.nasAdId) await setNasLink(c.id, r.nasAdId); }
catch (e) { console.error('nas push (scheduled)', c.id, e.message); }
}
}
return { ended: ended.length, started: starting.length };
}
// charge an arbitrary credit amount against a campaign's budget, earned-pool
// first then on-chain accrual — the shared spend path for NAS reconciliation
// per-day spend counters behind the optional daily cap
function bumpDayJ(c, credits) { const k = dayKeyNow(); if (c.dayKey !== k) { c.dayKey = k; c.daySpent = 0; } c.daySpent = (c.daySpent || 0) + credits; }
async function bumpDayD(id, credits) {
const k = dayKeyNow();
await db.q('UPDATE campaigns SET day_spent=IF(day_key=?, day_spent+?, ?), day_key=? WHERE id=?', [k, credits, credits, k, Number(id)]);
}
async function chargeCredits(id, credits) {
const r = rates();
if (!credits || credits <= 0) return { charged: 0 };
if (db.enabled()) {
const rows = await db.q('SELECT * FROM campaigns WHERE id=?', [Number(id)]);
if (!rows.length) return { charged: 0 };
const c = rowC(rows[0]);
credits = Math.min(credits, Math.max(0, c.budget - (c.spent || 0) - (c.accrued || 0))); // never past the budget
if (!credits) { await db.q("UPDATE campaigns SET status='out' WHERE id=? AND status='active'", [id]); return { charged: 0, exhausted: true }; }
await bumpDayD(c.id, credits);
if (await spendEarned(c.owner, credits)) await db.q('UPDATE campaigns SET spent=spent+? WHERE id=?', [credits, id]);
else if (c.memberId) { await db.q('UPDATE campaigns SET accrued=accrued+? WHERE id=?', [credits, id]); await D.rollBurn(id, r); }
else { await db.q("UPDATE campaigns SET status='out' WHERE id=?", [id]); return { charged: 0, exhausted: true }; }
const after = rowC((await db.q('SELECT * FROM campaigns WHERE id=?', [id]))[0]);
const exhausted = (after.spent + after.accrued) >= after.budget;
if (exhausted) await db.q("UPDATE campaigns SET status='out' WHERE id=? AND status='active'", [id]);
return { charged: credits, exhausted };
}
const c = J.db.campaigns.find(x => x.id === Number(id));
if (!c) return { charged: 0 };
credits = Math.min(credits, Math.max(0, c.budget - c.spent - (c.accrued || 0))); // never past the budget
if (!credits) { c.status = 'out'; J.save(); return { charged: 0, exhausted: true }; }
bumpDayJ(c, credits);
if (await spendEarned(c.owner, credits)) c.spent += credits;
else if (c.memberId) {
c.accrued = (c.accrued || 0) + credits;
if (c.accrued >= r.burnBatchMin) { J.db.burnsPending.push({ id: bid(), memberId: c.memberId, amount: c.accrued, ref: 'campaign-' + c.id, ts: Date.now() }); c.spent += c.accrued; c.accrued = 0; }
} else { c.status = 'out'; J.save(); return { charged: 0, exhausted: true }; }
const exhausted = (c.spent + (c.accrued || 0)) >= c.budget;
if (exhausted) c.status = 'out';
J.save();
return { charged: credits, exhausted };
}
async function setNasLink(id, nasAdId) {
if (db.enabled()) await db.q('UPDATE campaigns SET nas_ad_id=? WHERE id=?', [nasAdId, Number(id)]);
else { const c = J.db.campaigns.find(x => x.id === Number(id)); if (c) { c.nasAdId = nasAdId; J.save(); } }
}
async function setNasServed(id, served) {
if (db.enabled()) await db.q('UPDATE campaigns SET nas_served=? WHERE id=?', [served, Number(id)]);
else { const c = J.db.campaigns.find(x => x.id === Number(id)); if (c) { c.nasServed = served; J.save(); } }
}
// reconcile NAS delivery into the unified credit pool: read each syndicated
// ad's served count (assigned - remaining, counts DOWN), charge the new
// impressions to the campaign's budget, and stop the NAS ad when budget is dry
async function reconcileNas() {
if (!nas.enabled()) return { done: 0 };
let camps;
if (db.enabled()) {
const rows = await db.q("SELECT * FROM campaigns WHERE nas_ad_id IS NOT NULL AND type IN ('banner','text')");
camps = rows.map(rowC);
} else {
if (!J.db) J.load();
camps = J.db.campaigns.filter(c => c.nasAdId && ['banner', 'text'].includes(c.type));
}
let n = 0;
for (const c of camps) {
let st;
try { st = await nas.readServed(c.nasAdId); } catch (e) { continue; }
if (!st) continue;
const ipc = nas.impressionsPerCredit(c.type) || 1;
// a capped campaign gets one day's allowance of network impressions at a time: on a new day,
// hand NAS the next allowance (bounded by the budget left) instead of the whole budget
if (c.dailyCap && c.status === 'active' && c.dayKey !== dayKeyNow()) {
const left = c.budget - (c.spent || 0) - (c.accrued || 0);
const allow = Math.max(0, Math.min(c.dailyCap, left)) * ipc;
if (allow > 0 && st.remaining < allow) { try { await nas.topUp(c.nasAdId, allow - st.remaining, 30); } catch (e) {} }
if (db.enabled()) await db.q('UPDATE campaigns SET day_key=?, day_spent=0 WHERE id=?', [dayKeyNow(), c.id]);
else { c.dayKey = dayKeyNow(); c.daySpent = 0; J.save(); }
}
const prev = c.nasServed || 0;
if (st.served > prev) {
const creditsDue = Math.floor(st.served / ipc) - Math.floor(prev / ipc);
if (creditsDue > 0) {
const res = await chargeCredits(c.id, creditsDue);
if (res.exhausted) { try { await nas.deactivate(c.nasAdId); } catch (e) {} }
}
await setNasServed(c.id, st.served);
n++;
}
}
return { done: n };
}
// buy more views: raise a campaign's budget cap by `addCredits` (bounded by the
// member's available credits, same rule as creation), reactivate it if it had
// run out or was paused, and extend the syndicated NAS ad if there is one.
async function topUpCampaign(owner, memberId, id, addCredits) {
const add = Math.floor(Number(addCredits) || 0);
if (add < 10) return { error: 'Add at least 10 credits.' };
const list = await impl().list(owner);
const c = list.find(x => x.id === Number(id));
if (!c) return { error: 'No such campaign.' };
const fundId = c.memberId || memberId; // a campaign burns from the member it was created under
const bal = await balances(fundId ? [fundId] : [], owner);
const purchased = (fundId ? bal.best.avail : 0) + bal.credited;
const earned = c.type !== 'login' ? bal.earned : 0;
if (add > purchased + earned) return { error: 'That exceeds your available credits (' + (purchased + earned) + ' not already committed to a campaign).' };
const raw = db.enabled()
? rowC((await db.q('SELECT * FROM campaigns WHERE id=? AND owner_email=?', [Number(id), owner]))[0])
: J.db.campaigns.find(x => x.id === Number(id) && x.owner === owner);
if (!raw) return { error: 'No such campaign.' };
const newBudget = raw.budget + add;
const reactivate = raw.status !== 'active';
if (db.enabled()) {
await db.q("UPDATE campaigns SET budget=?, status=IF(status='active','active','active') WHERE id=?", [newBudget, Number(id)]);
} else { raw.budget = newBudget; raw.status = 'active'; J.save(); }
if (raw.nasAdId && nas.enabled()) {
try { await nas.topUp(raw.nasAdId, add * (nas.impressionsPerCredit(raw.type) || 0), 30); } catch (e) {}
}
return { ok: true, added: add, budget: newBudget, reactivated: reactivate };
}
const DAY_MS = 86400000;
// start-of-day (UTC) for a booking input: a YYYY-MM-DD string, or an integer
// offset in days from today. Returns a ms timestamp, or null if unparseable.
function dayStart(input) {
if (input === undefined || input === null || input === '') input = 0;
if (typeof input === 'number' || /^\d+$/.test(String(input))) {
const base = Math.floor(Date.now() / DAY_MS) * DAY_MS;
return base + Number(input) * DAY_MS;
}
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(input));
if (!m) return null;
const t = Date.UTC(+m[1], +m[2] - 1, +m[3]);
return Number.isFinite(t) ? t : null;
}
// all featured campaigns still relevant (not yet expired), for occupancy math
async function featuredAll() {
const now = Date.now();
if (db.enabled()) {
return (await db.q("SELECT * FROM campaigns WHERE type='featured' AND status IN ('active') AND expires>?", [now])).map(rowC);
}
if (!J.db) J.load();
return J.db.campaigns.filter(c => c.type === 'featured' && c.status === 'active' && (c.expires || 0) > now);
}
// how many featured runs cover a given day [dayStart, dayStart+DAY)
function occupancyOn(list, ds) {
return list.filter(c => (c.starts || c.created) < ds + DAY_MS && (c.expires || 0) > ds).length;
}
// occupancy for the booking window: [{ day:'YYYY-MM-DD', offset, count, cap, open }]
async function featuredOccupancy() {
const r = rates();
const cap = r.featuredSlotsPerDay || 10;
const list = await featuredAll();
const win = r.featuredWindowDays || 7;
const out = [];
for (let i = 0; i <= win; i++) {
const ds = dayStart(i);
const count = occupancyOn(list, ds);
out.push({ offset: i, day: new Date(ds).toISOString().slice(0, 10), count, cap, open: Math.max(0, cap - count) });
}
return out;
}
// is a run [start, start+days) bookable? every covered day must have an open slot
async function featuredAvailable(start, days) {
const r = rates();
const cap = r.featuredSlotsPerDay || 10;
const list = await featuredAll();
for (let i = 0; i < days; i++) {
const ds = start + i * DAY_MS;
if (occupancyOn(list, ds) >= cap) return { ok: false, fullDay: new Date(ds).toISOString().slice(0, 10) };
}
return { ok: true };
}
// live featured links: active AND currently within [starts, expires)
async function serveFeatured(viewer) {
const now = Date.now();
const list = await featuredAll();
const tier = viewer && viewer.tier;
return list.filter(c => (c.starts || c.created) <= now && (c.expires || 0) > now && geoOk(c, tier))
.map(c => ({ id: c.id, title: c.title, url: '/api/ads/click/' + c.id, memberId: c.memberId || 0, expires: c.expires }));
}
// featured strip impressions (2026-09-15): the strip is served on every Overview but never counted, so advertisers
// paying per day saw "0 views". One impression per viewer per campaign per hour, so a member refreshing all day
// does not inflate it; feeds the same imps + by-hour chart the other formats use.
const featuredSeen = new Map(); // viewerKey|campaignId -> hour key
async function noteFeaturedViews(items, viewerKey) {
const hk = new Date().toISOString().slice(0, 13); const k0 = String(viewerKey || 'anon');
if (featuredSeen.size > 100000) featuredSeen.clear();
for (const i of items || []) {
const k = k0 + '|' + i.id; if (featuredSeen.get(k) === hk) continue; featuredSeen.set(k, hk);
try {
if (db.enabled()) await db.q('UPDATE campaigns SET imps=imps+1 WHERE id=?', [i.id]);
else { const c = J.db && J.db.campaigns && J.db.campaigns.find(x => x.id === i.id); if (c) { c.imps = (c.imps || 0) + 1; J.save(); } }
bumpHour(i.id);
} catch (e) {}
}
}
async function featuredStats() {
const live = await serveFeatured();
const r = rates();
return { activeCount: live.length, perDay: r.featuredPerDay || 40, durations: r.featuredDurations || [1, 2, 7],
slotsPerDay: r.featuredSlotsPerDay || 10, occupancy: await featuredOccupancy() };
}
// ── verified visits: serve a pack the viewer hasn't completed, then on a
// dwell+captcha-verified visit record it uniquely, pay the viewer, and count it ──
async function visitDailyCount(email) {
const e = String(email || '').toLowerCase();
if (db.enabled()) { const r = await db.q('SELECT COUNT(*) n FROM visit_seen WHERE email=? AND day=?', [e, today()]); return r[0].n; }
if (!SVJ.db) SVJ.load();
return SVJ.db.items.filter(i => i.email === e && i.day === today()).length;
}
const SVJ = { db: null, FILE: () => path.join(DATA_DIR, 'visit-seen.json'),
load() { try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) { this.db = { items: [] }; } },
save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} } };
async function serveVisit(viewerEmail, viewer) {
const e = String(viewerEmail || '').toLowerCase();
const tier = viewer && viewer.tier, vcc = viewer && viewer.cc;
const now = Date.now();
let seen = new Set();
if (db.enabled()) {
(await db.q('SELECT campaign_id FROM visit_seen WHERE email=?', [e])).forEach(r => seen.add(r.campaign_id));
const rows = (await db.q("SELECT * FROM campaigns WHERE type='visits' AND status='active' AND owner_email<>?" + SCHED_SQL + GEO_SQL + " ORDER BY RAND() LIMIT 20", [e, Date.now(), Date.now(), String(tier || '')])).map(rowC);
const c = rows.find(x => (x.imps || 0) < Number(x.body || 0) && !seen.has(x.id));
if (c) bumpGeo(c.id, vcc);
return c ? { id: c.id, title: c.title, url: '/api/ads/click/' + c.id, target: c.targetUrl } : null;
}
if (!SVJ.db) SVJ.load();
SVJ.db.items.filter(i => i.email === e).forEach(i => seen.add(i.cid));
const pool = J.db.campaigns.filter(c => c.type === 'visits' && c.status === 'active' && c.owner !== e && inWindow(c) && geoOk(c, tier)
&& (c.imps || 0) < Number(c.body || 0) && !seen.has(c.id));
const c = pool[Math.floor(Math.random() * pool.length)];
if (c) bumpGeo(c.id, vcc);
return c ? { id: c.id, title: c.title, url: '/api/ads/click/' + c.id, target: c.targetUrl } : null;
}
async function visitStatus(email) {
const r = rates();
return { count: await visitDailyCount(email), cap: r.visitCapPerDay || 20, dwell: r.visitDwellSeconds || 8, reward: r.visitReward || 1 };
}
// record a verified visit: unique per (campaign, viewer), advance the count,
// reward the viewer, and complete the pack when the target is hit
async function completeVisit(email, campaignId) {
const e = String(email || '').toLowerCase();
const r = rates();
const cap = r.visitCapPerDay || 20;
if (await visitDailyCount(e) >= cap) return { error: 'Daily verified-visit cap reached. More tomorrow.' };
if (db.enabled()) {
const rows = await db.q("SELECT * FROM campaigns WHERE id=? AND type='visits' AND status='active'", [Number(campaignId)]);
if (!rows.length) return { error: 'That visit is no longer available.' };
const c = rowC(rows[0]);
if ((c.imps || 0) >= Number(c.body || 0)) return { error: 'That pack just filled up.' };
try { await db.q('INSERT INTO visit_seen (campaign_id,email,day,ts) VALUES (?,?,?,?)', [c.id, e, today(), Date.now()]); }
catch (er) { return { error: 'You already visited this one.' }; } // unique key
await db.q('UPDATE campaigns SET imps=imps+1 WHERE id=?', [c.id]); bumpHour(c.id);
await db.q("UPDATE campaigns SET status='out' WHERE id=? AND imps>=?", [c.id, Number(c.body || 0)]);
await addEarned(e, r.visitReward || 1);
return { ok: true, credited: r.visitReward || 1 };
}
if (!SVJ.db) SVJ.load();
const c = J.db.campaigns.find(x => x.id === Number(campaignId) && x.type === 'visits' && x.status === 'active');
if (!c) return { error: 'That visit is no longer available.' };
if ((c.imps || 0) >= Number(c.body || 0)) return { error: 'That pack just filled up.' };
if (SVJ.db.items.some(i => i.cid === c.id && i.email === e)) return { error: 'You already visited this one.' };
SVJ.db.items.push({ cid: c.id, email: e, day: today(), ts: Date.now() });
SVJ.save();
c.imps = (c.imps || 0) + 1; bumpHour(c.id);
if (c.imps >= Number(c.body || 0)) c.status = 'out';
J.save();
addEarned(e, r.visitReward || 1);
return { ok: true, credited: r.visitReward || 1 };
}
async function listCampaigns(owner) { return impl().list(owner); }
// ---- admin ----
async function campaignById(id) {
if (db.enabled()) { const rows = await db.q('SELECT * FROM campaigns WHERE id=?', [Number(id)]); return rows.length ? rowC(rows[0]) : null; }
return J.db.campaigns.find(x => x.id === Number(id)) || null;
}
// every campaign on the site, newest first (owner included so the admin can see whose it is)
async function adminList(limit = 500) {
if (db.enabled()) { const rows = await db.q('SELECT * FROM campaigns ORDER BY id DESC LIMIT ?', [Number(limit) || 500]); return rows.map(r => pubC(rowC(r))); }
return J.db.campaigns.slice().sort((a, b) => b.id - a.id).slice(0, limit).map(pubC);
}
// pause/resume any campaign regardless of owner (NAS mirror rides the normal path)
async function adminSetStatus(id, status) {
const c = await campaignById(id);
if (!c) return { error: 'No such campaign.' };
return setStatus(c.owner, id, status);
}
// admin house ad: same validation and delivery as a member campaign, but no
// credit check and no charge. Budget is a delivery cap (default 100k credits).
async function createHouseCampaign(input) {
const budget = Math.floor(Number(input.budget) || 0);
const v = validate(Object.assign({}, input, { budget: budget > 0 ? budget : 100000 }));
if (v.error) return v;
if (v.c.type === 'featured' && v.c.featuredRun) {
const av = await featuredAvailable(v.c.featuredRun.start, v.c.featuredRun.days);
if (!av.ok) return { error: 'That run is full on ' + av.fullDay + '. Pick a different start day or duration.' };
}
delete v.c.featuredRun;
v.c.house = true;
const campaign = await impl().create(HOUSE_OWNER, 0, v.c);
if (campaign.type === 'visits' || campaign.type === 'featured') { // flat buys: mirror the member path (charge is a no-op for house)
await chargeCredits(campaign.id, v.c.budget);
if (db.enabled()) await db.q("UPDATE campaigns SET status='active' WHERE id=?", [campaign.id]);
else { const cc = J.db.campaigns.find(x => x.id === campaign.id); if (cc) { cc.status = 'active'; J.save(); } }
campaign.status = 'active';
}
if (nas.enabled() && nas.nasKind(campaign.type)) {
try {
const r = await nas.pushCampaign(campaign, { email: HOUSE_OWNER, name: input.advName || 'LinkSpin' });
if (r && r.nasAdId) await setNasLink(campaign.id, r.nasAdId);
} catch (e) { console.error('nas push (house)', campaign.id, e.message); }
}
return { ok: true, campaign };
}
async function setStatus(owner, id, status) {
if (!['active', 'paused'].includes(status)) return { error: 'Bad status.' };
const r = await impl().setStatus(owner, id, status);
// mirror pause/resume to the syndicated NAS ad, if any
if (!r.error && nas.enabled()) {
try {
const c = (await impl().list(owner)).find(x => x.id === Number(id));
const raw = db.enabled() ? rowC((await db.q('SELECT * FROM campaigns WHERE id=?', [Number(id)]))[0])
: J.db.campaigns.find(x => x.id === Number(id));
if (raw && raw.nasAdId) {
if (status === 'paused') await nas.deactivate(raw.nasAdId);
else { // resume: restore remaining impressions for the budget still left
const left = (c.budget - c.spent) * (nas.impressionsPerCredit(c.type) || 0);
if (left > 0) await nas.topUp(raw.nasAdId, Math.floor(left), 30);
}
}
} catch (e) { console.error('nas setStatus', id, e.message); }
}
return r;
}
async function serve(type, opts) { return TYPES.includes(type) ? impl().serve(type, opts) : null; }
async function click(id) { return impl().click(id); }
async function targetOf(id) { return impl().targetOf(id); }
async function dailySweep() { await scheduleSweep(); return impl().dailySweep(); }
async function pendingBurns() { return impl().pendingBurns(); }
async function markBurned(id, tx) { return impl().markBurned(id, tx); }
// credits are pooled per account: when the position a campaign was pinned to runs dry on-chain
// (the shared credited pool covered a different campaign than expected), the burner settles the
// spend from another of the account's funded positions (Jim's #25, 2026-09-15)
async function burnOwner(ref) {
const m = /^campaign-(\d+)$/.exec(String(ref || '')); if (!m) return null;
const c = await campaignById(Number(m[1])); return c && c.owner ? String(c.owner) : null;
}
async function reassignBurn(id, memberId) {
if (db.enabled()) { const r = await db.q('UPDATE burns SET member_id=? WHERE id=? AND burned_tx IS NULL', [Number(memberId), String(id)]); return r.affectedRows > 0; }
if (!J.db) J.load(); const b = J.db.burnsPending.find(x => x.id === id && !x.burnedTx); if (!b) return false; b.memberId = Number(memberId); J.save(); return true;
}
async function unburnedFor(memberId) { return impl().unburned(memberId); }
// daily login bonus: once per day, gentle streak (base 5, +1/day up to +5, cap 10).
async function grantLoginBonus(email) {
const e = String(email || '').toLowerCase(); if (!e) return { granted: 0, streak: 0 };
const day = today(); const yest = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
let lastDay = '', streak = 0;
if (db.enabled()) {
const r = await db.q('SELECT login_day, login_streak FROM earned_credits WHERE email=?', [e]);
if (r.length) { lastDay = r[0].login_day || ''; streak = r[0].login_streak || 0; }
} else { if (!EJ.db) EJ.load(); const rec = EJ.db[e] || {}; lastDay = rec.loginDay || ''; streak = rec.loginStreak || 0; }
if (lastDay === day) return { granted: 0, streak, already: true };
streak = (lastDay === yest) ? streak + 1 : 1;
const amount = 5 + Math.min(streak - 1, 5);
await addEarned(e, amount);
if (db.enabled()) {
await db.q(`INSERT INTO earned_credits (email,balance,granted_welcome,updated,login_day,login_streak) VALUES (?,0,0,?,?,?)
ON DUPLICATE KEY UPDATE login_day=VALUES(login_day), login_streak=VALUES(login_streak), updated=VALUES(updated)`, [e, Date.now(), day, streak]);
} else { if (!EJ.db) EJ.load(); EJ.db[e] = EJ.db[e] || { balance: 0 }; EJ.db[e].loginDay = day; EJ.db[e].loginStreak = streak; EJ.save(); }
return { granted: amount, streak };
}
module.exports = { init, rates, setRates, bannerSizes: () => BANNER_SIZES, sanitizeRich: sanitizeSolo, createCampaign, listCampaigns, setStatus, grantLoginBonus,
createHouseCampaign, adminList, adminSetStatus, campaignById, HOUSE_OWNER,
serve, click, targetOf, dailySweep, scheduleSweep, hoursFor, geoFor, availableCredits, pooledCredits, balances, liveBudgets, earnedBalance, earnedSplit, chargeCredits, grantWelcome, welcomeGranted,
viewStatus, recordView, claimDaily, pendingBurns, markBurned, burnOwner, reassignBurn, unburnedFor, grantMilestones, milestonesOf, spendEarned,
inboxList, inboxOpen, markSoloVisit, claimSoloRead, unreadCount,
serveVideo, chargeVideoView, videoStatus, recordVideoWatch, markVideoSeen, hasWatchedVideoToday, addEarned,
reconcileNas, nasEnabled: () => nas.enabled(), topUpCampaign, serveFeatured, noteFeaturedViews, featuredStats, featuredOccupancy,
serveVisit, visitStatus, completeVisit };