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