diff --git a/chain.js b/chain.js index d17cd24..6ba9699 100644 --- a/chain.js +++ b/chain.js @@ -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 }; diff --git a/public/admin.html b/public/admin.html index 855d5b8..97f9193 100644 --- a/public/admin.html +++ b/public/admin.html @@ -4,11 +4,11 @@

Sponsor Queue

Mark a sponsor qualified to automatically activate the next waiting position.

OrderSponsorParentDirectsLevelStatusClicksActions

Traffic & Conversions

First-touch source per visitor session (referring domain or utm_source). Funnel: bridge page → start page → join click.

SourceBridge viewsStart viewsTraining viewsInvite viewsJoin clicksStart → Join

Member ID Submissions

New members who confirmed their purchase on the start page. Each one was posted to your Hermes Telegram chat — add them to the rotation.

WhenName / HandleNew IDJoined underSourceOn-chain
-

My Positions — Income

Every payment received by your own positions, live from the contract. Comma-separated IDs — saved for next time.

+

My Positions — Income

Every payment received by your own positions, live from the contract. Comma-separated IDs — saved for next time.

On-Chain Member Lookup

Enter an RM Circle ID to read its registration, lineage, and every payment it has received — live from the smart contract.

Matrix View

The entire on-chain matrix — who landed where, with tier, level, directs, and earnings per position. Click a position to drill down.

Add Sponsor

AI Chat

Paste an OpenRouter API key to switch the help chat from canned answers to AI (). Clear it to switch back.

Payment Emails (SendGrid)

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).

-

Public Page Settings

+

Public Page Settings

diff --git a/public/admin.js b/public/admin.js index 9e76008..9989319 100644 --- a/public/admin.js +++ b/public/admin.js @@ -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=>`
⏫ Upgrade #${n.id} to ${esc(n.neededLevelName)}${n.reason==='qualify'?' (and get 2 directs)':''} — ${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.
`).join(''):''; sum.innerHTML=`
Total received (all)${fmt(d.grandEarnedPol)} POL
`+ d.ids.map(id=>{const p=d.perId[id]||{};return `
#${id}${p.registered&&p.levelName?' · '+esc(p.levelName):''}${p.registered?fmt(p.totalEarnedPol)+' POL':'not registered'}${p.registered?`
${p.count} payment${p.count===1?'':'s'}
`:''}
`}).join(''); out.innerHTML=d.rows.length?`
${d.rows.map(r=>``).join('')}
When (UTC)ToFromForAmount
${date(r.ts)}#${r.toId}#${r.fromId}${esc(r.desc||'')}${fmt(r.pol)} POL

${d.rows.length} payment${d.rows.length===1?'':'s'} across your positions, newest first.

`:'
No payments recorded to these positions yet.
'; diff --git a/server.js b/server.js index ebe9655..4d19147 100644 --- a/server.js +++ b/server.js @@ -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();