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
+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 });