Email the admin a copy of every team-build alert

New config teamAlertEmail: the same team-gated on-chain events that post
to Telegram (registration, upgrade, payout at/below teamRootId) also go
to this address via SendGrid, so deep-leg activity surfaces even away
from Telegram.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-08-13 13:12:33 -05:00
parent accd869c12
commit 0f5f821b39
3 changed files with 19 additions and 9 deletions
+1 -1
View File
@@ -9,5 +9,5 @@
<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="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">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 &lt;no-reply@marketingwithmarty.com&gt;"><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">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 &lt;no-reply@marketingwithmarty.com&gt;"><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>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>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> <div id="toast" class="toast"></div><script src="/admin.js"></script></body></html>
+1 -1
View File
@@ -33,7 +33,7 @@ function render(){
const ai=state.aiChat||{};document.getElementById('aiModel').textContent=ai.model||'OpenRouter';document.getElementById('aiStatus').innerHTML=ai.configured?'<span style="color:var(--ok)">● AI chat is ON</span> — key is set':'<span style="color:var(--muted)">○ AI chat is OFF</span> — using built-in answers'; const ai=state.aiChat||{};document.getElementById('aiModel').textContent=ai.model||'OpenRouter';document.getElementById('aiStatus').innerHTML=ai.configured?'<span style="color:var(--ok)">● AI chat is ON</span> — key is set':'<span style="color:var(--muted)">○ AI chat is OFF</span> — using built-in answers';
const em=state.email||{};document.getElementById('emailStatus').innerHTML=em.configured?'<span style="color:var(--ok)">● Payment emails are ON</span> — key is set':'<span style="color:var(--muted)">○ Payment emails are OFF</span> — no SendGrid key'; const em=state.email||{};document.getElementById('emailStatus').innerHTML=em.configured?'<span style="color:var(--ok)">● Payment emails are ON</span> — key is set':'<span style="color:var(--muted)">○ Payment emails are OFF</span> — no SendGrid key';
const efi=document.getElementById('emailFromInput');if(efi&&!efi.value)efi.value=state.config.emailFrom||em.from||''; 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'])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'])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('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()}); document.getElementById('logoutBtn').addEventListener('click',async()=>{await api('/api/admin/logout',{method:'POST'});location.reload()});
+17 -7
View File
@@ -75,26 +75,29 @@ function getSendgridKey() {
// SendGrid is domain-authenticated for marketingwithmarty.com and // SendGrid is domain-authenticated for marketingwithmarty.com and
// mybrandedvoice.com — the from address must stay on one of those or DKIM fails. // mybrandedvoice.com — the from address must stay on one of those or DKIM fails.
function emailFrom() { return getConfig().emailFrom || 'The RM Circle Team <no-reply@marketingwithmarty.com>'; } function emailFrom() { return getConfig().emailFrom || 'The RM Circle Team <no-reply@marketingwithmarty.com>'; }
function sendPaidEmail(toEmail, memberName, evt) { function sendEmailRaw(toEmail, subject, text) {
const key = getSendgridKey(); const key = getSendgridKey();
if (!key) return; if (!key || !toEmail) return;
const fromStr = emailFrom(); const fromStr = emailFrom();
const m = fromStr.match(/^(.*)<([^>]+)>\s*$/); const m = fromStr.match(/^(.*)<([^>]+)>\s*$/);
const from = m ? { email: m[2].trim(), name: m[1].trim() || undefined } : { email: fromStr.trim() }; const from = m ? { email: m[2].trim(), name: m[1].trim() || undefined } : { email: fromStr.trim() };
const kindLine = evt.kind === 'upline' ? `an upgrade pass-up from member #${evt.fromId}` : `a referral reward from member #${evt.fromId}'s entry`;
const verify = evt.tx ? `\n\nVerify it yourself on the blockchain:\nhttps://polygonscan.com/tx/${evt.tx}` : '';
const text = `Hi ${memberName || 'there'},\n\nGood news — your RM Circle position #${evt.toId} just received ${evt.pol.toFixed(2)} POL (${kindLine}).${verify}\n\nKeep the momentum going: check your level so the next payment in your leg doesn't pass you by.\nhttps://rmcircle.saasy.top/training\n\n— The RM Circle Team\n\nYou're receiving this because your team admin has this address on file for team-build updates. Reply to this email to be removed.`;
fetch('https://api.sendgrid.com/v3/mail/send', { fetch('https://api.sendgrid.com/v3/mail/send', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }, headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
personalizations: [{ to: [{ email: toEmail }] }], personalizations: [{ to: [{ email: toEmail }] }],
from, subject: `Your RM Circle position #${evt.toId} just got paid ${evt.pol.toFixed(2)} POL`, from, subject,
content: [{ type: 'text/plain', value: text }] content: [{ type: 'text/plain', value: text }]
}) })
}).then(r => { if (r.status >= 300) r.text().then(t => console.error('sendgrid status', r.status, t.slice(0, 200))); }) }).then(r => { if (r.status >= 300) r.text().then(t => console.error('sendgrid status', r.status, t.slice(0, 200))); })
.catch(e => console.error('sendgrid error', e.message)); .catch(e => console.error('sendgrid error', e.message));
} }
function sendPaidEmail(toEmail, memberName, evt) {
const kindLine = evt.kind === 'upline' ? `an upgrade pass-up from member #${evt.fromId}` : `a referral reward from member #${evt.fromId}'s entry`;
const verify = evt.tx ? `\n\nVerify it yourself on the blockchain:\nhttps://polygonscan.com/tx/${evt.tx}` : '';
const text = `Hi ${memberName || 'there'},\n\nGood news — your RM Circle position #${evt.toId} just received ${evt.pol.toFixed(2)} POL (${kindLine}).${verify}\n\nKeep the momentum going: check your level so the next payment in your leg doesn't pass you by.\nhttps://rmcircle.saasy.top/training\n\n— The RM Circle Team\n\nYou're receiving this because your team admin has this address on file for team-build updates. Reply to this email to be removed.`;
sendEmailRaw(toEmail, `Your RM Circle position #${evt.toId} just got paid ${evt.pol.toFixed(2)} POL`, text);
}
function firePostback(clickid, txid, source) { function firePostback(clickid, txid, source) {
const pb = getConfig().bemobPostbackUrl; const pb = getConfig().bemobPostbackUrl;
if (!clickid || !pb || !/^https:\/\/[a-z0-9.-]+\/postback/i.test(pb)) return; if (!clickid || !pb || !/^https:\/\/[a-z0-9.-]+\/postback/i.test(pb)) return;
@@ -306,7 +309,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}); 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'){ 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'])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'])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))?$/); 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.'}); 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.'});
@@ -349,6 +352,13 @@ chain.startIndexer(evt=>{
else text=`💸 TEAM BUILD: #${evt.toId} just got PAID ${evt.pol.toFixed(2)} POL${evt.kind==='upline'?` (upgrade pass-up from #${evt.fromId})`:` (referral reward from #${evt.fromId})`}.${contact(evt.toId)}`; else text=`💸 TEAM BUILD: #${evt.toId} just got PAID ${evt.pol.toFixed(2)} POL${evt.kind==='upline'?` (upgrade pass-up from #${evt.fromId})`:` (referral reward from #${evt.fromId})`}.${contact(evt.toId)}`;
if(evt.tx)text+=`\nhttps://polygonscan.com/tx/${evt.tx}`; if(evt.tx)text+=`\nhttps://polygonscan.com/tx/${evt.tx}`;
sendTelegram(text); sendTelegram(text);
// admin email alert — same team-gated events, so deep-leg action still surfaces
if(c.teamAlertEmail){
const subj=evt.type==='registered'?`RM Circle team build: #${evt.id} registered under #${evt.referrerId}`
:evt.type==='upgraded'?`RM Circle team build: #${evt.id} upgraded to ${evt.levelName}`
:`RM Circle team build: #${evt.toId} paid ${evt.pol.toFixed(2)} POL`;
sendEmailRaw(c.teamAlertEmail,subj,text.replace(/^[^\s]+ /,''));
}
} }
}catch(e){console.error('team alert error',e.message)} }catch(e){console.error('team alert error',e.message)}
// "you've been paid" email — any payout whose recipient has a contact email on file (not subtree-gated) // "you've been paid" email — any payout whose recipient has a contact email on file (not subtree-gated)