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
+51
View File
@@ -36,6 +36,7 @@ const pub = a => a ? { email: a.email, sponsorRef: a.sponsorRef || '', code: a.c
username: a.username || null, memberId: a.memberId || 0,
lineBannerUrl: a.lineBannerUrl || null, lineTargetUrl: a.lineTargetUrl || null,
avatarUrl: a.avatarUrl || null, bio: a.bio || null, socials: a.socials || null,
chatAvailable: a.chatAvailable === false ? false : true, lastSeen: a.lastSeen || 0,
address: a.address || null, created: a.created } : null;
const USER_RE = /^[a-zA-Z0-9_]{3,20}$/;
const normUser = u => String(u || '').trim().toLowerCase();
@@ -117,6 +118,15 @@ const J = {
this.save();
return { ok: true, account: pub(acct) };
},
async touchSeen(e) { const a = this.db.byEmail[e]; if (a) { a.lastSeen = Date.now(); this.save(); } },
async setChatAvailable(e, v) { const a = this.db.byEmail[e]; if (!a) return { error: 'No such account.' }; a.chatAvailable = !!v; this.save(); return { ok: true, available: !!v }; },
async getMutes(e) { const a = this.db.byEmail[e]; return (a && Array.isArray(a.chatMutes)) ? a.chatMutes : []; },
async setMute(owner, target, muted) {
const a = this.db.byEmail[owner]; if (!a) return { error: 'No such account.' };
const set = new Set((a.chatMutes || []).map(x => String(x).toLowerCase())); const t = String(target).toLowerCase();
if (muted) set.add(t); else set.delete(t);
a.chatMutes = [...set].slice(0, 500); this.save(); return { ok: true, muted: !!muted };
},
async byMemberId(id) {
const a = Object.values(this.db.byEmail).find(x => x.memberId === Number(id));
return a ? pub(a) : null;
@@ -157,6 +167,7 @@ const rowPub = r => r ? pub({ email: r.email, sponsorRef: r.sponsor_ref, code: r
username: r.username, memberId: r.member_id || 0,
lineBannerUrl: r.line_banner_url, lineTargetUrl: r.line_target_url,
avatarUrl: r.avatar_url, bio: r.bio, socials: r.socials,
chatAvailable: r.chat_available === 0 ? false : true, lastSeen: Number(r.last_seen || 0),
address: r.address, created: Number(r.created) }) : null;
const D = {
async signup(e, password, ref) {
@@ -212,6 +223,19 @@ const D = {
if (socials !== undefined) await db.q('UPDATE accounts SET socials=? WHERE email=?', [socials || null, e]);
return { ok: true, account: await this.byEmail(e) };
},
async touchSeen(e) { await db.q('UPDATE accounts SET last_seen=? WHERE email=?', [Date.now(), e]); },
async setChatAvailable(e, v) { await db.q('UPDATE accounts SET chat_available=? WHERE email=?', [v ? 1 : 0, e]); return { ok: true, available: !!v }; },
async getMutes(e) {
const r = await db.q('SELECT chat_mutes FROM accounts WHERE email=?', [e]);
try { const arr = JSON.parse((r[0] && r[0].chat_mutes) || '[]'); return Array.isArray(arr) ? arr : []; } catch (x) { return []; }
},
async setMute(owner, target, muted) {
const cur = await this.getMutes(owner);
const set = new Set(cur.map(x => String(x).toLowerCase())); const t = String(target).toLowerCase();
if (muted) set.add(t); else set.delete(t);
await db.q('UPDATE accounts SET chat_mutes=? WHERE email=?', [JSON.stringify([...set].slice(0, 500)), owner]);
return { ok: true, muted: !!muted };
},
async byMemberId(id) {
const r = await db.q('SELECT * FROM accounts WHERE member_id=?', [Number(id)]);
return rowPub(r[0]);
@@ -309,8 +333,35 @@ async function linkWallet(email, address) {
}
async function count() { return impl().count(); }
// resolve a member's DIRECT sponsor account (the token they joined under)
async function sponsorOf(email) {
const root = await byEmail(email);
if (!root || !root.sponsorRef) return null;
const ref = String(root.sponsorRef);
let s = null;
try { s = await byCode(ref); } catch (e) {}
if (!s) { try { s = await byUsername(ref); } catch (e) {} }
if (!s && /^\d+$/.test(ref)) { try { s = await byMemberId(Number(ref)); } catch (e) {} }
return s || null;
}
// is `memberEmail` anywhere in `sponsorEmail`'s downline (to `depth` levels)?
async function isDownlineOf(sponsorEmail, memberEmail, depth = 3) {
const target = String(memberEmail || '').toLowerCase();
const levels = await downline(sponsorEmail, depth);
return levels.some(L => L.members.some(m => String(m.email || '').toLowerCase() === target));
}
async function getChatSettings(email) {
const a = await byEmail(email);
return { available: a ? a.chatAvailable !== false : true, mutes: await impl().getMutes(String(email || '').toLowerCase()) };
}
module.exports = { init, signup, login, ensure, byEmail, byAddress, byCode, byUsername,
setUsername, setMemberId, namesForMembers, listByReferrer, downline, linkWallet, count,
setLineBanner: (e, b, t) => impl().setLineBanner(String(e || '').toLowerCase(), b, t),
setProfile: (e, a, bio, socials) => impl().setProfile(String(e || '').toLowerCase(), a, bio, socials),
touchSeen: e => impl().touchSeen(String(e || '').toLowerCase()),
setChatAvailable: (e, v) => impl().setChatAvailable(String(e || '').toLowerCase(), v),
getMutes: e => impl().getMutes(String(e || '').toLowerCase()),
setMute: (o, t, m) => impl().setMute(String(o || '').toLowerCase(), String(t || '').toLowerCase(), m),
sponsorOf, isDownlineOf, getChatSettings,
byMemberId: id => impl().byMemberId(id) };