// 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'); 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. 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: 5, // 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 }, saved); } function setRates(patch) { fs.writeFileSync(RATES_FILE(), JSON.stringify(Object.assign(rates(), patch), null, 2)); return rates(); } const TYPES = ['banner', 'text', 'login', 'solo']; // 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', 'h3', 'h4', 'blockquote', 'div', 'span']); function sanitizeSolo(html) { const src = String(html || '').replace(//g, '').slice(0, 12000).replace(//g, ''); const keep = []; const tokenized = src.replace(/<\s*(\/?)\s*([a-zA-Z0-9]+)((?:[^>"']|"[^"]*"|'[^']*')*)>/g, (m, close, tag, attrs) => { tag = tag.toLowerCase(); let out = ''; if (tag === 'a') { if (close) out = ''; else { const hm = /href\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(attrs || ''); const href = (hm && (hm[1] || hm[2])) || ''; out = URL_RE.test(href) ? '' : ''; } } else if (SOLO_TAGS.has(tag)) out = '<' + (close ? '/' : '') + tag + '>'; keep.push(out); return '' + (keep.length - 1) + ''; }); return tokenized.replace(//g, '>') .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)://' }; const budget = Math.floor(Number(input.budget) || 0); if (budget < 10) return { error: 'Minimum budget is 10 credits.' }; const out = { type, name, targetUrl, budget, imageUrl: null, title: null, body: null }; 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)://' }; } // 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).' }; } return { ok: true, c: out }; } const pubC = c => ({ id: c.id, type: c.type, name: c.name, 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), imps: c.imps || 0, clicks: c.clicks || 0, status: c.status, created: c.created }); 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 }); // ---- 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 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 pool = this.db.campaigns.filter(c => c.type === type && c.status === 'active' && (!ex || c.owner !== ex)); if (!pool.length) return null; const c = pool[Math.floor(Math.random() * pool.length)]; c.imps += 1; const b = batchFor(type, r); if (b) { c.batchImps += 1; if (c.batchImps >= b.n) { c.batchImps = 0; // earned pool pays first (spec §8b); only the remainder burns on-chain if (await spendEarned(c.owner, b.cr)) { c.spent += b.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) + b.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) continue; c.lastDayCharged = today; c.accrued = (c.accrued || 0) + r.loginCreditsPerDay; 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, 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, 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 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) VALUES (?,?,?,?,?,?,?,?,?,?,?)`, [owner, memberId, c.type, c.name, c.targetUrl, c.imageUrl, c.title, c.body, c.budget, Date.now(), c.ctaLabel || 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 rows = await db.q('SELECT * FROM campaigns WHERE type=? AND status=\'active\' AND owner_email<>? ORDER BY RAND() LIMIT 1', [type, ex]); 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]); 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) { // earned pool pays first (spec §8b); only the remainder burns on-chain if (await spendEarned(c.owner, b.cr)) { await db.q('UPDATE campaigns SET spent=spent+? WHERE id=?', [b.cr, c.id]); await db.q('UPDATE campaigns SET status=\'out\' WHERE id=? AND status=\'active\' AND spent+accrued>=budget', [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=?', [b.cr, c.id]); await this.rollBurn(c.id, r); } } } 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.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 upd = await db.q(`UPDATE campaigns SET last_day_charged=?, accrued=accrued+? WHERE type='login' AND status='active' AND (last_day_charged IS NULL OR last_day_charged<>?)`, [today, r.loginCreditsPerDay, today]); if (upd.affectedRows) { const rows = await db.q('SELECT id FROM campaigns WHERE type=\'login\' AND last_day_charged=?', [today]); for (const row of rows) await this.rollBurn(row.id, r); } return upd.affectedRows || 0; }, 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 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 async function spendEarned(email, amount) { const e = String(email || '').toLowerCase(); if (!e || !amount) return false; if (db.enabled()) { const r = await db.q('UPDATE earned_credits SET balance=balance-? WHERE email=? AND balance>=?', [amount, e, amount]); return !!r.affectedRows; } if (!EJ.db) EJ.load(); const rec = EJ.db[e]; if (!rec || rec.balance < amount) return false; rec.balance -= amount; EJ.save(); return true; } function addEarned(email, amount) { const e = String(email || '').toLowerCase(); if (db.enabled()) { return db.q(`INSERT INTO earned_credits (email,balance,granted_welcome,updated) VALUES (?,?,0,?) ON DUPLICATE KEY UPDATE balance=balance+VALUES(balance), updated=VALUES(updated)`, [e, amount, Date.now()]); } if (!EJ.db) EJ.load(); EJ.db[e] = EJ.db[e] || { balance: 0 }; EJ.db[e].balance += amount; 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) { 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=?) ORDER BY c.created LIMIT ?`, [e, cost, e, 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]); 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)) 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; 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) { const e = String(email || '').toLowerCase(); await deliverSolos(e); 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, 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, url: '/api/ads/click/' + i.cid, delivered: i.delivered, rewarded: !!i.rewarded, dwell: r.soloReadDwellSeconds || 10, reward: r.soloReadCredits || 2 }; } 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.' }; 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.' }; 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); 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; } } return { views, target: r.dailyViewTarget, claimCredits: r.dailyClaimCredits, dwell: r.viewDwellSeconds, claimed, earned: await earnedBalance(e) }; } 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= 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); } 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 today’s 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 today’s ads first, then claim.' }; rec.claimed = true; VJ.save(); } await addEarned(e, r.dailyClaimCredits); return { ok: true, credited: r.dailyClaimCredits, 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); return Math.max(0, onchain - await impl().unburned(memberId)); } async function createCampaign(owner, memberId, input) { const v = validate(input); if (v.error) return v; const purchased = await availableCredits(memberId); const earned = v.c.type !== 'login' ? await earnedBalance(owner) : 0; // earned pool: baseline formats only const avail = purchased + earned; if (v.c.budget > avail) return { error: 'Budget exceeds your available credits (' + avail + (earned ? ', including ' + earned + ' earned' : '') + ').' }; return { ok: true, campaign: await impl().create(owner, memberId, v.c) }; } async function listCampaigns(owner) { return impl().list(owner); } async function setStatus(owner, id, status) { if (!['active', 'paused'].includes(status)) return { error: 'Bad status.' }; return impl().setStatus(owner, id, status); } 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() { return impl().dailySweep(); } async function pendingBurns() { return impl().pendingBurns(); } async function markBurned(id, tx) { return impl().markBurned(id, tx); } module.exports = { init, rates, setRates, createCampaign, listCampaigns, setStatus, serve, click, targetOf, dailySweep, availableCredits, earnedBalance, grantWelcome, viewStatus, recordView, claimDaily, pendingBurns, markBurned, inboxList, inboxOpen, claimSoloRead, unreadCount };