Dormant-lead rescue: 10-day warning, 14-day move to the holding tank, Contacted-them reset, dead sponsor links to the tank

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-11 08:06:40 -05:00
parent 9709ce782d
commit 09dc6ed368
8 changed files with 116 additions and 10 deletions
+84 -5
View File
@@ -15,11 +15,13 @@ const CAP_OPEN = 2; // open adoptions per adopter
const TTL_MS = 7 * 86400000; // an adoption's window to convert
const MAX_ADOPTIONS = 2; // per adoptee, lifetime
const MIN_OWN_BUY_CENTS = 2000; // adopter must have bought a $20+ package themselves
const WARN_DAYS = 10; // dormant lead: sponsor warned after this many days without contact
const RESCUE_DAYS = 14; // ...and the lead moves to the tank after this many (warning at least 4 days old)
let DATA_DIR = '.', accounts, chain, messages, mailer, siteUrl = 'https://instantadpay.com';
const J = {
db: { v: 1, nextId: 1, adoptions: [] },
db: { v: 1, nextId: 1, adoptions: [], marks: {} },
FILE: () => path.join(DATA_DIR, 'tank.json'),
load() { try { this.db = Object.assign(this.db, JSON.parse(fs.readFileSync(this.FILE(), 'utf8'))); } catch (e) {} },
save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} },
@@ -27,7 +29,10 @@ const J = {
async open(adopter) { return this.db.adoptions.filter(x => x.status === 'open' && (!adopter || x.adopter === adopter)); },
async countFor(adoptee) { return this.db.adoptions.filter(x => x.adoptee === adoptee && x.status !== 'released').length; },
async setStatus(id, status) { const x = this.db.adoptions.find(r => r.id === id); if (x) { x.status = status; x.closed = Date.now(); this.save(); } },
async recent(n) { return this.db.adoptions.slice(-(n || 100)).reverse(); }
async recent(n) { return this.db.adoptions.slice(-(n || 100)).reverse(); },
async hasRecord(adoptee) { return this.db.adoptions.some(x => x.adoptee === adoptee && x.status !== 'released'); },
async mark(lead) { return this.db.marks[lead] || null; },
async setMark(lead, f) { this.db.marks[lead] = Object.assign(this.db.marks[lead] || {}, f); this.save(); }
};
const D = {
async add(a) {
@@ -40,7 +45,13 @@ const D = {
},
async countFor(adoptee) { const r = await db.q("SELECT COUNT(*) n FROM adoptions WHERE adoptee=? AND status<>'released'", [adoptee]); return Number(r[0].n); },
async setStatus(id, status) { await db.q('UPDATE adoptions SET status=?, closed=? WHERE id=?', [status, Date.now(), Number(id)]); },
async recent(n) { return (await db.q('SELECT * FROM adoptions ORDER BY id DESC LIMIT ?', [Number(n) || 100])).map(rowA); }
async recent(n) { return (await db.q('SELECT * FROM adoptions ORDER BY id DESC LIMIT ?', [Number(n) || 100])).map(rowA); },
async hasRecord(adoptee) { const r = await db.q("SELECT COUNT(*) n FROM adoptions WHERE adoptee=? AND status<>'released'", [adoptee]); return Number(r[0].n) > 0; },
async mark(lead) { const r = await db.q('SELECT * FROM lead_marks WHERE lead=?', [lead]); return r[0] ? { sponsor: r[0].sponsor, contacted: Number(r[0].contacted_ts || 0), warned: Number(r[0].warned_ts || 0) } : null; },
async setMark(lead, f) {
const cur = (await this.mark(lead)) || {}; const m = Object.assign(cur, f);
await db.q('INSERT INTO lead_marks (lead,sponsor,contacted_ts,warned_ts) VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE sponsor=VALUES(sponsor), contacted_ts=VALUES(contacted_ts), warned_ts=VALUES(warned_ts)', [lead, m.sponsor || null, m.contacted || null, m.warned || null]);
}
};
const rowA = r => ({ id: r.id, adoptee: r.adoptee, adopter: r.adopter, ts: Number(r.ts), expires: Number(r.expires), status: r.status, note: r.note || null, closed: r.closed ? Number(r.closed) : null });
const impl = () => db.enabled() ? D : J;
@@ -143,7 +154,8 @@ async function sweep() {
await accounts.setSponsorRef(ad.adoptee, '');
await impl().setStatus(ad.id, 'expired'); back++;
}
return { done, back, kept };
const r = await rescueSweep();
return { done, back, kept, warned: r.warned, rescued: r.rescued, dead: r.dead };
}
// pay-it-forward gift record: the sponsor already sent POL wallet-to-wallet; we
@@ -167,6 +179,73 @@ async function recordGift(fromEmail, toEmail, tx, pol) {
return { ok: true };
}
// ── dormant-lead rescue ──
// a free lead (no wallet, no purchase) whose sponsor has not written to them, on
// site or by marking "contacted", for WARN_DAYS gets a warning to the sponsor;
// at RESCUE_DAYS (warning at least 4 days old) the lead moves to the tank.
// Anyone with an adoption record is left to the tank's own window.
async function sponsorOf(acct) {
const t = String(acct.sponsorRef || ''); if (!t) return null;
return (await accounts.byUsername(t)) || (await accounts.byCode(t)) || (/^\d+$/.test(t) ? await accounts.byMemberId(Number(t)) : null) || null;
}
async function lastContact(sponsorEmail, lead) {
const m = await impl().mark(lead.email);
const chat = messages ? await messages.lastFrom(sponsorEmail, lead.email) : 0;
return Math.max(lead.created || 0, chat, (m && m.contacted) || 0);
}
// what the coach card shows the sponsor about each free direct
async function rescueInfo(sponsorEmail, lead) {
if (!lead || lead.memberId || lead.address) return null;
if (await impl().hasRecord(lead.email)) return null;
const since = await lastContact(sponsorEmail, lead);
const days = Math.floor((Date.now() - since) / 86400000);
const m = await impl().mark(lead.email);
return { days, warned: !!(m && m.warned), rescueInDays: Math.max(0, RESCUE_DAYS - days), unreached: days >= WARN_DAYS };
}
async function markContacted(sponsorEmail, leadEmail) {
const s = String(sponsorEmail || '').toLowerCase(), l = String(leadEmail || '').toLowerCase();
const owner = await accounts.byEmail(s), lead = await accounts.byEmail(l);
if (!owner || !lead) return { error: 'No such member.' };
const toks = [owner.code, owner.username, owner.memberId ? String(owner.memberId) : null].filter(Boolean).map(String);
if (!toks.includes(String(lead.sponsorRef || ''))) return { error: 'That member is not in your line.' };
await impl().setMark(l, { sponsor: s, contacted: Date.now(), warned: 0 });
return { ok: true };
}
async function rescueSweep() {
const now = Date.now();
let warned = 0, rescued = 0, dead = 0;
for (const a of await accounts.listAll(5000)) {
if (a.memberId || a.address || !a.sponsorRef) continue; // bound, wallet, or already in the tank
if (await impl().hasRecord(a.email)) continue; // adopted / rescued before: the tank's window rules
const sp = await sponsorOf(a);
if (!sp) { // dead sponsor link: nobody can ever coach them, straight to the tank after a day
if (now - (a.created || 0) < 86400000) continue;
await accounts.setSponsorRef(a.email, '');
await impl().add({ adoptee: a.email, adopter: 'dead:' + a.sponsorRef, ts: now, expires: now, status: 'rescued', note: 'sponsor link ' + a.sponsorRef + ' resolves to nobody' });
dead++; continue;
}
if (sp.email === a.email) continue;
const since = await lastContact(sp.email, a);
const days = (now - since) / 86400000;
if (days < WARN_DAYS) continue;
const m = (await impl().mark(a.email)) || {};
if (!m.warned || m.warned < since) {
await impl().setMark(a.email, { sponsor: sp.email, warned: now });
if (mailer && mailer.hasKey()) { try { await mailer.send(sp.email, 'InstantAdPay: you have not reached ' + nameOf(a),
nameOf(a) + ' joined through your link ' + Math.floor(days) + ' days ago and there is no message from you to them on the site.\n\nContact them, or mark "Contacted them" on their row in Members > My line if you reached them by phone or text. If nothing happens in the next ' + (RESCUE_DAYS - WARN_DAYS) + ' days they move to the holding tank so another member can help them.\n\n' + siteUrl + '/my#line'); } catch (e) {} }
warned++; continue;
}
if (days >= RESCUE_DAYS && now - m.warned >= (RESCUE_DAYS - WARN_DAYS) * 86400000) {
await accounts.setSponsorRef(a.email, '');
await impl().add({ adoptee: a.email, adopter: sp.email, ts: now, expires: now, status: 'rescued', note: 'rescued from ' + nameOf(sp) + ' after ' + Math.floor(days) + ' days without contact' });
if (mailer && mailer.hasKey()) { try { await mailer.send(sp.email, 'InstantAdPay: ' + nameOf(a) + ' moved to the holding tank',
nameOf(a) + ' joined through your link ' + Math.floor(days) + ' days ago and was never contacted, so they have moved to the holding tank where another member can pick them up. Nothing on-chain changed. Your other referrals are unaffected.\n\n' + siteUrl + '/my#line'); } catch (e) {} }
rescued++;
}
}
return { warned, rescued, dead };
}
async function adminView() {
const recent = await impl().recent(200);
const names = {};
@@ -174,4 +253,4 @@ async function adminView() {
return { waiting: await waiting(), adoptions: recent.map(ad => Object.assign({}, ad, { adopteeName: names[ad.adoptee], adopterName: names[ad.adopter] })), cap: CAP_OPEN, ttlDays: TTL_MS / 86400000 };
}
module.exports = { init, view, adopt, release, sweep, adminView, waiting, hasOwnBuy, recordGift, CAP_OPEN, TTL_MS };
module.exports = { init, view, adopt, release, sweep, rescueSweep, rescueInfo, markContacted, adminView, waiting, hasOwnBuy, recordGift, CAP_OPEN, TTL_MS, WARN_DAYS, RESCUE_DAYS };