Sponsor Chat: two-way member<->sponsor messaging (presence, availability, mute)

Evolves the one-way broadcast into real support threads, keeping broadcast alongside.
- messages.js: kind='chat' rides sponsor_messages; sendChat/thread/threadList/
  markChatRead/chatUnread; broadcast inbox + login modal scoped to kind='broadcast'
- accounts.js: presence (last_seen), chat availability toggle, per-member mutes;
  sponsorOf() + isDownlineOf() resolvers
- server.js: /api/my/chat/{send,thread,threads,available,mute} + /api/my/ping
  heartbeat; dashboard emits chatUnread/chatAvailable/sponsor; auth = direct
  sponsor up, any downline down, or an existing thread; email only when offline
  and not mid-chat
- my.html/my.js/site.css: slide-in chat drawer (threads list + live thread,
  4s poll), presence dot, Message buttons on direct rows, Message-my-sponsor
  quick action, availability switch in Profile
- db.js: additive migrations (kind, pair index, chat_available, chat_mutes)
- chatbot.js: canned answer + AI fact for Sponsor Chat

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-07 05:24:23 -05:00
parent 23136f4ba0
commit ade0a13064
8 changed files with 475 additions and 18 deletions
+108
View File
@@ -464,6 +464,17 @@ 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) { // sponsor chat: presence heartbeat + unread + my availability + direct sponsor
accounts.touchSeen(out.email).catch(() => {});
out.chatUnread = await messages.chatUnread(out.email);
out.chatAvailable = (acct && acct.chatAvailable !== false);
const spon = await accounts.sponsorOf(out.email);
if (spon && spon.email) out.sponsor = {
email: spon.email,
name: spon.username ? '@' + spon.username : (spon.memberId ? 'member #' + spon.memberId : 'your sponsor'),
online: (Date.now() - (spon.lastSeen || 0)) < 60000,
available: spon.chatAvailable !== false };
}
// achievement milestones (same ladder as the Overview stepper) + one-time credit bonuses
{
const bc = out.buyerCount || 0;
@@ -654,6 +665,103 @@ const server = http.createServer(async (req, res) => {
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
return json(res, 200, await messages.markRead(s.email, m[1]));
}
// ── SPONSOR CHAT (two-way): presence-aware 1:1 threads up/down the line ──
const chatOnline = ts => (Date.now() - (ts || 0)) < 60000;
const chatName = a => !a ? 'member' : (a.username ? '@' + a.username : (a.memberId ? 'member #' + a.memberId : 'member'));
// lightweight presence heartbeat (called on a timer while the dashboard is open)
if (p === '/api/my/ping' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (s && s.email) accounts.touchSeen(s.email).catch(() => {});
return json(res, 200, { ok: true });
}
if (p === '/api/my/chat/send' && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const me = s.email.toLowerCase();
const b = await readBody(req);
const to = String(b.to || '').trim().toLowerCase();
const text = String(b.body || '').replace(/<[^>]*>/g, '').replace(/\s+$/, '').slice(0, 2000).trim();
if (!to || to === me) return json(res, 400, { error: 'Pick who to message.' });
if (!text) return json(res, 400, { error: 'Write a message first.' });
const target = await accounts.byEmail(to);
if (!target) return json(res, 404, { error: 'No such member.' });
// authorize: existing thread, my direct sponsor, or someone in my downline
let ok = (await messages.thread(me, to, 0, 1)).length > 0;
if (!ok) { const spon = await accounts.sponsorOf(me); ok = !!(spon && spon.email && spon.email.toLowerCase() === to); }
if (!ok) ok = await accounts.isDownlineOf(me, to);
if (!ok) return json(res, 403, { error: 'You can only message your direct sponsor or someone in your line.' });
if ((await accounts.getMutes(to)).map(x => String(x).toLowerCase()).includes(me))
return json(res, 403, { error: 'They are not accepting messages from you right now.' });
const memberId = s.memberId || await auth.refreshMemberId(s);
const msg = await messages.sendChat(memberId, me, to, text);
// email only when they are offline AND I have not messaged them in ~10 min (no mid-chat spam)
try {
if (mailer.hasKey() && !chatOnline(target.lastSeen)) {
const mine = (await messages.thread(me, to, 0, 400)).filter(x => x.id !== msg.id && String(x.fromEmail).toLowerCase() === me);
const lastMineTs = mine.length ? mine[mine.length - 1].sent : 0;
if (Date.now() - lastMineTs > 10 * 60 * 1000) {
const who = await accounts.byEmail(me);
const from = who && who.username ? '@' + who.username : 'someone in your InstantAdPay line';
mailer.send(to, 'New message from ' + from,
text.slice(0, 400) + '\n\n— reply in your dashboard: https://instantadpay.com/my').catch(() => {});
}
}
} catch (e) {}
return json(res, 200, { ok: true, message: { id: msg.id, sent: msg.sent, fromMe: true, body: text } });
}
if (p === '/api/my/chat/thread' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const me = s.email.toLowerCase();
const other = String(u.searchParams.get('with') || '').trim().toLowerCase();
const after = Number(u.searchParams.get('after')) || 0;
if (!other) return json(res, 400, { error: 'Who with?' });
// may view a thread I'm party to (existing), or one I'm allowed to start
let ok = (await messages.thread(me, other, 0, 1)).length > 0;
if (!ok) { const spon = await accounts.sponsorOf(me); ok = !!(spon && spon.email && spon.email.toLowerCase() === other); }
if (!ok) ok = await accounts.isDownlineOf(me, other);
if (!ok) return json(res, 403, { error: 'Not your conversation.' });
const msgs = await messages.thread(me, other, after, 300);
await messages.markChatRead(me, other);
accounts.touchSeen(me).catch(() => {});
const oa = await accounts.byEmail(other);
const iMute = (await accounts.getMutes(me)).map(x => String(x).toLowerCase()).includes(other);
const theyMuteMe = (await accounts.getMutes(other)).map(x => String(x).toLowerCase()).includes(me);
return json(res, 200, {
messages: msgs.map(x => ({ id: x.id, fromMe: String(x.fromEmail).toLowerCase() === me, body: x.body, sent: x.sent })),
otherName: chatName(oa), online: oa ? chatOnline(oa.lastSeen) : false,
available: oa ? oa.chatAvailable !== false : true, iMute, blocked: theyMuteMe,
canMute: await accounts.isDownlineOf(me, other) });
}
if (p === '/api/my/chat/threads' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const list = await messages.threadList(s.email.toLowerCase());
for (const t of list) {
const a = await accounts.byEmail(t.email);
t.name = chatName(a); t.online = a ? chatOnline(a.lastSeen) : false;
}
list.sort((x, y) => (y.last.sent || 0) - (x.last.sent || 0));
const meAcct = await accounts.byEmail(s.email);
return json(res, 200, { threads: list, available: meAcct ? meAcct.chatAvailable !== false : true });
}
if (p === '/api/my/chat/available' && 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);
return json(res, 200, await accounts.setChatAvailable(s.email, !!b.available));
}
if (p === '/api/my/chat/mute' && 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 target = String(b.email || '').trim().toLowerCase();
if (!target) return json(res, 400, { error: 'Who?' });
const allowed = (await accounts.isDownlineOf(s.email, target)) || (await messages.thread(s.email.toLowerCase(), target, 0, 1)).length > 0;
if (!allowed) return json(res, 403, { error: 'You can only mute someone in your line.' });
return json(res, 200, await accounts.setMute(s.email, target, !!b.muted));
}
// -- featured rotation: the live featured links + dilution stats
if (p === '/api/featured' && req.method === 'GET') {
const items = await ads.serveFeatured();