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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user