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:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user