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