Alert when an owned position needs upgrading to catch incoming pay
New owner upgrade watcher: every 5 min it checks config.ownerIds against the chain state and, when a leg member is ONE upgrade from paying a position that isn't eligible yet (below the required level, or not qualified), emails config.ownerAlertEmail + pings Telegram — "upgrade #24 to Fabrica, #61 is one upgrade from paying you 2,486 POL." Deduped per (position, level) in owner-alerts.json, re-fires if the situation recurs. Same warning renders inline in the admin "My Positions" panel (upgradeNeeds in the income endpoint). New ownerAlertEmail settings field. Detection logic unit-tested. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -477,6 +477,43 @@ async function memberPublic(id) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// For owned positions: detect when a leg member is ONE upgrade away from paying
|
||||
// the position, but the position isn't eligible yet (not qualified, or below the
|
||||
// required level) — i.e. "upgrade now or the payment passes you". Computed from
|
||||
// in-memory state, no RPC. A member M at depth D pays this position on M's
|
||||
// upgrade OUT of level D, so the trigger is M.level === D with the owner ineligible.
|
||||
function getOwnerUpgradeNeeds(ids) {
|
||||
if (!state || !state.snapshotAt || !costs) return { ready: false, needs: [] };
|
||||
const needs = [];
|
||||
for (const id of ids) {
|
||||
const p = state.members[id];
|
||||
const root = getSubtree(id, 99);
|
||||
if (!p || !root) continue;
|
||||
const pLevel = p.level || 1, pQual = (p.directCount || 0) >= 2;
|
||||
const items = [];
|
||||
(function walk(n, depth) {
|
||||
if (!n) return;
|
||||
if (depth >= 1 && (n.level || 1) === depth) {
|
||||
const eligible = pQual && pLevel >= depth;
|
||||
if (!eligible) items.push({ memberId: n.id, depth, amount: (costs.up[n.tier === 2 ? 2 : 1] || [])[depth - 1] || 0 });
|
||||
}
|
||||
walk(n.left, depth + 1); walk(n.right, depth + 1);
|
||||
})(root, 0);
|
||||
if (items.length) {
|
||||
const minDepth = Math.min(...items.map(i => i.depth));
|
||||
const atMin = items.filter(i => i.depth === minDepth);
|
||||
needs.push({
|
||||
id, level: pLevel, levelName: levelName(pLevel), qualified: pQual,
|
||||
neededLevel: minDepth, neededLevelName: levelName(minDepth),
|
||||
members: atMin.map(i => i.memberId),
|
||||
amountAtRisk: +atMin.reduce((s, i) => s + i.amount, 0).toFixed(2),
|
||||
reason: pQual ? 'upgrade' : 'qualify'
|
||||
});
|
||||
}
|
||||
}
|
||||
return { ready: true, needs };
|
||||
}
|
||||
|
||||
// focused income read for one position — for the admin "my positions" income view
|
||||
async function getIncome(id) {
|
||||
const m = await fetchMember(id);
|
||||
@@ -491,4 +528,4 @@ async function getIncome(id) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { startIndexer, getPayoutsPublic, verifyMember, memberLookup, memberPublic, getIncome, getMatrixTree, isInTeam, CONTRACT };
|
||||
module.exports = { startIndexer, getPayoutsPublic, verifyMember, memberLookup, memberPublic, getIncome, getOwnerUpgradeNeeds, getMatrixTree, isInTeam, CONTRACT };
|
||||
|
||||
+2
-2
@@ -4,11 +4,11 @@
|
||||
<div class="admin-grid"><div class="stack"><div class="table-card"><div style="display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:12px"><div><h2 style="margin:0">Sponsor Queue</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">Mark a sponsor qualified to automatically activate the next waiting position.</p></div></div><div class="table-wrap"><table class="table"><thead><tr><th>Order</th><th>Sponsor</th><th>Parent</th><th>Directs</th><th>Level</th><th>Status</th><th>Clicks</th><th>Actions</th></tr></thead><tbody id="sponsorRows"></tbody></table></div></div>
|
||||
<div class="table-card"><div style="margin-bottom:12px"><h2 style="margin:0">Traffic & Conversions</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">First-touch source per visitor session (referring domain or utm_source). Funnel: bridge page → start page → join click.</p></div><div id="funnelStats" class="funnel"></div><div class="table-wrap"><table class="table"><thead><tr><th>Source</th><th>Bridge views</th><th>Start views</th><th>Training views</th><th>Invite views</th><th>Join clicks</th><th>Start → Join</th></tr></thead><tbody id="trafficRows"></tbody></table></div></div>
|
||||
<div class="table-card"><div style="margin-bottom:12px"><h2 style="margin:0">Member ID Submissions</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">New members who confirmed their purchase on the start page. Each one was posted to your Hermes Telegram chat — add them to the rotation.</p></div><div class="table-wrap"><table class="table"><thead><tr><th>When</th><th>Name / Handle</th><th>New ID</th><th>Joined under</th><th>Source</th><th>On-chain</th></tr></thead><tbody id="submissionRows"></tbody></table></div></div>
|
||||
<div class="table-card"><div style="margin-bottom:12px"><h2 style="margin:0">My Positions — Income</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">Every payment received by your own positions, live from the contract. Comma-separated IDs — saved for next time.</p></div><form id="incomeForm" style="display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap"><input id="incomeIds" class="input" style="max-width:260px" placeholder="21,24,25" inputmode="numeric"><button class="btn btn-teal">Load</button></form><div id="incomeSummary" class="facts" style="grid-template-columns:repeat(4,1fr);margin-bottom:12px"></div><div id="incomeResult"></div></div>
|
||||
<div class="table-card"><div style="margin-bottom:12px"><h2 style="margin:0">My Positions — Income</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">Every payment received by your own positions, live from the contract. Comma-separated IDs — saved for next time.</p></div><form id="incomeForm" style="display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap"><input id="incomeIds" class="input" style="max-width:260px" placeholder="21,24,25" inputmode="numeric"><button class="btn btn-teal">Load</button></form><div id="incomeAlert"></div><div id="incomeSummary" class="facts" style="grid-template-columns:repeat(4,1fr);margin-bottom:12px"></div><div id="incomeResult"></div></div>
|
||||
<div class="table-card"><div style="margin-bottom:12px"><h2 style="margin:0">On-Chain Member Lookup</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">Enter an RM Circle ID to read its registration, lineage, and every payment it has received — live from the smart contract.</p></div><form id="lookupForm" style="display:flex;gap:10px;margin-bottom:14px"><input id="lookupId" class="input" style="max-width:220px" placeholder="Member ID e.g. 46" inputmode="numeric"><button class="btn btn-teal">Look Up</button></form><div id="lookupResult"></div></div>
|
||||
<div class="table-card"><div style="display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:12px;flex-wrap:wrap"><div><h2 style="margin:0">Matrix View</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">The entire on-chain matrix — who landed where, with tier, level, directs, and earnings per position. Click a position to drill down.</p></div><div style="display:flex;gap:8px"><button id="treeLoadBtn" class="btn btn-secondary btn-sm">Load Matrix</button><button id="treeToggleBtn" class="btn btn-secondary btn-sm hidden">List view</button></div></div><div id="matrixNav" class="hidden" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:12px"></div><div id="matrixTree"></div></div></div>
|
||||
<div class="stack"><div class="table-card"><h2 style="margin-top:0">Add Sponsor</h2><form id="addSponsorForm"><div class="form-grid"><div class="field"><label>ID</label><input name="id" class="input" required></div><div class="field"><label>Name</label><input name="name" class="input" required></div><div class="field"><label>Parent ID</label><input name="parentId" class="input"></div><div class="field"><label>Level</label><select name="level" class="select"><option>Scintilla</option><option>Ascensus</option><option>Fabrica</option><option>Culmen</option><option>Apex</option><option>Fastigium</option><option>Vertex</option><option>Corona</option></select></div></div><div class="field"><label>Contact email (optional)</label><input name="email" class="input" type="email" placeholder="member@example.com"></div><div class="field"><label>Notes</label><input name="notes" class="input"></div><button class="btn btn-teal" style="width:100%">Add to Queue</button></form></div>
|
||||
<div class="table-card"><h2 style="margin-top:0">AI Chat</h2><p style="color:var(--muted);font-size:13px;margin:4px 0 12px">Paste an OpenRouter API key to switch the help chat from canned answers to AI (<span id="aiModel"></span>). Clear it to switch back.</p><div id="aiStatus" class="micro" style="margin-bottom:10px"></div><form id="aiKeyForm"><div class="field"><label>OpenRouter API key</label><input name="key" class="input" type="password" placeholder="sk-or-v1-…" autocomplete="off"></div><button class="btn btn-teal" style="width:100%">Save Key</button><button type="button" id="aiKeyClear" class="btn btn-secondary" style="width:100%;margin-top:8px">Clear Key (use canned answers)</button></form></div>
|
||||
<div class="table-card"><h2 style="margin-top:0">Payment Emails (SendGrid)</h2><p style="color:var(--muted);font-size:13px;margin:4px 0 12px">When a payout hits a member whose sponsor record has a contact email, they get a "you've been paid" email automatically. Paste your SendGrid API key (Branded Voice Coolify app → Environment Variables → SENDGRID_API_KEY).</p><div id="emailStatus" class="micro" style="margin-bottom:10px"></div><form id="sgKeyForm"><div class="field"><label>SendGrid API key</label><input name="key" class="input" type="password" placeholder="SG.…" autocomplete="off"></div><button class="btn btn-teal" style="width:100%">Save Key</button><button type="button" id="sgKeyClear" class="btn btn-secondary" style="width:100%;margin-top:8px">Clear Key (disable emails)</button></form><div class="field" style="margin-top:12px"><label>From address (marketingwithmarty.com or mybrandedvoice.com — DKIM)</label><input id="emailFromInput" class="input" placeholder="The RM Circle Team <no-reply@marketingwithmarty.com>"><button type="button" id="emailFromSave" class="btn btn-secondary" style="width:100%;margin-top:8px">Save From Address</button></div></div>
|
||||
<div class="table-card"><h2 style="margin-top:0">Public Page Settings</h2><form id="configForm"><div class="field"><label>Site name</label><input name="siteName" class="input"></div><div class="field"><label>Program name</label><input name="programName" class="input"></div><div class="field"><label>Bridge headline</label><input name="bridgeHeadline" class="input"></div><div class="field"><label>Bridge subheadline</label><textarea name="bridgeSubheadline" class="input" rows="3"></textarea></div><div class="field"><label>Premium entry (POL)</label><input name="premiumEntryPol" class="input" type="number"></div><div class="field"><label>RM dApp referral base URL</label><input name="dappReferralBaseUrl" class="input" placeholder="https://app.thermcircle.com?ref="></div><div class="field"><label>BeMob postback URL (paid traffic)</label><input name="bemobPostbackUrl" class="input" placeholder="https://xxxxx.bemobtrcks.com/postback"></div><div class="field"><label>Telegram bot token (Hermes notifications)</label><input name="telegramBotToken" class="input" type="password" autocomplete="off" placeholder="123456:ABC…"></div><div class="field"><label>Telegram chat ID (group or user)</label><input name="telegramChatId" class="input" placeholder="-1001234567890"></div><div class="field"><label>Telegram topic ID (optional, for forum groups)</label><input name="telegramTopicId" class="input" placeholder="55"></div><div class="field"><label>Team root ID (on-chain team-build alerts)</label><input name="teamRootId" class="input" inputmode="numeric" placeholder="21 — alerts fire for any activity at or below this member ID"></div><div class="field"><label>Team alert email (admin copy of every team-build alert)</label><input name="teamAlertEmail" class="input" type="email" placeholder="you@example.com — emailed for any activity below the team root"></div><div class="field"><label>Telegram/support URL (optional)</label><input name="telegramUrl" class="input"></div><div class="field"><label>Support message</label><textarea name="supportLabel" class="input" rows="3"></textarea></div><label style="text-transform:none;letter-spacing:0;margin:10px 0"><input type="checkbox" name="showSponsorName"> Show sponsor name publicly</label><label style="text-transform:none;letter-spacing:0;margin:10px 0"><input type="checkbox" name="showQueueProgress"> Show number waiting in queue</label><button class="btn btn-primary" style="width:100%;margin-top:8px">Save Settings</button></form></div></div></div></section>
|
||||
<div class="table-card"><h2 style="margin-top:0">Public Page Settings</h2><form id="configForm"><div class="field"><label>Site name</label><input name="siteName" class="input"></div><div class="field"><label>Program name</label><input name="programName" class="input"></div><div class="field"><label>Bridge headline</label><input name="bridgeHeadline" class="input"></div><div class="field"><label>Bridge subheadline</label><textarea name="bridgeSubheadline" class="input" rows="3"></textarea></div><div class="field"><label>Premium entry (POL)</label><input name="premiumEntryPol" class="input" type="number"></div><div class="field"><label>RM dApp referral base URL</label><input name="dappReferralBaseUrl" class="input" placeholder="https://app.thermcircle.com?ref="></div><div class="field"><label>BeMob postback URL (paid traffic)</label><input name="bemobPostbackUrl" class="input" placeholder="https://xxxxx.bemobtrcks.com/postback"></div><div class="field"><label>Telegram bot token (Hermes notifications)</label><input name="telegramBotToken" class="input" type="password" autocomplete="off" placeholder="123456:ABC…"></div><div class="field"><label>Telegram chat ID (group or user)</label><input name="telegramChatId" class="input" placeholder="-1001234567890"></div><div class="field"><label>Telegram topic ID (optional, for forum groups)</label><input name="telegramTopicId" class="input" placeholder="55"></div><div class="field"><label>Team root ID (on-chain team-build alerts)</label><input name="teamRootId" class="input" inputmode="numeric" placeholder="21 — alerts fire for any activity at or below this member ID"></div><div class="field"><label>Team alert email (admin copy of every team-build alert)</label><input name="teamAlertEmail" class="input" type="email" placeholder="you@example.com — emailed for any activity below the team root"></div><div class="field"><label>Upgrade alert email (for your own positions)</label><input name="ownerAlertEmail" class="input" type="email" placeholder="you@example.com — emailed when a position in 'My Positions' needs upgrading to catch incoming pay"></div><div class="field"><label>Telegram/support URL (optional)</label><input name="telegramUrl" class="input"></div><div class="field"><label>Support message</label><textarea name="supportLabel" class="input" rows="3"></textarea></div><label style="text-transform:none;letter-spacing:0;margin:10px 0"><input type="checkbox" name="showSponsorName"> Show sponsor name publicly</label><label style="text-transform:none;letter-spacing:0;margin:10px 0"><input type="checkbox" name="showQueueProgress"> Show number waiting in queue</label><button class="btn btn-primary" style="width:100%;margin-top:8px">Save Settings</button></form></div></div></div></section>
|
||||
<div id="toast" class="toast"></div><script src="/admin.js"></script></body></html>
|
||||
|
||||
+3
-1
@@ -37,7 +37,7 @@ function render(){
|
||||
const incEl=document.getElementById('incomeIds');if(incEl&&!incEl.value)incEl.value=state.config.ownerIds||'21,24,25';
|
||||
if(!incomeAutoLoaded&&incEl&&incEl.value){incomeAutoLoaded=true;loadIncome();}
|
||||
const efi=document.getElementById('emailFromInput');if(efi&&!efi.value)efi.value=state.config.emailFrom||em.from||'';
|
||||
const f=document.getElementById('configForm'),c=state.config;for(const k of ['siteName','programName','bridgeHeadline','bridgeSubheadline','premiumEntryPol','dappReferralBaseUrl','telegramUrl','supportLabel','bemobPostbackUrl','telegramBotToken','telegramChatId','telegramTopicId','teamRootId','teamAlertEmail'])if(f.elements[k])f.elements[k].value=c[k]??'';f.elements.showSponsorName.checked=!!c.showSponsorName;f.elements.showQueueProgress.checked=!!c.showQueueProgress;
|
||||
const f=document.getElementById('configForm'),c=state.config;for(const k of ['siteName','programName','bridgeHeadline','bridgeSubheadline','premiumEntryPol','dappReferralBaseUrl','telegramUrl','supportLabel','bemobPostbackUrl','telegramBotToken','telegramChatId','telegramTopicId','teamRootId','teamAlertEmail','ownerAlertEmail'])if(f.elements[k])f.elements[k].value=c[k]??'';f.elements.showSponsorName.checked=!!c.showSponsorName;f.elements.showQueueProgress.checked=!!c.showQueueProgress;
|
||||
}
|
||||
document.getElementById('loginForm').addEventListener('submit',async e=>{e.preventDefault();const err=document.getElementById('loginError');err.textContent='';try{await api('/api/admin/login',{method:'POST',body:JSON.stringify({password:document.getElementById('password').value})});document.getElementById('password').value='';await loadState()}catch(x){err.textContent=x.message}});
|
||||
document.getElementById('logoutBtn').addEventListener('click',async()=>{await api('/api/admin/logout',{method:'POST'});location.reload()});
|
||||
@@ -61,6 +61,8 @@ async function loadIncome(){
|
||||
const d=await api('/api/admin/income?ids='+encodeURIComponent(ids));
|
||||
const fmt=n=>Number(n).toLocaleString(undefined,{maximumFractionDigits:2});
|
||||
const date=ts=>ts?new Date(ts*1000).toISOString().replace('T',' ').slice(0,16):'—';
|
||||
const needs=d.upgradeNeeds||[];
|
||||
document.getElementById('incomeAlert').innerHTML=needs.length?needs.map(n=>`<div class="callout warning" style="margin-bottom:10px">⏫ <strong>Upgrade #${n.id} to ${esc(n.neededLevelName)}${n.reason==='qualify'?' (and get 2 directs)':''}</strong> — ${n.members.map(m=>'#'+m).join(', ')} ${n.members.length===1?'is':'are'} one upgrade from paying you ~${fmt(n.amountAtRisk)} POL, but #${n.id} (${esc(n.levelName)}) can't catch it yet. Upgrade before they do or it passes up.</div>`).join(''):'';
|
||||
sum.innerHTML=`<div class="fact" style="border-color:rgba(123,224,161,.4)"><small>Total received (all)</small><strong style="color:var(--ok);font-size:18px">${fmt(d.grandEarnedPol)} POL</strong></div>`+
|
||||
d.ids.map(id=>{const p=d.perId[id]||{};return `<div class="fact"><small>#${id}${p.registered&&p.levelName?' · '+esc(p.levelName):''}</small><strong>${p.registered?fmt(p.totalEarnedPol)+' POL':'<span style=\"color:var(--danger)\">not registered</span>'}</strong>${p.registered?`<div class="micro" style="margin-top:2px">${p.count} payment${p.count===1?'':'s'}</div>`:''}</div>`}).join('');
|
||||
out.innerHTML=d.rows.length?`<div class="table-wrap"><table class="table" style="min-width:640px"><thead><tr><th>When (UTC)</th><th>To</th><th>From</th><th>For</th><th>Amount</th></tr></thead><tbody>${d.rows.map(r=>`<tr><td>${date(r.ts)}</td><td><strong class="mt-id">#${r.toId}</strong></td><td>#${r.fromId}</td><td>${esc(r.desc||'')}</td><td><strong>${fmt(r.pol)} POL</strong></td></tr>`).join('')}</tbody></table></div><p class="micro" style="margin:8px 0 0">${d.rows.length} payment${d.rows.length===1?'':'s'} across your positions, newest first.</p>`:'<div class="empty">No payments recorded to these positions yet.</div>';
|
||||
|
||||
@@ -380,7 +380,8 @@ async function handleApi(req,res,pathname){
|
||||
for(const p of r.income){rows.push({toId:r.id,fromId:p.fromId,pol:p.pol,ts:p.ts,desc:p.desc});grandListed+=p.pol;}
|
||||
}
|
||||
rows.sort((a,b)=>(b.ts||0)-(a.ts||0));
|
||||
return json(res,200,{ids,perId,rows:rows.slice(0,500),grandEarnedPol:+grand.toFixed(2),grandListedPol:+grandListed.toFixed(2)});
|
||||
let upgradeNeeds=[];try{upgradeNeeds=chain.getOwnerUpgradeNeeds(ids).needs;}catch(e){}
|
||||
return json(res,200,{ids,perId,rows:rows.slice(0,500),grandEarnedPol:+grand.toFixed(2),grandListedPol:+grandListed.toFixed(2),upgradeNeeds});
|
||||
}catch(e){return json(res,502,{error:e.message||'Lookup failed'})}
|
||||
}
|
||||
if(req.method==='GET'&&pathname==='/api/admin/member-lookup'){
|
||||
@@ -413,7 +414,7 @@ async function handleApi(req,res,pathname){
|
||||
const maxOrder=sponsors.reduce((m,s)=>Math.max(m,s.sortOrder||0),0);sponsors.push({id:String(id).trim(),name:String(name).trim(),parentId:String(parentId||'').trim(),directs:0,level,status:sponsors.some(s=>s.status==='active')?'waiting':'active',sortOrder:maxOrder+10,clicks:0,notes:String(notes||'').trim(),email:String(email||'').trim().slice(0,120)});sponsors=normalizeStatuses(sponsors);saveSponsors(sponsors);return json(res,201,{sponsors});
|
||||
}
|
||||
if(req.method==='PATCH'&&pathname==='/api/admin/config'){
|
||||
const b=await bodyJson(req),cur=getConfig(),next={...cur};for(const k of ['siteName','programName','bridgeHeadline','bridgeSubheadline','premiumEntryPol','dappReferralBaseUrl','telegramUrl','supportLabel','showSponsorName','showQueueProgress','bemobPostbackUrl','telegramBotToken','telegramChatId','telegramTopicId','teamRootId','emailFrom','teamAlertEmail','ownerIds'])if(Object.prototype.hasOwnProperty.call(b,k))next[k]=b[k];next.premiumEntryPol=Number(next.premiumEntryPol)||362;next.updatedAt=new Date().toISOString();writeJson(CONFIG_FILE,next);return json(res,200,{config:next});
|
||||
const b=await bodyJson(req),cur=getConfig(),next={...cur};for(const k of ['siteName','programName','bridgeHeadline','bridgeSubheadline','premiumEntryPol','dappReferralBaseUrl','telegramUrl','supportLabel','showSponsorName','showQueueProgress','bemobPostbackUrl','telegramBotToken','telegramChatId','telegramTopicId','teamRootId','emailFrom','teamAlertEmail','ownerIds','ownerAlertEmail'])if(Object.prototype.hasOwnProperty.call(b,k))next[k]=b[k];next.premiumEntryPol=Number(next.premiumEntryPol)||362;next.updatedAt=new Date().toISOString();writeJson(CONFIG_FILE,next);return json(res,200,{config:next});
|
||||
}
|
||||
const m=pathname.match(/^\/api\/admin\/sponsors\/([^/]+)(?:\/(increment|activate|qualify|reset|move))?$/);
|
||||
if(m){const id=decodeURIComponent(m[1]),action=m[2]||null;let sponsors=getSponsors(),idx=sponsors.findIndex(s=>s.id===id);if(idx<0)return json(res,404,{error:'Sponsor not found.'});
|
||||
@@ -443,6 +444,40 @@ const server=http.createServer(async(req,res)=>{
|
||||
server.listen(PORT,()=>{console.log(`Crypto Team Build sponsor router running on http://localhost:${PORT}`);if(ADMIN_PASSWORD==='changeme')console.warn('WARNING: Set ADMIN_PASSWORD before production deployment.');});
|
||||
// Team-activity alerts: any NEW on-chain event at/below config.teamRootId goes
|
||||
// to the Telegram group topic, with the sponsor's contact email when we have it.
|
||||
// Owner upgrade watcher: emails/Telegrams when an owned position (config.ownerIds)
|
||||
// has a payment about to arrive it can't catch yet, so Marty can upgrade in time.
|
||||
const OWNER_ALERTS_FILE = path.join(DATA_DIR, 'owner-alerts.json');
|
||||
function loadOwnerAlerts(){ try{ return new Set(readJson(OWNER_ALERTS_FILE)); }catch(e){ return new Set(); } }
|
||||
function parseOwnerIds(){ return [...new Set(String(getConfig().ownerIds||'').split(',').map(s=>parseInt(String(s).trim(),10)).filter(n=>Number.isInteger(n)&&n>0))].slice(0,12); }
|
||||
function checkOwnerUpgrades(){
|
||||
try{
|
||||
const c=getConfig(); const ids=parseOwnerIds();
|
||||
if(!ids.length) return;
|
||||
const res=chain.getOwnerUpgradeNeeds(ids);
|
||||
if(!res.ready) return;
|
||||
const alerted=loadOwnerAlerts(); const active=new Set();
|
||||
for(const n of res.needs){
|
||||
const key=`${n.id}:${n.reason}:${n.neededLevel}`; active.add(key);
|
||||
if(alerted.has(key)) continue;
|
||||
alerted.add(key);
|
||||
const who=n.members.map(m=>'#'+m).join(', ');
|
||||
const action=n.reason==='qualify'
|
||||
? `Position #${n.id} needs its 2 directs to catch this.`
|
||||
: `Upgrade position #${n.id} (now ${n.levelName}) to ${n.neededLevelName} to catch it.`;
|
||||
if(c.ownerAlertEmail){
|
||||
sendEmailRaw(c.ownerAlertEmail,
|
||||
`RM Circle: upgrade #${n.id} to ${n.neededLevelName} — ${n.amountAtRisk} POL incoming`,
|
||||
`Heads up — one of your positions has money about to arrive that it can't catch yet.\n\nPosition #${n.id} is at ${n.levelName}. Member(s) ${who} are ONE upgrade away from paying #${n.id} about ${n.amountAtRisk} POL — but that payment only stops at #${n.id} if it's at ${n.neededLevelName} and qualified.\n\n${action}\n\nDo it before they upgrade, or the payment passes to the next eligible position above you (it doesn't come back). Your positions: https://rmcircle.saasy.top/admin\n\n— RM Circle auto-watch`);
|
||||
}
|
||||
sendTelegram(`⏫ UPGRADE #${n.id} SOON: ${who} one upgrade from paying ~${n.amountAtRisk} POL. #${n.id} is ${n.levelName} — needs ${n.neededLevelName}${n.reason==='qualify'?' + 2 directs':''}. Upgrade before they do.`);
|
||||
}
|
||||
let changed=false;
|
||||
for(const k of [...alerted]) if(!active.has(k)){ alerted.delete(k); changed=true; }
|
||||
if(changed||active.size) writeJson(OWNER_ALERTS_FILE,[...alerted]);
|
||||
}catch(e){ console.error('owner upgrade check', e.message); }
|
||||
}
|
||||
setInterval(checkOwnerUpgrades, 5*60*1000).unref();
|
||||
setTimeout(checkOwnerUpgrades, 30000).unref();
|
||||
chain.startIndexer(evt=>{
|
||||
try{
|
||||
const c=getConfig();
|
||||
|
||||
Reference in New Issue
Block a user