Admin > Releases: email an update to members (pick notes, intro, audience, preview, test, send; opt-outs honoured; send log)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-14 13:40:01 -05:00
parent 203ebdb4bf
commit 7bb956e896
6 changed files with 142 additions and 3 deletions
+1
View File
@@ -83,6 +83,7 @@ FACTS:
- BLOG (public, instantadpay.com/blog): Marty's coaching and teaching articles on building a line, advertising that pays, and daily habits; each article has its own page and can be shared; RSS at /blog/feed.xml. Members who want to write their own articles: not offered today.
- ACHIEVEMENT BADGES ON TELEGRAM (2026-09-13): when a member unlocks Spark/Surge/Circuit/Nexus, their personalised badge image (username on the ribbon) is posted automatically to the team's Telegram payments topic and the main group, once per badge; members cannot trigger posts themselves (the 'Post to Telegram' button is admin-only); 'Share' opens a picker (X, Facebook, Telegram, WhatsApp, LinkedIn, Massifly (copies the post and opens the feed composer), the phone's own share menu, copy link, save image) that shares the member's public badge page instantadpay.com/b/<username>/<badge>, which shows the badge and their join link.
- PROMO TOOLKIT BY BADGE + AI COPY ENGINE (2026-09-14): Promo tools > AI Copy Engine shows the ladder: Free (links, posts, swipes, banners, wall, objections, shorts, badge pages), Spark (payouts on: one-tap campaign templates aimed at the member's link, printable handout with their QR at /handout/<username>), Surge (first qualifying buyer; the AI Copy Engine is live: posts, DMs, follow-ups, objection replies, emails, story posts, team broadcasts in the member's name with their link, honesty rules built in; 20 free generations a month), Circuit (60 free; Video Maker renders every promo video and short with the member's own end card and QR, hosted for download; split tester compares join angles by views, joins, buyers), Nexus (150 free; Leader Ops: three-level team triage with stalled flags and one-click nudges, AI-drafted team broadcasts, credit grants from the leader's earned pool to anyone in their line, co-branded join page showing the sponsor's bio, and the member's own partner code: welcome credits funded from the leader's pool at each redemption, plus a partner kit page /partners?ref=<username>&promo=CODE). After the free allowance each generation costs 10 ad credits from the earned pool. Credits are advertising, never money.
- MEMBER UPDATE EMAILS (2026-09-14, admin only): Admin > Releases > Email an update to members: pick release notes, add an intro, choose an audience (active 30 days, active 90 days, everyone), preview, send a test to the admin, then send; plain text from no-reply@instantadpay.com with an opt-out link; the log shows sent/opted out/failed.
- LINKED POSITIONS AND BADGES (2026-09-14): qualifying buyers on a member's linked positions count toward their achievement badges (Spark/Surge/Circuit/Nexus) and light their chip on their sponsor's line, and a badge once earned never regresses. The contract still pays levels 2 and 3 to each position based on that position's OWN qualifying buyers, so the Qualifying buyers tile shows the main position's count with the linked positions' count underneath.
- NO-PAYOUT POSITIONS (2026-09-14): admin can list member ids (Settings > No-payout positions) whose wallets must never be paid again (e.g. a compromised key). Those positions stay linked for history and counting only: the buy-from picker disables them, a purchase from them is refused, and invite links that would place a new member under them (or under their upline chain) route to the fallback sponsor instead. The contract itself cannot change a member's wallet.
- LINE AT A GLANCE, ALL LEVELS (2026-09-14): a gold chip on any level means that person made their $20+ buy (a qualifying buyer for their own sponsor); a small number on the chip is how many qualifying buyers of their own they have. My line rows say the same in words. Leaders use it to see who is one buyer short and encourage them.
+3 -2
View File
@@ -97,10 +97,11 @@ async function unsubscribe(email, t) {
const e = String(email || '').trim().toLowerCase();
if (!e || !t || t !== token(e)) return { error: 'That link is not valid.' };
const cur = await impl().get(e);
if (!cur) return { ok: true, already: true };
if (!cur) { try { await enqueue(e, '', ''); await impl().update(e, { stopped: 2 }); } catch (err) {} return { ok: true }; } // member without a drip row: record the opt-out anyway (update emails honour it)
await impl().update(e, { stopped: 2 });
return { ok: true };
}
async function isUnsubscribed(email) { const cur = await impl().get(String(email || '').toLowerCase()); return !!(cur && cur.stopped === 2); }
// ---- rendering ----
async function vars(email) {
@@ -178,4 +179,4 @@ async function tick() {
}
async function stats() { return impl().stats(); }
module.exports = { init, enqueue, tick, stats, sequence, setSequence, resetSequence, sendStep, unsubscribe, unsubUrl, DEFAULT_SEQUENCE };
module.exports = { init, enqueue, tick, stats, sequence, setSequence, resetSequence, sendStep, unsubscribe, unsubUrl, isUnsubscribed, DEFAULT_SEQUENCE };
+20 -1
View File
@@ -394,6 +394,25 @@
<div class="card"><div class="card-head"><h3>Notes</h3><span class="sub" id="rnSub"></span></div><div class="tablewrap"><table class="adm-table" id="rnTable"></table></div></div>
<div class="card"><div class="card-head"><h3>Roadmap</h3><span class="sub" id="rmSub"></span></div><div class="tablewrap"><table class="adm-table" id="rmTable"></table></div></div>
</div>
<div class="card" id="updCard">
<div class="card-head"><h3>Email an update to members</h3><span class="sub" id="updSub"></span></div>
<p class="small muted" style="margin:0 0 10px">Pick the notes to include, add a line or two in your own words, preview, send yourself a test, then send. Plain text from no-reply@instantadpay.com, one member at a time, opt-outs honoured. Nothing goes out until you press Send.</p>
<div class="grid c2">
<div style="display:grid;gap:8px">
<label class="small">Subject<input id="updSubject" type="text" maxlength="150" placeholder="What is new on InstantAdPay this week"></label>
<label class="small">Intro (optional, plain text)<textarea id="updIntro" rows="4" style="width:100%" placeholder="Quick one. Three things shipped since my last note, and the third one is the one to try today."></textarea></label>
<label class="small">Audience<select id="updAudience"></select></label>
<div style="display:flex;gap:8px;flex-wrap:wrap"><button type="button" class="btn small sec" id="updPreview">Preview</button><button type="button" class="btn small sec" id="updTest">Send test to me</button><button type="button" class="btn small" id="updSend">Send</button></div>
<pre id="updPre" class="small" hidden style="white-space:pre-wrap;background:rgba(255,255,255,.04);border:1px solid var(--line);border-radius:10px;padding:10px;max-height:320px;overflow:auto"></pre>
</div>
<div>
<div class="small muted" style="margin:0 0 6px">Release notes to include (newest first; notes since your last send are pre-ticked)</div>
<div id="updNotes" style="display:grid;gap:4px;max-height:260px;overflow:auto"></div>
<div class="small muted" style="margin:12px 0 6px">Recent sends</div>
<div class="tablewrap"><table class="adm-table" id="updLog"></table></div>
</div>
</div>
</div>
<p class="small muted">Public page: <a href="/whats-new" target="_blank" rel="noopener">instantadpay.com/whats-new</a></p>
</div>
<div class="pane" id="pane-pnl" hidden>
@@ -461,6 +480,6 @@
</div>
<script src="/assets/common.js?v=20260914a"></script>
<script src="/assets/admin.js?v=20260914a"></script>
<script src="/assets/admin.js?v=20260914b"></script>
</body>
</html>
+24
View File
@@ -427,6 +427,7 @@
// ── release notes + roadmap (Marty, 2026-09-14) ──
async function loadReleases() {
loadUpdates();
const d = await api('/api/admin/releases');
$('rnSub').textContent = d.notes.length + ' notes'; $('rmSub').textContent = d.roadmap.length + ' items';
$('rnTable').innerHTML = '<tr><th>Date</th><th>Title</th><th>Tags</th><th></th></tr>' + (d.notes.length ? d.notes.map(n => '<tr><td class="small">' + esc(n.date) + '</td><td><b>' + esc(n.title) + '</b></td><td class="small">' + esc(n.tags.join(', ')) + '</td><td class="act"><button type="button" class="btn small sec" data-rnedit="' + esc(n.id) + '">Edit</button> <button type="button" class="btn small sec" data-rndel="' + esc(n.id) + '">Delete</button></td></tr>').join('') : '<tr><td colspan="4" class="muted">No notes yet.</td></tr>');
@@ -436,6 +437,29 @@
$('rnTable').querySelectorAll('[data-rndel]').forEach(b => b.addEventListener('click', async () => { if (!await IAP.confirmBox('Delete this release note?', { ok: 'Delete', cancel: 'Keep' })) return; await api('/api/admin/releases?kind=notes&id=' + encodeURIComponent(b.dataset.rndel), undefined, 'DELETE'); loadReleases(); }));
$('rmTable').querySelectorAll('[data-rmdel]').forEach(b => b.addEventListener('click', async () => { if (!await IAP.confirmBox('Delete this roadmap item?', { ok: 'Delete', cancel: 'Keep' })) return; await api('/api/admin/releases?kind=roadmap&id=' + encodeURIComponent(b.dataset.rmdel), undefined, 'DELETE'); loadReleases(); }));
}
// ── member update emails (Marty, 2026-09-14) ──
async function loadUpdates() {
if (!$('updCard')) return;
try {
const d = await api('/api/admin/updates');
$('updSub').textContent = (d.mailer ? '' : 'mailer not configured · ') + (d.lastSentAt ? 'last send ' + new Date(d.lastSentAt).toLocaleString() : 'nothing sent yet') + (d.running ? ' · sending now' : '');
$('updAudience').innerHTML = Object.entries(d.audiences).map(([k, v]) => '<option value="' + k + '">' + esc(v) + ' (' + (d.counts[k] || 0) + ')</option>').join('');
$('updNotes').innerHTML = d.notes.length ? d.notes.map(n => '<label class="small" style="display:flex;gap:8px;align-items:flex-start"><input type="checkbox" value="' + esc(n.id) + '"' + (n.fresh ? ' checked' : '') + '><span>' + esc(n.title) + ' <span class="muted">' + esc(n.date) + '</span></span></label>').join('') : '<span class="muted small">No release notes yet.</span>';
$('updLog').innerHTML = '<tr><th>When</th><th>Subject</th><th>Audience</th><th>Sent</th></tr>' + (d.sends.length ? d.sends.map(x => '<tr><td class="small">' + new Date(x.ts).toLocaleString() + '</td><td>' + esc(x.subject) + '</td><td class="small">' + esc(d.audiences[x.audience] || x.audience) + '</td><td class="small">' + x.sent + ' of ' + x.total + (x.skipped ? ' · ' + x.skipped + ' opted out' : '') + (x.failed ? ' · ' + x.failed + ' failed' : '') + (x.status === 'running' ? ' · running' : '') + '</td></tr>').join('') : '<tr><td colspan="4" class="muted small">None yet.</td></tr>');
if (d.running) setTimeout(loadUpdates, 4000);
} catch (e) { $('updSub').textContent = e.message; }
}
const updInput = () => ({ subject: $('updSubject').value, intro: $('updIntro').value, noteIds: [...$('updNotes').querySelectorAll('input:checked')].map(i => i.value), audience: $('updAudience').value });
if ($('updCard')) {
$('updPreview').addEventListener('click', busy($('updPreview'), async () => { const r = await api('/api/admin/updates/preview', updInput()); $('updPre').hidden = false; $('updPre').textContent = 'Subject: ' + r.subject + '\n\n' + r.text; }));
$('updTest').addEventListener('click', busy($('updTest'), async () => { const r = await api('/api/admin/updates/send', Object.assign(updInput(), { test: true })); IAP.status('Test sent to ' + r.to + '.', 'ok'); }));
$('updSend').addEventListener('click', busy($('updSend'), async () => {
const inp = updInput(); if (!inp.noteIds.length) { IAP.status('Pick at least one note.', 'bad'); return; }
const opt = $('updAudience').selectedOptions[0].textContent;
if (!(await IAP.confirmBox('Send this update to ' + opt + '? One email per member, opt-outs skipped.', { title: 'Send member update', ok: 'Send now', cancel: 'Not yet' }))) return;
const r = await api('/api/admin/updates/send', inp); IAP.status('Sending to ' + r.total + ' members in the background.', 'ok'); loadUpdates();
}));
}
$('rnClear').addEventListener('click', () => { ['rnId', 'rnTitle', 'rnDate', 'rnTags', 'rnBody'].forEach(id => { $(id).value = ''; }); });
$('rmClear').addEventListener('click', () => { ['rmId', 'rmTitle', 'rmEta', 'rmOrder', 'rmNote'].forEach(id => { $(id).value = ''; }); $('rmStatus').value = 'planned'; });
$('rnSave').addEventListener('click', busy($('rnSave'), async () => { await api('/api/admin/releases', { kind: 'note', id: $('rnId').value || null, title: $('rnTitle').value, date: $('rnDate').value, tags: $('rnTags').value, body: $('rnBody').value }); IAP.status('Note saved.', 'ok'); $('rnClear').click(); loadReleases(); }));
+18
View File
@@ -32,6 +32,7 @@ const blog = require('./blog');
const adminMember = require('./adminmember');
const syndicate = require('./syndicate');
const releases = require('./releases');
const updates = require('./updates'); // member update emails from Admin > Releases (Marty, 2026-09-14)
const leaderboard = require('./leaderboard');
const toolkit = require('./toolkit');
const videomaker = require('./videomaker'); // Circuit tool: promo videos with the member's own end card (ffmpeg in the image) // badge-gated promo toolkit + AI Copy Engine (Surge and up) (Marty, 2026-09-14) // referral contest: /leaderboard, Overview card, weekly + monthly winners (Marty, 2026-09-14) // release notes + roadmap: /whats-new, Overview card, Admin > Releases (Marty, 2026-09-14) // blog -> Blotato -> X + Instagram on publish (Marty, 2026-09-13) // admin member card: search, drilldown, edits (Marty, 2026-09-13) // admin-written coaching articles, server-rendered public /blog with SEO metadata (Marty, 2026-09-12)
@@ -377,6 +378,7 @@ async function boot() {
syndicate.init({ dataDir: DATA_DIR, publicDir: PUBLIC_DIR, uploadsDir: UPLOADS_DIR });
releases.init({ dataDir: DATA_DIR });
toolkit.init({ dataDir: DATA_DIR, ads, accounts, siteConfig, coach, messages, promos, videomaker, chain });
updates.init({ dataDir: DATA_DIR, accounts, releases, mailer, drip, adminEmail: ADMIN_EMAIL });
videomaker.init({ dataDir: DATA_DIR, spaces, accounts });
leaderboard.init({ chain, accounts, ads, dataDir: DATA_DIR, siteConfig, pushFeed, adminEmail: ADMIN_EMAIL,
notify: async text => { const sc = siteConfig(); if (!sc.telegramBotToken || !sc.telegramEchoChatId) return; await telegramSend(sc.telegramEchoChatId, text, sc.telegramEchoTopicId); if (String(sc.leaderboardAnnounceGeneral || '1') !== '0') await telegramSend(sc.telegramEchoChatId, text, null); } });
@@ -1328,6 +1330,22 @@ const server = http.createServer(async (req, res) => {
const period = ['week', 'month', 'all', 'lastweek', 'lastmonth'].includes(u.searchParams.get('period')) ? u.searchParams.get('period') : 'week';
return json(res, 200, await leaderboard.view(period, s && s.email ? s.email : null));
}
if (p === '/api/admin/updates' && req.method === 'GET') { // member update emails: notes, audiences, log
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
const since = updates.lastSentAt();
return json(res, 200, Object.assign({ notes: releases.notes().map(n => ({ id: n.id, title: n.title, date: n.date, fresh: !since || (n.created || 0) > since || (n.date && new Date(n.date + 'T12:00:00Z').getTime() > since) })), audiences: updates.AUDIENCES, counts: await updates.counts(), mailer: mailer.hasKey(), lastSentAt: since }, updates.status()));
}
if (p === '/api/admin/updates/preview' && req.method === 'POST') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
const b = await readBody(req);
return json(res, 200, updates.compose({ subject: b.subject, intro: b.intro, noteIds: (b.noteIds || []).map(String) }, { email: ADMIN_EMAIL, username: 'you' }));
}
if (p === '/api/admin/updates/send' && req.method === 'POST') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
const b = await readBody(req);
const r = await updates.send({ subject: b.subject, intro: b.intro, noteIds: (b.noteIds || []).map(String), audience: b.audience }, { test: !!b.test });
return json(res, r.error ? 400 : 200, r);
}
if (p === '/api/admin/releases' && req.method === 'GET') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
return json(res, 200, { notes: releases.notes(), roadmap: releases.roadmap(), tags: releases.TAGS, statuses: releases.STATUSES });
+76
View File
@@ -0,0 +1,76 @@
// Member update emails (Marty, 2026-09-14): the admin picks release notes, writes a short intro, chooses an
// audience, previews, sends a test to themselves, then sends. Plain text through the site mailer (SendGrid),
// one recipient at a time with a small gap, opt-outs honoured (the same unsubscribe link the drip uses).
// Log: DATA_DIR/updates-log.json. Nothing here sends on its own: every send is a click in Admin > Releases.
const fs = require('fs');
const path = require('path');
let R = null; // { dataDir, accounts, releases, mailer, drip, adminEmail }
const SITE = 'https://instantadpay.com';
const FILE = () => path.join(R.dataDir, 'updates-log.json');
function log() { try { return JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch (e) { return { sends: [] }; } }
function saveLog(l) { try { fs.writeFileSync(FILE(), JSON.stringify(l)); } catch (e) {} }
function init(refs) { R = refs; }
const AUDIENCES = { active30: 'Active in the last 30 days', active90: 'Active in the last 90 days', all: 'Every member' };
async function recipients(kind) {
const all = await R.accounts.listAll(20000);
const now = Date.now(); const days = kind === 'active30' ? 30 : kind === 'active90' ? 90 : 0;
const out = [];
for (const a of all) {
if (!a.email || /@(demo|example)\./i.test(a.email)) continue;
if (days && now - Math.max(a.lastSeen || 0, a.created || 0) > days * 86400000) continue;
out.push(a);
}
return out;
}
async function counts() { const o = {}; for (const k of Object.keys(AUDIENCES)) o[k] = (await recipients(k)).length; return o; }
function compose({ subject, intro, noteIds }, acct) {
const notes = R.releases.notes().filter(n => noteIds.includes(n.id));
const name = acct && acct.username ? '@' + acct.username : 'there';
const parts = ['Hi ' + name + ','];
if (intro && intro.trim()) parts.push(intro.trim());
for (const n of notes) parts.push(n.title.toUpperCase() + (n.date ? ' (' + n.date + ')' : '') + '\n' + String(n.body || '').trim());
parts.push('Every release note and what is being built next: ' + SITE + '/whats-new');
parts.push('Sign in: ' + SITE + '/my');
parts.push('InstantAdPay\nAdvertise and earn instantly. Locked in code, not promises.\nNo income is guaranteed; results depend on your effort. Cryptocurrency carries risk.');
if (acct && acct.email) parts.push('Stop these update emails: ' + R.drip.unsubUrl(acct.email));
return { subject: String(subject || '').trim().slice(0, 150) || 'What is new on InstantAdPay', text: parts.join('\n\n') };
}
let running = null;
async function send(input, opts) {
const noteIds = (input.noteIds || []).map(String); if (!noteIds.length) return { error: 'Pick at least one release note.' };
if (!R.mailer.hasKey()) return { error: 'The mailer is not configured on this server.' };
if (opts && opts.test) {
const to = R.adminEmail; if (!to) return { error: 'No admin email configured.' };
const acct = (await R.accounts.byEmail(to)) || { email: to, username: null };
const m = compose(input, acct);
await R.mailer.send(to, '[TEST] ' + m.subject, m.text);
return { ok: true, test: true, to };
}
if (running) return { error: 'A send is already running (' + running.sent + ' of ' + running.total + '). Wait for it to finish.' };
const kind = AUDIENCES[input.audience] ? input.audience : 'active30';
const list = await recipients(kind);
if (!list.length) return { error: 'Nobody in that audience.' };
const rec = { id: Date.now().toString(36), ts: Date.now(), subject: compose(input, null).subject, noteIds, audience: kind, total: list.length, sent: 0, skipped: 0, failed: 0, status: 'running' };
const l = log(); l.sends.unshift(rec); l.sends = l.sends.slice(0, 50); saveLog(l); running = rec;
(async () => {
for (const acct of list) {
try {
if (R.drip.isUnsubscribed && await R.drip.isUnsubscribed(acct.email)) { rec.skipped += 1; continue; }
const m = compose(input, acct);
await R.mailer.send(acct.email, m.subject, m.text); rec.sent += 1;
} catch (e) { rec.failed += 1; console.error('updates send', acct.email, e.message); }
if ((rec.sent + rec.failed + rec.skipped) % 10 === 0) { const l2 = log(); const k = l2.sends.find(x => x.id === rec.id); if (k) Object.assign(k, rec); saveLog(l2); }
await new Promise(r => setTimeout(r, 150));
}
rec.status = 'done'; rec.doneAt = Date.now();
const l3 = log(); const k = l3.sends.find(x => x.id === rec.id); if (k) Object.assign(k, rec); saveLog(l3); running = null;
console.log('updates sent', rec.subject, rec.sent, 'of', rec.total, 'skipped', rec.skipped, 'failed', rec.failed);
})();
return { ok: true, id: rec.id, total: rec.total };
}
function status() { const l = log(); if (running) { const k = l.sends.find(x => x.id === running.id); if (k) Object.assign(k, running); } return { sends: l.sends.slice(0, 12), running: !!running }; }
function lastSentAt() { const l = log(); const d = l.sends.find(x => x.status === 'done'); return d ? d.ts : 0; }
module.exports = { init, AUDIENCES, counts, compose, send, status, lastSentAt };