Onsite solo ads: inbox delivery with read rewards, composer, homepage format live

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-05 15:25:00 -05:00
parent 5c945a1b09
commit f6a3befe09
9 changed files with 373 additions and 21 deletions
+174 -3
View File
@@ -24,7 +24,13 @@ function rates() {
welcomeCredits: 25,
dailyViewTarget: 5, // ads to view for the daily claim (spec §8b attention-gated claim)
dailyClaimCredits: 5,
viewDwellSeconds: 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) {
@@ -32,7 +38,7 @@ function setRates(patch) {
return rates();
}
const TYPES = ['banner', 'text', 'login'];
const TYPES = ['banner', 'text', 'login', 'solo'];
const URL_RE = /^https?:\/\/[^\s]+$/i;
const bid = () => crypto.randomBytes(8).toString('hex');
function batchFor(type, r) {
@@ -58,6 +64,15 @@ function validate(input) {
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 = String(input.body || '').trim().slice(0, 1000);
if (!out.title) return { error: 'Solo ads need a subject line.' };
if (out.body.length < 40) return { error: 'Write the message — at least 40 characters.' };
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,
@@ -336,6 +351,161 @@ function addEarned(email, 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 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,
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,
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,
@@ -430,4 +600,5 @@ 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 };
viewStatus, recordView, claimDaily, pendingBurns, markBurned,
inboxList, inboxOpen, claimSoloRead, unreadCount };