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:
+1
-1
@@ -438,7 +438,7 @@ async function getChatSettings(email) {
|
|||||||
return { available: a ? a.chatAvailable !== false : true, mutes: await impl().getMutes(String(email || '').toLowerCase()) };
|
return { available: a ? a.chatAvailable !== false : true, mutes: await impl().getMutes(String(email || '').toLowerCase()) };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { init, signup, login, ensure, byEmail, byAddress, byCode, byUsername, listAll, setSponsorRef,
|
module.exports = { init, signup, login, ensure, byEmail, byAddress, byCode, byUsername, byMemberId: id => impl().byMemberId(Number(id) || 0), listAll, setSponsorRef,
|
||||||
setUsername, setMemberId, namesForMembers, listByReferrer, downline, linkWallet, count,
|
setUsername, setMemberId, namesForMembers, listByReferrer, downline, linkWallet, count,
|
||||||
setLineBanner: (e, b, t) => impl().setLineBanner(String(e || '').toLowerCase(), b, t),
|
setLineBanner: (e, b, t) => impl().setLineBanner(String(e || '').toLowerCase(), b, t),
|
||||||
setWallOffers: (e, j) => impl().setWallOffers(String(e || '').toLowerCase(), j),
|
setWallOffers: (e, j) => impl().setWallOffers(String(e || '').toLowerCase(), j),
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ FACTS:
|
|||||||
- Live formats: display banners (per impression), text ads (per impression), full-screen LOGIN ADS (per day: right after a member signs in they land on a sponsor interstitial — they click "Open Ad", the advertiser's page opens in a NEW tab, a countdown runs on the interstitial, and at zero a "Go to dashboard" button appears. Just a CTA link is enough; an optional banner image can be the clickable creative. No framing requirement since it opens in its own tab), WATCH-TO-EARN VIDEO ADS (advertiser uploads an MP4/WebM or gives a direct https .mp4/.webm link and picks a required watch length — 10s/30s/60s — which sets the per-view price; viewers watch in an escape-proof player under Earn credits > Watch videos, the watch time is enforced on the server clock, and they earn credits per completed watch; you never see your own videos), and solo ads. Banner ads also require a size (standard IAB sizes like 728x90, 300x250). Coming: featured rotation with disclosed rotation size, verified-visit packs.
|
- Live formats: display banners (per impression), text ads (per impression), full-screen LOGIN ADS (per day: right after a member signs in they land on a sponsor interstitial — they click "Open Ad", the advertiser's page opens in a NEW tab, a countdown runs on the interstitial, and at zero a "Go to dashboard" button appears. Just a CTA link is enough; an optional banner image can be the clickable creative. No framing requirement since it opens in its own tab), WATCH-TO-EARN VIDEO ADS (advertiser uploads an MP4/WebM or gives a direct https .mp4/.webm link and picks a required watch length — 10s/30s/60s — which sets the per-view price; viewers watch in an escape-proof player under Earn credits > Watch videos, the watch time is enforced on the server clock, and they earn credits per completed watch; you never see your own videos), and solo ads. Banner ads also require a size (standard IAB sizes like 728x90, 300x250). Coming: featured rotation with disclosed rotation size, verified-visit packs.
|
||||||
- WALLETS + BUYING POL (Training > Wallets and buying POL, /wallets, members only): preferred MetaMask (recommended; extra accounts for Qualified Start), Phantom, SafePal, Coinbase Wallet; Trust works but blocks buys spending most of its POL (keep ~2x). MoonPay flow: connect wallet, Buy packages > "Buy POL with a card" opens MoonPay with POL on Polygon + the member's address prefilled; card/Apple Pay/Google Pay; first-time ID check; minimum order ~$30; buy package cost + 2-3 POL for fees; POL arrives in minutes; then buy. Exchanges: withdraw POL on the Polygon network. Never MATIC on Ethereum, never share the recovery phrase.
|
- WALLETS + BUYING POL (Training > Wallets and buying POL, /wallets, members only): preferred MetaMask (recommended; extra accounts for Qualified Start), Phantom, SafePal, Coinbase Wallet; Trust works but blocks buys spending most of its POL (keep ~2x). MoonPay flow: connect wallet, Buy packages > "Buy POL with a card" opens MoonPay with POL on Polygon + the member's address prefilled; card/Apple Pay/Google Pay; first-time ID check; minimum order ~$30; buy package cost + 2-3 POL for fees; POL arrives in minutes; then buy. Exchanges: withdraw POL on the Polygon network. Never MATIC on Ethereum, never share the recovery phrase.
|
||||||
- HOLDING TANK (Members > My line > Holding tank card): free members who joined with no sponsor wait there; a member who has switched on payouts AND bought their own $20+ package can Adopt one (first come, max 2 open adoptions, 7-day window; if the person never links a wallet or buys, they fall back into the tank; a person can be adopted twice at most). Adopting sets the sponsor, opens a chat and emails the member; their first purchase then binds to the adopter on-chain. Members can also "Release to tank" one of their own free referrals (pay it forward). Admin sees the tank under Members.
|
- HOLDING TANK (Members > My line > Holding tank card): free members who joined with no sponsor wait there; a member who has switched on payouts AND bought their own $20+ package can Adopt one (first come, max 2 open adoptions, 7-day window; if the person never links a wallet or buys, they fall back into the tank; a person can be adopted twice at most). Adopting sets the sponsor, opens a chat and emails the member; their first purchase then binds to the adopter on-chain. Members can also "Release to tank" one of their own free referrals (pay it forward). Admin sees the tank under Members.
|
||||||
|
- DORMANT-LEAD RESCUE: a FREE referral (no wallet, no purchase) with no message from their sponsor for 10 days triggers a warning email + dashboard flag to the sponsor ("unreached, tank in N days"); at 14 days (warning at least 4 days old) the lead moves to the holding tank and the sponsor is told. Sponsor resets the clock with a chat, a Nudge, or the "Contacted them" button (for phone/text contact). Leads whose sponsor link resolves to nobody go to the tank after a day. Nothing on-chain moves; anyone bound by a purchase never moves.
|
||||||
- PIF (pay it forward) button: on a free direct or an adopted member who has linked a wallet, the sponsor taps PIF, enters an amount (suggested: the $20 package plus fees), and their OWN wallet app opens with the member's address prefilled; the POL goes wallet to wallet. The site never touches the funds; it only logs the transaction and tells the recipient with a Polygonscan link. The gift is theirs; nothing forces a purchase.
|
- PIF (pay it forward) button: on a free direct or an adopted member who has linked a wallet, the sponsor taps PIF, enters an amount (suggested: the $20 package plus fees), and their OWN wallet app opens with the member's address prefilled; the POL goes wallet to wallet. The site never touches the funds; it only logs the transaction and tells the recipient with a Polygonscan link. The gift is theirs; nothing forces a purchase.
|
||||||
- FOUNDING WEEK / PRE-LAUNCH (Training > Founding week checklist, /launch, members only): eight items read live from the account: username, wallet linked, payouts on, level 2 qualified (2 buyers of $20+, or Qualified Start with 2 linked positions), the leader play = level 3 (5 qualifying buyers, up to 5 linked positions; then buy from the main wallet), line banner, links + play chosen (self-marked), first two placed. Reason: unqualified levels pass up, so leaders qualify BEFORE their teams' teams buy. Countdown shows when admin sets launchAt. Never call the site 'pre-launch' publicly: it is live and paying.
|
- FOUNDING WEEK / PRE-LAUNCH (Training > Founding week checklist, /launch, members only): eight items read live from the account: username, wallet linked, payouts on, level 2 qualified (2 buyers of $20+, or Qualified Start with 2 linked positions), the leader play = level 3 (5 qualifying buyers, up to 5 linked positions; then buy from the main wallet), line banner, links + play chosen (self-marked), first two placed. Reason: unqualified levels pass up, so leaders qualify BEFORE their teams' teams buy. Countdown shows when admin sets launchAt. Never call the site 'pre-launch' publicly: it is live and paying.
|
||||||
- SCHEDULING (2026-09-11): any campaign except featured can take an optional start and end time (local time) in the New campaign form; solo ads label it "Send from" (inbox deliveries begin then). A scheduled campaign shows "scheduled" until it starts; at the end it shows "ended" and the unspent budget returns to Available. Banner/text scheduled campaigns join the partner network at their start time. There is NO dayparting (hours-of-day targeting) by design; the daily cap paces budgets. Each campaign row shows a small views-by-hour chart (on-site views, viewer's local time, last 7 days).
|
- SCHEDULING (2026-09-11): any campaign except featured can take an optional start and end time (local time) in the New campaign form; solo ads label it "Send from" (inbox deliveries begin then). A scheduled campaign shows "scheduled" until it starts; at the end it shows "ended" and the unspent budget returns to Available. Banner/text scheduled campaigns join the partner network at their start time. There is NO dayparting (hours-of-day targeting) by design; the daily cap paces budgets. Each campaign row shows a small views-by-hour chart (on-site views, viewer's local time, last 7 days).
|
||||||
|
|||||||
@@ -146,6 +146,9 @@ async function bootstrap() {
|
|||||||
note VARCHAR(600) NULL, closed BIGINT NULL,
|
note VARCHAR(600) NULL, closed BIGINT NULL,
|
||||||
INDEX (adoptee), INDEX (adopter), INDEX (status)
|
INDEX (adoptee), INDEX (adopter), INDEX (status)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); // holding-tank adoptions
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); // holding-tank adoptions
|
||||||
|
await q(`CREATE TABLE IF NOT EXISTS lead_marks (
|
||||||
|
lead VARCHAR(190) PRIMARY KEY, sponsor VARCHAR(190) NULL, contacted_ts BIGINT NULL, warned_ts BIGINT NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); // dormant-lead rescue: manual "contacted" marks + warning sent
|
||||||
await q(`CREATE TABLE IF NOT EXISTS prospects (
|
await q(`CREATE TABLE IF NOT EXISTS prospects (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
owner_email VARCHAR(190) NOT NULL,
|
owner_email VARCHAR(190) NOT NULL,
|
||||||
|
|||||||
+8
-1
@@ -77,6 +77,13 @@ async function sendChat(fromMember, fromEmail, toEmail, body) {
|
|||||||
return { id, sent: now };
|
return { id, sent: now };
|
||||||
}
|
}
|
||||||
// messages between two members (both directions), id > afterId, oldest→newest
|
// messages between two members (both directions), id > afterId, oldest→newest
|
||||||
|
// last message from one member to another, any kind (0 if never)
|
||||||
|
async function lastFrom(fromEmail, toEmail) {
|
||||||
|
const f = String(fromEmail || '').toLowerCase(), t = String(toEmail || '').toLowerCase();
|
||||||
|
if (db.enabled()) { const r = await db.q('SELECT MAX(sent) s FROM sponsor_messages WHERE from_email=? AND to_email=?', [f, t]); return Number((r[0] && r[0].s) || 0); }
|
||||||
|
if (!J.db) J.load();
|
||||||
|
return J.db.items.filter(i => i.fromEmail === f && i.toEmail === t).reduce((m, i) => Math.max(m, i.sent || 0), 0);
|
||||||
|
}
|
||||||
async function thread(aEmail, bEmail, afterId = 0, limit = 300) {
|
async function thread(aEmail, bEmail, afterId = 0, limit = 300) {
|
||||||
const a = String(aEmail || '').toLowerCase(), b = String(bEmail || '').toLowerCase();
|
const a = String(aEmail || '').toLowerCase(), b = String(bEmail || '').toLowerCase();
|
||||||
if (db.enabled()) {
|
if (db.enabled()) {
|
||||||
@@ -170,5 +177,5 @@ async function markRead(email, id) {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { init, lastBroadcastAt, deliver, inbox, unreadCount, newestUnread, markRead,
|
module.exports = { init, lastFrom, lastBroadcastAt, deliver, inbox, unreadCount, newestUnread, markRead,
|
||||||
sendChat, thread, markChatRead, chatUnread, threadList };
|
sendChat, thread, markChatRead, chatUnread, threadList };
|
||||||
|
|||||||
+7
-1
@@ -1065,13 +1065,19 @@
|
|||||||
const d = r.directs || [];
|
const d = r.directs || [];
|
||||||
$('coachSummary').innerHTML = d.length ? '<b>' + d.length + '</b> direct' + (d.length === 1 ? '' : 's') + ' · <b>' + r.stalled + '</b> quiet for 3+ days' + (r.stalled ? ' · start at the top' : '') : '';
|
$('coachSummary').innerHTML = d.length ? '<b>' + d.length + '</b> direct' + (d.length === 1 ? '' : 's') + ' · <b>' + r.stalled + '</b> quiet for 3+ days' + (r.stalled ? ' · start at the top' : '') : '';
|
||||||
if (!d.length) { el.innerHTML = '<p class="muted small">No directs yet. When someone joins through your link they show up here with their next step.</p>'; return; }
|
if (!d.length) { el.innerHTML = '<p class="muted small">No directs yet. When someone joins through your link they show up here with their next step.</p>'; return; }
|
||||||
el.innerHTML = d.map(x => '<div class="lin-row' + (x.stalled ? ' own' : '') + '"><span class="nm">' + esc(x.name) + (x.stalled ? ' <span class="badge amber">quiet ' + x.quietDays + 'd</span>' : '') + (x.bound === false ? ' <span class="badge amber" title="On-chain this member registered under ' + (x.onchainSponsorId ? 'member #' + x.onchainSponsorId : 'no sponsor') + ', so the contract pays their purchases there, not to you. Sponsor binding is permanent at first activation.">not bound to you on-chain</span>' : '') + '</span>'
|
el.innerHTML = d.map(x => '<div class="lin-row' + (x.stalled ? ' own' : '') + '"><span class="nm">' + esc(x.name) + (x.stalled ? ' <span class="badge amber">quiet ' + x.quietDays + 'd</span>' : '')
|
||||||
|
+ (x.rescue && x.rescue.unreached ? ' <span class="badge amber" title="No message from you to this person. Contact them or mark Contacted, or they move to the holding tank.">unreached · tank in ' + x.rescue.rescueInDays + 'd</span>' : '') + (x.bound === false ? ' <span class="badge amber" title="On-chain this member registered under ' + (x.onchainSponsorId ? 'member #' + x.onchainSponsorId : 'no sponsor') + ', so the contract pays their purchases there, not to you. Sponsor binding is permanent at first activation.">not bound to you on-chain</span>' : '') + '</span>'
|
||||||
+ '<span class="em">' + esc(x.label) + ' → ' + esc(x.next) + '</span>'
|
+ '<span class="em">' + esc(x.label) + ' → ' + esc(x.next) + '</span>'
|
||||||
+ '<span class="id">rung ' + x.rung + '/6</span>'
|
+ '<span class="id">rung ' + x.rung + '/6</span>'
|
||||||
+ '<span class="dt">' + (x.buyerCount ? x.buyerCount + ' buyer' + (x.buyerCount === 1 ? '' : 's') : '') + '</span>'
|
+ '<span class="dt">' + (x.buyerCount ? x.buyerCount + ' buyer' + (x.buyerCount === 1 ? '' : 's') : '') + '</span>'
|
||||||
+ '<button class="btn sec small" type="button" data-nudge="' + esc(x.email) + '" data-nname="' + esc(x.name) + '" data-say="' + esc(x.say) + '">Nudge</button>'
|
+ '<button class="btn sec small" type="button" data-nudge="' + esc(x.email) + '" data-nname="' + esc(x.name) + '" data-say="' + esc(x.say) + '">Nudge</button>'
|
||||||
+ (x.free && x.address ? ' <button class="btn small" type="button" title="Pay it forward: send POL from your wallet to theirs for their first package" data-pif="' + esc(x.email) + '" data-pname="' + esc(x.name) + '" data-paddr="' + esc(x.address) + '">PIF</button>' : '')
|
+ (x.free && x.address ? ' <button class="btn small" type="button" title="Pay it forward: send POL from your wallet to theirs for their first package" data-pif="' + esc(x.email) + '" data-pname="' + esc(x.name) + '" data-paddr="' + esc(x.address) + '">PIF</button>' : '')
|
||||||
|
+ (x.free && x.rescue ? ' <button class="btn sec small" type="button" title="Reached them by phone, text or in person? Mark it so the rescue clock resets." data-contacted="' + esc(x.email) + '" data-cname="' + esc(x.name) + '">Contacted them</button>' : '')
|
||||||
+ (x.free ? ' <button class="btn sec small" type="button" title="Pay it forward: give this free member to the holding tank so another member can coach them" data-release="' + esc(x.email) + '" data-rname="' + esc(x.name) + '">Release to tank</button>' : '') + '</div>').join('');
|
+ (x.free ? ' <button class="btn sec small" type="button" title="Pay it forward: give this free member to the holding tank so another member can coach them" data-release="' + esc(x.email) + '" data-rname="' + esc(x.name) + '">Release to tank</button>' : '') + '</div>').join('');
|
||||||
|
el.querySelectorAll('[data-contacted]').forEach(b => b.addEventListener('click', async () => {
|
||||||
|
try { await api('/api/my/tank/contacted', { email: b.dataset.contacted }); IAP.status('Marked: you have contacted ' + b.dataset.cname + '.', 'ok'); loadCoach(); }
|
||||||
|
catch (e) { IAP.status(e.message, 'bad'); }
|
||||||
|
}));
|
||||||
el.querySelectorAll('[data-pif]').forEach(b => b.addEventListener('click', () => pif(b.dataset.pif, b.dataset.pname, b.dataset.paddr)));
|
el.querySelectorAll('[data-pif]').forEach(b => b.addEventListener('click', () => pif(b.dataset.pif, b.dataset.pname, b.dataset.paddr)));
|
||||||
el.querySelectorAll('[data-nudge]').forEach(b => b.addEventListener('click', async () => {
|
el.querySelectorAll('[data-nudge]').forEach(b => b.addEventListener('click', async () => {
|
||||||
await openConvo(b.dataset.nudge, b.dataset.nname);
|
await openConvo(b.dataset.nudge, b.dataset.nname);
|
||||||
|
|||||||
+2
-1
@@ -302,6 +302,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card" id="coachCard">
|
<div class="card" id="coachCard">
|
||||||
<h3>Coach your directs</h3>
|
<h3>Coach your directs</h3>
|
||||||
|
<p class="muted small"><b>Reach your free referrals.</b> If there is no message from you to a free referral for 10 days, you get a warning; at 14 days they move to the holding tank so someone else can help them. A chat, a nudge, or tapping <b>Contacted them</b> resets the clock. Nothing on-chain ever moves.</p>
|
||||||
<p class="muted small">Where each person you referred sits on the ladder, who has gone quiet, and what to say next. <b>Nudge</b> opens a chat with the message already written; edit it if you like, then send. Quiet members also get one automatic reminder email per step from InstantAdPay, so your nudge is the personal one.</p>
|
<p class="muted small">Where each person you referred sits on the ladder, who has gone quiet, and what to say next. <b>Nudge</b> opens a chat with the message already written; edit it if you like, then send. Quiet members also get one automatic reminder email per step from InstantAdPay, so your nudge is the personal one.</p>
|
||||||
<p class="small" id="coachSummary"></p>
|
<p class="small" id="coachSummary"></p>
|
||||||
<div id="coachList"><p class="muted small">Loading…</p></div>
|
<div id="coachList"><p class="muted small">Loading…</p></div>
|
||||||
@@ -899,7 +900,7 @@
|
|||||||
<script src="/assets/common.js?v=20260911b"></script>
|
<script src="/assets/common.js?v=20260911b"></script>
|
||||||
<script src="/assets/wallet.js?v=20260911a"></script>
|
<script src="/assets/wallet.js?v=20260911a"></script>
|
||||||
<script src="/assets/promo.js?v=20260911a"></script>
|
<script src="/assets/promo.js?v=20260911a"></script>
|
||||||
<script src="/assets/my.js?v=20260911e"></script>
|
<script src="/assets/my.js?v=20260911f"></script>
|
||||||
<script src="/assets/chat.js?v=20260907l"></script>
|
<script src="/assets/chat.js?v=20260907l"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1003,6 +1003,13 @@ const server = http.createServer(async (req, res) => {
|
|||||||
const r = await tank.release(s.email, b.email);
|
const r = await tank.release(s.email, b.email);
|
||||||
return json(res, r.error ? 400 : 200, r);
|
return json(res, r.error ? 400 : 200, r);
|
||||||
}
|
}
|
||||||
|
if (p === '/api/my/tank/contacted' && req.method === 'POST') { // sponsor reached the lead off-site: reset the rescue clock
|
||||||
|
const s = await auth.fromRequest(req);
|
||||||
|
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||||
|
const b = await readBody(req);
|
||||||
|
const r = await tank.markContacted(s.email, b.email);
|
||||||
|
return json(res, r.error ? 400 : 200, r);
|
||||||
|
}
|
||||||
if (p === '/api/my/gift' && req.method === 'POST') { // PIF: log a wallet-to-wallet POL gift and tell the recipient
|
if (p === '/api/my/gift' && req.method === 'POST') { // PIF: log a wallet-to-wallet POL gift and tell the recipient
|
||||||
const s = await auth.fromRequest(req);
|
const s = await auth.fromRequest(req);
|
||||||
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||||
@@ -1017,7 +1024,9 @@ const server = http.createServer(async (req, res) => {
|
|||||||
if (p === '/api/my/coach' && req.method === 'GET') {
|
if (p === '/api/my/coach' && req.method === 'GET') {
|
||||||
const s = await auth.fromRequest(req);
|
const s = await auth.fromRequest(req);
|
||||||
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||||
return json(res, 200, await coach.coachView(s.email));
|
const cv = await coach.coachView(s.email);
|
||||||
|
for (const d of cv.directs) if (d.free) { try { d.rescue = await tank.rescueInfo(s.email, await accounts.byEmail(d.email)); } catch (e) {} } // dormant-lead clock
|
||||||
|
return json(res, 200, cv);
|
||||||
}
|
}
|
||||||
// -- link stats: views, joins and buyers per angle link
|
// -- link stats: views, joins and buyers per angle link
|
||||||
if (p === '/api/my/linkstats' && req.method === 'GET') {
|
if (p === '/api/my/linkstats' && req.method === 'GET') {
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ const CAP_OPEN = 2; // open adoptions per adopter
|
|||||||
const TTL_MS = 7 * 86400000; // an adoption's window to convert
|
const TTL_MS = 7 * 86400000; // an adoption's window to convert
|
||||||
const MAX_ADOPTIONS = 2; // per adoptee, lifetime
|
const MAX_ADOPTIONS = 2; // per adoptee, lifetime
|
||||||
const MIN_OWN_BUY_CENTS = 2000; // adopter must have bought a $20+ package themselves
|
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';
|
let DATA_DIR = '.', accounts, chain, messages, mailer, siteUrl = 'https://instantadpay.com';
|
||||||
|
|
||||||
const J = {
|
const J = {
|
||||||
db: { v: 1, nextId: 1, adoptions: [] },
|
db: { v: 1, nextId: 1, adoptions: [], marks: {} },
|
||||||
FILE: () => path.join(DATA_DIR, 'tank.json'),
|
FILE: () => path.join(DATA_DIR, 'tank.json'),
|
||||||
load() { try { this.db = Object.assign(this.db, JSON.parse(fs.readFileSync(this.FILE(), 'utf8'))); } catch (e) {} },
|
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) {} },
|
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 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 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 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 = {
|
const D = {
|
||||||
async add(a) {
|
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 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 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 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;
|
const impl = () => db.enabled() ? D : J;
|
||||||
@@ -143,7 +154,8 @@ async function sweep() {
|
|||||||
await accounts.setSponsorRef(ad.adoptee, '');
|
await accounts.setSponsorRef(ad.adoptee, '');
|
||||||
await impl().setStatus(ad.id, 'expired'); back++;
|
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
|
// 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 };
|
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() {
|
async function adminView() {
|
||||||
const recent = await impl().recent(200);
|
const recent = await impl().recent(200);
|
||||||
const names = {};
|
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 };
|
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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user