Sponsor to downline messaging: lineage view, daily broadcast (email+inbox), login modal

- Downline resolver (3 levels); email visible for directs only, username+ID deeper
- Broadcast composer (rich editor) to directs or full downline, 1/day cap, sends email + on-site inbox
- Upline messages inbox in My line; unmissable sign-in modal for unread, ack marks read
- messages.js dual-mode store; sponsor_messages table; sanitizeRich reused

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-06 08:39:06 -05:00
parent 289538dbeb
commit 7701a29169
14 changed files with 344 additions and 27 deletions
+69
View File
@@ -16,6 +16,7 @@ const auth = require('./auth');
const accounts = require('./accounts');
const ads = require('./ads');
const mailer = require('./mailer');
const messages = require('./messages');
const chatbot = require('./chatbot');
const PORT = Number(process.env.PORT || 3000);
@@ -120,6 +121,7 @@ async function boot() {
accounts.init({ dataDir: DATA_DIR });
ads.init({ dataDir: DATA_DIR, chain });
mailer.init({ dataDir: DATA_DIR });
messages.init({ dataDir: DATA_DIR });
chatbot.init({ dataDir: DATA_DIR, chain });
setTimeout(() => ads.dailySweep().catch(e => console.error('sweep', e.message)), 60 * 1000);
setInterval(() => ads.dailySweep().catch(e => console.error('sweep', e.message)), 60 * 60 * 1000);
@@ -459,6 +461,14 @@ const server = http.createServer(async (req, res) => {
else { out.welcomeCredits = 0; out.gauntletPending = true; }
}
if (out.email) out.inboxUnread = await ads.unreadCount(out.email); // delivers pending solos too
if (out.email) { // unmissable login modal when the upline sent a message
const un = await messages.newestUnread(out.email);
if (un) {
const nm = un.fromMember ? await accounts.namesForMembers([un.fromMember]) : {};
out.sponsorMsg = { id: un.id, subject: un.subject, body: un.body,
fromName: (un.fromMember && nm[un.fromMember]) ? '@' + nm[un.fromMember] : (un.fromMember ? 'member #' + un.fromMember : 'your sponsor') };
}
}
if (memberId) {
try {
const mm = await chain.member(memberId);
@@ -571,6 +581,65 @@ const server = http.createServer(async (req, res) => {
const r = await ads.claimDaily(s.email);
return json(res, r.error ? 400 : 200, r);
}
// -- downline lineage: 3 levels, usernames+IDs; email only for directs
if (p === '/api/my/line' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const levels = await accounts.downline(s.email, 3);
const out = levels.map(L => ({ level: L.level, members: L.members.map(m => ({
memberId: m.memberId || 0,
name: m.username ? '@' + m.username : m.memberId ? 'member #' + m.memberId : 'member',
email: L.level === 1 ? m.email : null, // directs only
joined: m.created })) }));
return json(res, 200, { levels: out, counts: out.map(L => L.members.length) });
}
// -- broadcast a message to your downline (1/day), on-site inbox + email
if (p === '/api/my/broadcast' && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const b = await readBody(req);
const subject = String(b.subject || '').trim().slice(0, 160);
const body = ads.sanitizeRich(b.body);
const plain = body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
if (!subject) return json(res, 400, { error: 'Give your message a subject.' });
if (plain.length < 10) return json(res, 400, { error: 'Write a message first.' });
const last = await messages.lastBroadcastAt(s.email);
if (Date.now() - last < 24 * 3600 * 1000)
return json(res, 429, { error: 'You can send one broadcast a day. Try again in ' + Math.ceil((24 * 3600 * 1000 - (Date.now() - last)) / 3600000) + 'h.' });
const depth = b.scope === 'direct' ? 1 : 3;
const levels = await accounts.downline(s.email, depth);
const recipients = [...new Set(levels.flatMap(L => L.members.map(m => m.email)).filter(Boolean))];
if (!recipients.length) return json(res, 400, { error: 'No one in your line to message yet.' });
const memberId = s.memberId || await auth.refreshMemberId(s);
await messages.deliver(memberId, s.email, recipients, subject, body);
// email each recipient too (best-effort; never blocks the on-site delivery)
if (mailer.hasKey()) {
const who = (await accounts.byEmail(s.email));
const from = who && who.username ? '@' + who.username : 'your sponsor';
for (const to of recipients) {
mailer.send(to, 'Message from ' + from + ': ' + subject,
plain + '\n\n— sent via your InstantAdPay upline. Read it in your dashboard: https://instantadpay.com/my#line')
.catch(() => {});
}
}
return json(res, 200, { ok: true, sent: recipients.length });
}
// -- sponsor messages: this member's inbox from their upline
if (p === '/api/my/messages' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const items = await messages.inbox(s.email);
const names = await accounts.namesForMembers([...new Set(items.map(i => i.fromMember).filter(Boolean))]);
for (const i of items) i.fromName = (i.fromMember && names[i.fromMember]) ? '@' + names[i.fromMember]
: i.fromMember ? 'member #' + i.fromMember : 'your upline';
return json(res, 200, { items, unread: items.filter(i => !i.read).length });
}
m = /^\/api\/my\/messages\/(\d+)\/read$/.exec(p);
if (m && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
return json(res, 200, await messages.markRead(s.email, m[1]));
}
// -- line banner: the member's viral slot on welcome tours + their wall
if (p === '/api/my/linebanner' && req.method === 'POST') {
const s = await auth.fromRequest(req);