LinkSpin test area: InstantAdPay engine fork rebranded, network registry, sponsor carry-over with engine activation and claim window, rotator with /r/ redirects, link-domain mini-sites
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
'use strict';
|
||||
// Holding tank (Marty, 2026-09-11): free members who arrived with no sponsor wait
|
||||
// here, and a member who has bought their own $20+ package can adopt one:
|
||||
// first come, at most two open adoptions at a time, an adoption falls back into
|
||||
// the tank after 7 days if the person never linked a wallet or bought, and a
|
||||
// person can be adopted twice at most before they stay wherever they are.
|
||||
// Members can also release one of their own free referrals into the tank
|
||||
// (pay it forward). The contract binds sponsor at first purchase, so every
|
||||
// hand-off here is a site record until then.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const db = require('./db');
|
||||
|
||||
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 (dropped adoptions count too, since 2026-09-13)
|
||||
const HOLD_MS = 3 * 24 * 3600 * 1000; // an adoption is a commitment: no releasing someone you picked up less than 3 days ago (Marty, 2026-09-13)
|
||||
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://linkspin-test.saasy.top';
|
||||
|
||||
const J = {
|
||||
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) {} },
|
||||
async add(a) { const row = Object.assign({ id: this.db.nextId++ }, a); this.db.adoptions.push(row); this.save(); return row; },
|
||||
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 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) {
|
||||
const r = await db.q('INSERT INTO adoptions (adoptee,adopter,ts,expires,status,note) VALUES (?,?,?,?,?,?)', [a.adoptee, a.adopter, a.ts, a.expires, a.status, a.note || null]);
|
||||
return Object.assign({ id: r.insertId }, a);
|
||||
},
|
||||
async open(adopter) {
|
||||
const rows = adopter ? await db.q("SELECT * FROM adoptions WHERE status='open' AND adopter=?", [adopter]) : await db.q("SELECT * FROM adoptions WHERE status='open'");
|
||||
return rows.map(rowA);
|
||||
},
|
||||
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 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;
|
||||
|
||||
function init(opts) { DATA_DIR = opts.dataDir; accounts = opts.accounts; chain = opts.chain; messages = opts.messages; mailer = opts.mailer; if (opts.site) siteUrl = opts.site; J.load(); }
|
||||
|
||||
const mask = e => String(e || '').replace(/^(.).*(@.*)$/, '$1***$2');
|
||||
const nameOf = a => a.username ? '@' + a.username : (a.memberId ? 'member #' + a.memberId : mask(a.email));
|
||||
const inTank = a => !a.sponsorRef && !a.memberId; // arrived with no sponsor (or fell back), still free
|
||||
|
||||
// waiting list: newest sign-in first so a live one is easy to spot
|
||||
async function waiting() {
|
||||
const all = await accounts.listAll(2000);
|
||||
return all.filter(inTank).map(a => ({ email: a.email, name: nameOf(a), username: a.username || null, joined: a.created, lastSeen: a.lastSeen || 0, wallet: false }))
|
||||
.sort((a, b) => (b.lastSeen || b.joined) - (a.lastSeen || a.joined));
|
||||
}
|
||||
|
||||
// has this member bought a $20+ package themselves? (the qualifying-for-yourself buy)
|
||||
function hasOwnBuy(memberId) {
|
||||
if (!memberId) return false;
|
||||
for (const ev of chain.recentEvents(200000)) if (ev.type === 'Purchase' && ev.buyerId === memberId && Number(ev.priceCents) >= MIN_OWN_BUY_CENTS) return true;
|
||||
return false;
|
||||
}
|
||||
async function eligibility(email) {
|
||||
const a = await accounts.byEmail(email);
|
||||
if (!a) return { ok: false, reason: 'Sign in first.' };
|
||||
if (!a.memberId) return { ok: false, reason: 'Switch on payouts and buy your first $20 package to adopt from the tank.' };
|
||||
if (!hasOwnBuy(a.memberId)) return { ok: false, reason: 'Buy your own $20 or more package first. Adopting is for members who have made that move themselves.' };
|
||||
const open = await impl().open(a.email);
|
||||
if (open.length >= CAP_OPEN) return { ok: false, reason: 'You have ' + CAP_OPEN + ' open adoptions. Help one of them link a wallet or buy, and a slot frees up.', full: true };
|
||||
return { ok: true, account: a, open };
|
||||
}
|
||||
|
||||
async function view(email) {
|
||||
const e = String(email || '').toLowerCase();
|
||||
const el = await eligibility(e);
|
||||
const mine = [];
|
||||
for (const ad of await impl().open(e)) {
|
||||
const a = await accounts.byEmail(ad.adoptee);
|
||||
mine.push({ id: ad.id, name: a ? nameOf(a) : mask(ad.adoptee), email: ad.adoptee, ts: ad.ts, expires: ad.expires, lastSeen: a ? (a.lastSeen || 0) : 0, wallet: !!(a && a.address), address: (a && a.address) || null, bought: !!(a && a.memberId) });
|
||||
}
|
||||
return { eligible: el.ok, reason: el.ok ? '' : el.reason, cap: CAP_OPEN, ttlDays: TTL_MS / 86400000, waiting: await waiting(), mine };
|
||||
}
|
||||
|
||||
async function adopt(adopterEmail, who, note) {
|
||||
const e = String(adopterEmail || '').toLowerCase();
|
||||
const el = await eligibility(e);
|
||||
if (!el.ok) return { error: el.reason };
|
||||
const me = el.account;
|
||||
const key = String(who || '').trim().toLowerCase().replace(/^@/, '');
|
||||
const list = await waiting();
|
||||
const target = list.find(w => (w.username && w.username.toLowerCase() === key) || w.email === key);
|
||||
if (!target) return { error: 'That member is no longer in the tank.' };
|
||||
if (target.email === e) return { error: 'That is you.' };
|
||||
if (await impl().countFor(target.email) >= MAX_ADOPTIONS) return { error: 'That member has been adopted twice already and stays where they are.' };
|
||||
const token = me.username || me.code || String(me.memberId);
|
||||
const r = await accounts.setSponsorRef(target.email, token);
|
||||
if (r.error) return r;
|
||||
const now = Date.now();
|
||||
const text = String(note || '').trim().slice(0, 600) || ('Hi, I am ' + nameOf(me) + '. You joined LinkSpin without a sponsor, so I picked you up from the holding tank. I will walk you through the first three steps whenever you are ready. Reply here.');
|
||||
const ad = await impl().add({ adoptee: target.email, adopter: e, ts: now, expires: now + TTL_MS, status: 'open', note: text });
|
||||
try { await messages.sendChat(me.memberId || 0, e, target.email, text); } catch (err) {}
|
||||
if (mailer && mailer.hasKey()) {
|
||||
try {
|
||||
await mailer.send(target.email, nameOf(me) + ' is now your sponsor on LinkSpin',
|
||||
'You joined LinkSpin without a sponsor. ' + nameOf(me) + ' has picked you up from the holding tank and is your sponsor now, which means a real person to walk you through the first steps.\n\nTheir message:\n\n' + text + '\n\nReply in your member area: ' + siteUrl + '/my#messages\n\nLinkSpin');
|
||||
} catch (err) {}
|
||||
}
|
||||
return { ok: true, adoption: ad, name: target.name, adopterName: nameOf(me), adopteeName: target.name };
|
||||
}
|
||||
|
||||
// pay it forward: give one of your own free referrals to the tank
|
||||
async function release(ownerEmail, directEmail) {
|
||||
const o = String(ownerEmail || '').toLowerCase(), d = String(directEmail || '').toLowerCase();
|
||||
const owner = await accounts.byEmail(o), direct = await accounts.byEmail(d);
|
||||
if (!owner || !direct) 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(direct.sponsorRef || ''))) return { error: 'That member is not in your line.' };
|
||||
if (direct.memberId) return { error: 'That member has already bought; on-chain sponsorship cannot move.' };
|
||||
// picked up from the tank recently? then it is not theirs to drop yet (Bradley churned three in 80 seconds, 2026-09-13)
|
||||
const openAds = (await impl().open(null)).filter(ad => ad.adoptee === d);
|
||||
const young = openAds.find(ad => Date.now() - ad.ts < HOLD_MS);
|
||||
if (young) {
|
||||
const hrs = Math.ceil((HOLD_MS - (Date.now() - young.ts)) / 3600000);
|
||||
return { error: 'You picked ' + nameOf(direct) + ' up from the tank ' + Math.max(1, Math.round((Date.now() - young.ts) / 3600000)) + ' hour(s) ago. An adoption is a commitment: message them, help them link a wallet, and if it goes nowhere you can release them in ' + hrs + ' hour(s).' };
|
||||
}
|
||||
const r = await accounts.setSponsorRef(d, '');
|
||||
if (r.error) return r;
|
||||
// an open adoption of this person closes as dropped, and a dropped adoption still counts toward their two
|
||||
let closed = 0;
|
||||
for (const ad of openAds) { await impl().setStatus(ad.id, 'dropped'); closed++; }
|
||||
if (!closed) await impl().add({ adoptee: d, adopter: o, ts: Date.now(), expires: Date.now(), status: 'released', note: 'released to the tank' });
|
||||
return { ok: true, adopted: closed > 0, ownerName: nameOf(owner), memberName: nameOf(direct) };
|
||||
}
|
||||
|
||||
// open adoptions past their window: converted ones close as done; the rest fall
|
||||
// back into the tank unless the person has been adopted twice already
|
||||
async function sweep() {
|
||||
const now = Date.now();
|
||||
let done = 0, back = 0, kept = 0;
|
||||
for (const ad of await impl().open(null)) {
|
||||
if (ad.expires > now) continue;
|
||||
const a = await accounts.byEmail(ad.adoptee);
|
||||
if (!a) { await impl().setStatus(ad.id, 'expired'); continue; }
|
||||
if (a.address || a.memberId) { await impl().setStatus(ad.id, 'done'); done++; continue; }
|
||||
if (await impl().countFor(ad.adoptee) >= MAX_ADOPTIONS) { await impl().setStatus(ad.id, 'expired'); kept++; continue; }
|
||||
await accounts.setSponsorRef(ad.adoptee, '');
|
||||
await impl().setStatus(ad.id, 'expired'); back++;
|
||||
}
|
||||
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
|
||||
// only log the hash and tell the recipient. Recipient must be in the giver's line.
|
||||
async function recordGift(fromEmail, toEmail, tx, pol) {
|
||||
const f = String(fromEmail || '').toLowerCase(), t = String(toEmail || '').toLowerCase();
|
||||
const giver = await accounts.byEmail(f), to = await accounts.byEmail(t);
|
||||
if (!giver || !to) return { error: 'No such member.' };
|
||||
const toks = [giver.code, giver.username, giver.memberId ? String(giver.memberId) : null].filter(Boolean).map(String);
|
||||
const adopted = (await impl().open(f)).some(ad => ad.adoptee === t);
|
||||
if (!toks.includes(String(to.sponsorRef || '')) && !adopted) return { error: 'That member is not in your line.' };
|
||||
if (!/^0x[0-9a-fA-F]{64}$/.test(String(tx || ''))) return { error: 'Transaction hash missing.' };
|
||||
const amount = Number(pol) || 0;
|
||||
const row = { adoptee: t, adopter: f, ts: Date.now(), expires: Date.now(), status: 'gift', note: 'PIF ' + amount + ' POL ' + tx };
|
||||
await impl().add(row);
|
||||
const cc = chain.getConfig ? chain.getConfig() : {};
|
||||
const link = ((cc.explorer || 'https://polygonscan.com').replace(/\/+$/, '')) + '/tx/' + tx;
|
||||
const text = nameOf(giver) + ' just sent ' + amount + ' POL to your wallet so you can buy your first package. It is already there: open Buy packages when you are ready. Proof: ' + link;
|
||||
try { await messages.sendChat(giver.memberId || 0, f, t, text); } catch (e) {}
|
||||
if (mailer && mailer.hasKey()) { try { await mailer.send(t, nameOf(giver) + ' sent you POL for your first LinkSpin package', text + '\n\n' + siteUrl + '/my#buy'); } catch (e) {} }
|
||||
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, 'LinkSpin: 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, 'LinkSpin: ' + 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 = {};
|
||||
for (const ad of recent) for (const em of [ad.adoptee, ad.adopter]) if (!(em in names)) { const a = await accounts.byEmail(em); names[em] = a ? nameOf(a) : em; }
|
||||
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, rescueSweep, rescueInfo, markContacted, adminView, waiting, hasOwnBuy, recordGift, CAP_OPEN, TTL_MS, WARN_DAYS, RESCUE_DAYS };
|
||||
Reference in New Issue
Block a user