diff --git a/accounts.js b/accounts.js index 8b9b02c..8f18808 100644 --- a/accounts.js +++ b/accounts.js @@ -261,6 +261,30 @@ async function setUsername(email, username) { async function setMemberId(email, id) { return impl().setMemberId(normEmail(email), Number(id) || 0); } async function namesForMembers(ids) { return impl().namesForMembers([...new Set(ids)].filter(n => n > 0)); } async function listByReferrer(refs) { return impl().listByReferrer(refs || []); } +// the tokens a member could have been joined under (code, username, or numeric id) +function refTokens(a) { + return [a.code, a.username, a.memberId ? String(a.memberId) : null].filter(Boolean).map(String); +} +// walk the downline breadth-first to `depth` levels. Returns +// [{ level, members:[pub...] }]; emails are included on pub but the CALLER +// decides who may see them (directs only, per product rule). +async function downline(email, depth = 3) { + const root = await byEmail(email); + if (!root) return []; + const seen = new Set([String(root.email)]); + const levels = []; + let frontier = [root]; + for (let lvl = 1; lvl <= depth; lvl++) { + const toks = [...new Set(frontier.flatMap(refTokens))]; + if (!toks.length) break; + const kids = (await listByReferrer(toks)).filter(k => !seen.has(String(k.email))); + if (!kids.length) break; + kids.forEach(k => seen.add(String(k.email))); + levels.push({ level: lvl, members: kids }); + frontier = kids; + } + return levels; +} async function linkWallet(email, address) { const a = normAddr(address); if (!/^0x[0-9a-f]{40}$/.test(a)) return { error: 'Bad wallet address.' }; @@ -269,6 +293,6 @@ async function linkWallet(email, address) { async function count() { return impl().count(); } module.exports = { init, signup, login, ensure, byEmail, byAddress, byCode, byUsername, - setUsername, setMemberId, namesForMembers, listByReferrer, linkWallet, count, + setUsername, setMemberId, namesForMembers, listByReferrer, downline, linkWallet, count, setLineBanner: (e, b, t) => impl().setLineBanner(String(e || '').toLowerCase(), b, t), byMemberId: id => impl().byMemberId(id) }; diff --git a/ads.js b/ads.js index 5875491..a372a21 100644 Binary files a/ads.js and b/ads.js differ diff --git a/db.js b/db.js index 92e9ca7..ae6a31f 100644 --- a/db.js +++ b/db.js @@ -110,6 +110,17 @@ async function bootstrap() { await alterSafe('ALTER TABLE campaigns ADD COLUMN height INT NULL'); // banner size (IAB) → also NAS height await alterSafe('ALTER TABLE campaigns ADD COLUMN nas_ad_id INT NULL'); // syndicated NAS sponsorads.ID await alterSafe('ALTER TABLE campaigns ADD COLUMN nas_served INT NOT NULL DEFAULT 0'); // NAS impressions already reconciled into spend + await q(`CREATE TABLE IF NOT EXISTS sponsor_messages ( + id INT AUTO_INCREMENT PRIMARY KEY, + from_member INT NOT NULL, + from_email VARCHAR(190) NOT NULL, + to_email VARCHAR(190) NOT NULL, + subject VARCHAR(160) NOT NULL, + body TEXT NULL, + sent BIGINT NOT NULL, + read_ts BIGINT NULL, + INDEX (to_email, read_ts), INDEX (from_email, sent) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); await q(`CREATE TABLE IF NOT EXISTS burns ( id VARCHAR(32) PRIMARY KEY, member_id INT NOT NULL, diff --git a/messages.js b/messages.js new file mode 100644 index 0000000..79bc3d3 --- /dev/null +++ b/messages.js @@ -0,0 +1,96 @@ +// Sponsor → downline messages: team comms delivered to members' on-site inbox +// (and by email). Distinct from solo ADS (which are paid and earn credits). +// Dual-mode: MySQL when DATABASE_URL is set, else a JSON file in the volume. +const fs = require('fs'); +const path = require('path'); +const db = require('./db'); + +let DATA_DIR = null; +const J = { + db: null, + FILE: () => path.join(DATA_DIR, 'sponsor-messages.json'), + load() { try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) { this.db = { nextId: 1, items: [] }; } }, + save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} } +}; +function init(opts) { DATA_DIR = opts.dataDir; } +const DAY = 86400000; + +// has this sponsor already broadcast within the last 24h? (1/day cap) +async function lastBroadcastAt(fromEmail) { + const e = String(fromEmail || '').toLowerCase(); + if (db.enabled()) { + const r = await db.q('SELECT MAX(sent) m FROM sponsor_messages WHERE from_email=?', [e]); + return r.length && r[0].m ? Number(r[0].m) : 0; + } + if (!J.db) J.load(); + const mine = J.db.items.filter(i => i.fromEmail === e).map(i => i.sent); + return mine.length ? Math.max(...mine) : 0; +} +// deliver one message to many recipients (already-resolved emails). Returns count. +async function deliver(fromMember, fromEmail, recipients, subject, body) { + const now = Date.now(); + let n = 0; + if (db.enabled()) { + for (const to of recipients) { + await db.q('INSERT INTO sponsor_messages (from_member,from_email,to_email,subject,body,sent) VALUES (?,?,?,?,?,?)', + [fromMember || 0, fromEmail, to, subject, body, now]); + n++; + } + } else { + if (!J.db) J.load(); + for (const to of recipients) { + J.db.items.push({ id: J.db.nextId++, fromMember: fromMember || 0, fromEmail, toEmail: to, subject, body, sent: now, readTs: 0 }); + n++; + } + J.save(); + } + return n; +} +async function inbox(email) { + const e = String(email || '').toLowerCase(); + if (db.enabled()) { + const rows = await db.q(`SELECT id, from_member, subject, body, sent, read_ts FROM sponsor_messages + WHERE to_email=? ORDER BY sent DESC LIMIT 100`, [e]); + return rows.map(r => ({ id: r.id, fromMember: r.from_member, subject: r.subject, body: r.body, + sent: Number(r.sent), read: !!r.read_ts })); + } + if (!J.db) J.load(); + return J.db.items.filter(i => i.toEmail === e).sort((a, b) => b.sent - a.sent).slice(0, 100) + .map(i => ({ id: i.id, fromMember: i.fromMember, subject: i.subject, body: i.body, sent: i.sent, read: !!i.readTs })); +} +async function unreadCount(email) { + const e = String(email || '').toLowerCase(); + if (db.enabled()) { + const r = await db.q('SELECT COUNT(*) n FROM sponsor_messages WHERE to_email=? AND read_ts IS NULL', [e]); + return r[0].n; + } + if (!J.db) J.load(); + return J.db.items.filter(i => i.toEmail === e && !i.readTs).length; +} +// the newest unread message (for the login modal) +async function newestUnread(email) { + const e = String(email || '').toLowerCase(); + if (db.enabled()) { + const rows = await db.q(`SELECT id, from_member, subject, body, sent FROM sponsor_messages + WHERE to_email=? AND read_ts IS NULL ORDER BY sent DESC LIMIT 1`, [e]); + if (!rows.length) return null; + const r = rows[0]; + return { id: r.id, fromMember: r.from_member, subject: r.subject, body: r.body, sent: Number(r.sent) }; + } + if (!J.db) J.load(); + const u = J.db.items.filter(i => i.toEmail === e && !i.readTs).sort((a, b) => b.sent - a.sent)[0]; + return u ? { id: u.id, fromMember: u.fromMember, subject: u.subject, body: u.body, sent: u.sent } : null; +} +async function markRead(email, id) { + const e = String(email || '').toLowerCase(); + if (db.enabled()) { + await db.q('UPDATE sponsor_messages SET read_ts=? WHERE id=? AND to_email=? AND read_ts IS NULL', [Date.now(), Number(id), e]); + return { ok: true }; + } + if (!J.db) J.load(); + const i = J.db.items.find(x => x.id === Number(id) && x.toEmail === e); + if (i && !i.readTs) { i.readTs = Date.now(); J.save(); } + return { ok: true }; +} + +module.exports = { init, lastBroadcastAt, deliver, inbox, unreadCount, newestUnread, markRead }; diff --git a/public/assets/my.js b/public/assets/my.js index dd3c7a6..ba8c2a1 100644 --- a/public/assets/my.js +++ b/public/assets/my.js @@ -146,6 +146,7 @@ const tc = $('dbTeamChip'), wk = (d.referrals || []).filter(r => Date.now() - new Date(r.joined) < 6048e5).length; if (tc && wk) { tc.hidden = false; tc.textContent = '+' + wk + ' this week'; } setInboxBadge(d.inboxUnread || 0); + if (d.sponsorMsg) showSponsorModal(d.sponsorMsg); loadCharts(d); $('nextMove').textContent = nextMove(d); renderSteps(d); @@ -228,6 +229,7 @@ if ($('boTitle')) $('boTitle').textContent = TITLES[name]; if (name === 'earn') setEarnSub(earnSub); // refresh whichever sub-tab is active if (name === 'profile') loadLineBanner(); + if (name === 'line') { loadLineage(); loadUplineMessages(); } document.getElementById('memberArea').classList.remove('side-open'); // close mobile drawer if (location.hash !== '#' + name) history.replaceState(null, '', '#' + name); } @@ -537,6 +539,65 @@ } $('vidStartBtn').addEventListener('click', () => loadVideoAd()); + // ── unmissable sponsor-message modal on sign-in ── + function showSponsorModal(msg) { + if (!$('msgModal')) return; + $('mmFrom').textContent = 'A message from ' + (msg.fromName || 'your sponsor'); + $('mmSubject').textContent = msg.subject || ''; + $('mmBody').innerHTML = msg.body || ''; // server-sanitized + $('msgModal').hidden = false; + $('mmAck').onclick = async () => { + $('msgModal').hidden = true; + try { await fetch('/api/my/messages/' + msg.id + '/read', { method: 'POST' }); } catch (e) {} + }; + } + + // ── downline lineage + sponsor broadcast + upline messages ── + async function loadLineage() { + try { + const r = await (await fetch('/api/my/line')).json(); + const el = $('lineageWrap'); + if (r.error || !r.levels || !r.levels.every) return; + if (!r.levels.length || !r.levels.some(L => L.members.length)) { + el.innerHTML = '
No one in your downline yet. Share your link and it fills in here.
'; + return; + } + el.innerHTML = r.levels.map(L => !L.members.length ? '' : + '