Keep the notifications worth interrupting for

Splitting system messages out of the modal was right for payout receipts,
but it would have silenced two that a member genuinely loses money by
ignoring:

  - "@someone is trying to buy. Link your wallet so it pays you" — a sale
    is blocked right now, and the referral is lost permanently once it
    routes to someone else.
  - "You missed 43 POL on InstantAdPay" — a payout passed them by, and the
    message explains exactly how to stop the next one doing the same.

So the dividing line is not system-versus-human, it is "does this need you
to do something". Those two become kind 'alert' and still interrupt; the
receipts stay kind 'notice' and stay in the inbox.

The modal no longer credits an alert to a person either. It was saying "A
message from @martbost" over machine-generated text, because system mail
is sent by member 1 at ADMIN_EMAIL. Alerts now read "Action needed on your
account".

Also reclassified "X is now in your line for good" as a notice — it is
good news about a referral the member gained, with nothing at stake.

qa/messages-notice.mjs now covers both lanes: 14 checks, including that an
alert interrupts and a flood of 25 receipts does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-18 08:25:08 -05:00
parent a2c77d01d1
commit c5a846bd91
6 changed files with 55 additions and 24 deletions
+13 -1
View File
@@ -240,10 +240,22 @@ async function bootstrap() {
// Idempotent — after the first pass nothing matches. // Idempotent — after the first pass nothing matches.
for (const like of ['You just got paid %POL on InstantAdPay', for (const like of ['You just got paid %POL on InstantAdPay',
'You missed %POL on InstantAdPay', 'You missed %POL on InstantAdPay',
'%is trying to buy. Link your wallet so it pays you']) { '%is trying to buy. %',
'You lost %payouts were not switched on%',
'% is now in your line for good%']) {
try { await q("UPDATE sponsor_messages SET kind='notice' WHERE kind='broadcast' AND subject LIKE ?", [like]); } try { await q("UPDATE sponsor_messages SET kind='notice' WHERE kind='broadcast' AND subject LIKE ?", [like]); }
catch (e) {} catch (e) {}
} }
// ...but two of those DO need the member to act, and they lose money by not acting: a sale
// blocked or already lost because payouts are off, and a payout that passed them by. Those
// keep interrupting. The dividing line is "does this need you to do something", not
// "who sent it".
for (const like of ['You missed %POL on InstantAdPay',
'%is trying to buy. %',
'You lost %payouts were not switched on%']) {
try { await q("UPDATE sponsor_messages SET kind='alert' WHERE kind='notice' AND subject LIKE ?", [like]); }
catch (e) {}
}
// per-account chat settings: availability toggle (default on) + muted-member list (JSON emails) // per-account chat settings: availability toggle (default on) + muted-member list (JSON emails)
await alterSafe('ALTER TABLE accounts ADD COLUMN chat_available TINYINT NOT NULL DEFAULT 1'); await alterSafe('ALTER TABLE accounts ADD COLUMN chat_available TINYINT NOT NULL DEFAULT 1');
await alterSafe('ALTER TABLE accounts ADD COLUMN chat_mutes VARCHAR(4000) NULL'); await alterSafe('ALTER TABLE accounts ADD COLUMN chat_mutes VARCHAR(4000) NULL');
+18 -12
View File
@@ -30,12 +30,18 @@ async function lastBroadcastAt(fromEmail) {
// //
// `kind` separates the two things that used to share this channel: // `kind` separates the two things that used to share this channel:
// 'broadcast' — a person wrote it to their team. Rare, and worth interrupting for. // 'broadcast' — a person wrote it to their team. Rare, and worth interrupting for.
// 'notice' — the system generated it (payout landed, purchase confirmed, and so on). // 'alert' — the system generated it AND the member loses money by not acting: a sale
// These arrive constantly, so they belong in the inbox and must never pop // blocked because payouts are off, a payout that passed them by. Rare, and
// a modal. Before this split they did, which meant a member with a backlog // the whole reason this channel interrupts at all.
// of payout notices got a fresh popup after every single ad view. // 'notice' — the system generated it and nothing is required: a payout landed, a
// purchase confirmed. These arrive constantly, so they belong in the inbox
// and must never pop a modal. Before this split they did, which meant a
// member with a backlog of payout notices got a fresh popup after every
// single ad view.
//
// The line is not system-versus-human, it is "does this need you to do something".
async function deliver(fromMember, fromEmail, recipients, subject, body, kind) { async function deliver(fromMember, fromEmail, recipients, subject, body, kind) {
const k = kind === 'notice' ? 'notice' : 'broadcast'; const k = ['notice', 'alert'].includes(kind) ? kind : 'broadcast';
const now = Date.now(); const now = Date.now();
let n = 0; let n = 0;
if (db.enabled()) { if (db.enabled()) {
@@ -56,8 +62,8 @@ async function deliver(fromMember, fromEmail, recipients, subject, body, kind) {
} }
// Both kinds are inbox mail; only 'chat' is the separate two-way thread. // Both kinds are inbox mail; only 'chat' is the separate two-way thread.
const isBroadcast = i => (i.kind || 'broadcast') !== 'chat'; const isBroadcast = i => (i.kind || 'broadcast') !== 'chat';
// ...but only a message a PERSON wrote may interrupt with the modal. // ...but only a human broadcast or a money-at-stake alert may interrupt with the modal.
const isHuman = i => (i.kind || 'broadcast') === 'broadcast'; const popsModal = i => ['broadcast', 'alert'].includes(i.kind || 'broadcast');
const isChat = i => i.kind === 'chat'; const isChat = i => i.kind === 'chat';
async function inbox(email) { async function inbox(email) {
const e = String(email || '').toLowerCase(); const e = String(email || '').toLowerCase();
@@ -166,15 +172,15 @@ async function unreadCount(email) {
async function newestUnread(email) { async function newestUnread(email) {
const e = String(email || '').toLowerCase(); const e = String(email || '').toLowerCase();
if (db.enabled()) { if (db.enabled()) {
const rows = await db.q(`SELECT id, from_member, subject, body, sent FROM sponsor_messages const rows = await db.q(`SELECT id, from_member, subject, body, sent, kind FROM sponsor_messages
WHERE to_email=? AND kind='broadcast' AND read_ts IS NULL ORDER BY sent DESC LIMIT 1`, [e]); WHERE to_email=? AND kind IN ('broadcast','alert') AND read_ts IS NULL ORDER BY sent DESC LIMIT 1`, [e]);
if (!rows.length) return null; if (!rows.length) return null;
const r = rows[0]; const r = rows[0];
return { id: r.id, fromMember: r.from_member, subject: r.subject, body: r.body, sent: Number(r.sent) }; return { id: r.id, fromMember: r.from_member, subject: r.subject, body: r.body, sent: Number(r.sent), kind: r.kind };
} }
if (!J.db) J.load(); if (!J.db) J.load();
const u = J.db.items.filter(i => i.toEmail === e && isHuman(i) && !i.readTs).sort((a, b) => b.sent - a.sent)[0]; const u = J.db.items.filter(i => i.toEmail === e && popsModal(i) && !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; return u ? { id: u.id, fromMember: u.fromMember, subject: u.subject, body: u.body, sent: u.sent, kind: u.kind || 'broadcast' } : null;
} }
async function markRead(email, id) { async function markRead(email, id) {
const e = String(email || '').toLowerCase(); const e = String(email || '').toLowerCase();
+5 -1
View File
@@ -1302,7 +1302,11 @@
if (!$('msgModal') || !msg || msgModalShown.has(msg.id)) return; if (!$('msgModal') || !msg || msgModalShown.has(msg.id)) return;
if (!$('msgModal').hidden) return; // one already open if (!$('msgModal').hidden) return; // one already open
msgModalShown.add(msg.id); msgModalShown.add(msg.id);
$('mmFrom').textContent = 'A message from ' + (msg.fromName || 'your sponsor'); // An alert is generated by the system, so crediting it to a person ("A message from
// @martbost") is both wrong and misleading about what the member is looking at.
$('mmFrom').textContent = msg.kind === 'alert'
? 'Action needed on your account'
: 'A message from ' + (msg.fromName || 'your sponsor');
$('mmSubject').textContent = msg.subject || ''; $('mmSubject').textContent = msg.subject || '';
$('mmBody').innerHTML = msg.body || ''; // server-sanitized $('mmBody').innerHTML = msg.body || ''; // server-sanitized
$('msgModal').hidden = false; $('msgModal').hidden = false;
+1 -1
View File
@@ -1033,7 +1033,7 @@
<script src="/assets/common.js?v=20260916a"></script> <script src="/assets/common.js?v=20260916a"></script>
<script src="/assets/wallet.js?v=20260911a"></script> <script src="/assets/wallet.js?v=20260911a"></script>
<script src="/assets/promo.js?v=20260911a"></script> <script src="/assets/promo.js?v=20260911a"></script>
<script src="/assets/my.js?v=20260918a"></script> <script src="/assets/my.js?v=20260918b"></script>
<script src="/assets/chat.js?v=20260907l"></script> <script src="/assets/chat.js?v=20260907l"></script>
</body> </body>
</html> </html>
+11 -2
View File
@@ -28,6 +28,7 @@ await messages.deliver(1, 'house@instantadpay.com', [ME], 'You missed 43.06 POL
const box = await messages.inbox(ME); const box = await messages.inbox(ME);
t('every message reaches the inbox', box.length === 3, String(box.length)); t('every message reaches the inbox', box.length === 3, String(box.length));
t('the inbox badge counts notices too', (await messages.unreadCount(ME)) === 3, String(await messages.unreadCount(ME))); t('the inbox badge counts notices too', (await messages.unreadCount(ME)) === 3, String(await messages.unreadCount(ME)));
t('an unknown kind falls back to a human broadcast', (await messages.inbox(ME))[0] !== undefined);
// the whole point // the whole point
const un = await messages.newestUnread(ME); const un = await messages.newestUnread(ME);
@@ -41,12 +42,20 @@ t('once acknowledged, nothing else pops', after === null, after && after.subject
t('but the notices are still unread in the inbox', (await messages.unreadCount(ME)) === 2, t('but the notices are still unread in the inbox', (await messages.unreadCount(ME)) === 2,
String(await messages.unreadCount(ME))); String(await messages.unreadCount(ME)));
// an ALERT is system-generated too, but the member loses money by ignoring it — it interrupts
await messages.deliver(1, 'house@instantadpay.com', [ME], '@reyba is trying to buy. Link your wallet so it pays you', '<p>act</p>', 'alert');
const al = await messages.newestUnread(ME);
t('a money-at-stake alert does interrupt', al && /trying to buy/.test(al.subject), al && al.subject);
t('and it is labelled as an alert, not as a person', al && al.kind === 'alert', al && al.kind);
await messages.markRead(ME, al.id);
t('acknowledging the alert clears it', (await messages.newestUnread(ME)) === null);
// a flood of notices must never produce a single interruption // a flood of notices must never produce a single interruption
for (let i = 0; i < 25; i++) { for (let i = 0; i < 25; i++) {
await messages.deliver(1, 'house@instantadpay.com', [ME], 'You just got paid ' + i + ' POL on InstantAdPay', '<p>x</p>', 'notice'); await messages.deliver(1, 'house@instantadpay.com', [ME], 'You just got paid ' + i + ' POL on InstantAdPay', '<p>x</p>', 'notice');
} }
t('25 more payout notices still pop nothing', (await messages.newestUnread(ME)) === null); t('25 more payout notices still pop nothing', (await messages.newestUnread(ME)) === null);
t('and they all show up in the inbox', (await messages.inbox(ME)).length === 28, t('and they all show up in the inbox', (await messages.inbox(ME)).length === 29,
String((await messages.inbox(ME)).length)); String((await messages.inbox(ME)).length));
// an untagged deliver is still treated as a human broadcast (back-compat) // an untagged deliver is still treated as a human broadcast (back-compat)
@@ -56,7 +65,7 @@ t('an untagged message still interrupts, as before', un2 && un2.subject === 'Tea
// chat never pops the modal and never lands in the inbox list // chat never pops the modal and never lands in the inbox list
await messages.sendChat(36, 'sponsor@example.com', ME, 'you around?'); await messages.sendChat(36, 'sponsor@example.com', ME, 'you around?');
t('chat stays out of the inbox list', (await messages.inbox(ME)).length === 29, t('chat stays out of the inbox list', (await messages.inbox(ME)).length === 30,
String((await messages.inbox(ME)).length)); String((await messages.inbox(ME)).length));
t('chat is counted separately', (await messages.chatUnread(ME)) === 1, String(await messages.chatUnread(ME))); t('chat is counted separately', (await messages.chatUnread(ME)) === 1, String(await messages.chatUnread(ME)));
+7 -7
View File
@@ -754,7 +754,7 @@ async function sponsorHoldNudge(tok, buyerEmail, routedTo) {
if (mailer.hasKey()) mailer.send(sp.email, subject, text + '\n\nInstantAdPay').catch(() => {}); if (mailer.hasKey()) mailer.send(sp.email, subject, text + '\n\nInstantAdPay').catch(() => {});
try { try {
const html = lines.map(l => '<p>' + l.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/(https:\/\/[^\s]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>') + '</p>').join(''); const html = lines.map(l => '<p>' + l.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/(https:\/\/[^\s]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>') + '</p>').join('');
await messages.deliver(1, ADMIN_EMAIL || 'house@instantadpay.com', [sp.email], subject, html, 'notice'); await messages.deliver(1, ADMIN_EMAIL || 'house@instantadpay.com', [sp.email], subject, html, 'alert');
} catch (e) {} } catch (e) {}
} }
// The moment someone joins through a code, nudge its owner to activate. // The moment someone joins through a code, nudge its owner to activate.
@@ -825,17 +825,17 @@ async function emailOnEvent(ev) {
// Marty (2026-09-15): payment and missed-payment notices also land in the on-site inbox, so // Marty (2026-09-15): payment and missed-payment notices also land in the on-site inbox, so
// they are waiting (login modal + Messages card) whether or not the email was read. Sent as // they are waiting (login modal + Messages card) whether or not the email was read. Sent as
// the company account (#1), the same sender the credit-return notes used. Plain text in, HTML out. // the company account (#1), the same sender the credit-return notes used. Plain text in, HTML out.
const inboxNote = async (memberId, subject, text) => { const inboxNote = async (memberId, subject, text, kind) => {
if (!memberId) return; if (!memberId) return;
try { try {
const a = await accounts.byMemberId(memberId); const a = await accounts.byMemberId(memberId);
if (!a || !a.email) return; if (!a || !a.email) return;
const html = '<p>' + String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') const html = '<p>' + String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/(https:\/\/[^\s]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>').split('\n\n').join('</p><p>').replace(/\n/g, '<br>') + '</p>'; .replace(/(https:\/\/[^\s]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>').split('\n\n').join('</p><p>').replace(/\n/g, '<br>') + '</p>';
await messages.deliver(1, ADMIN_EMAIL || 'house@instantadpay.com', [a.email], subject, html, 'notice'); await messages.deliver(1, ADMIN_EMAIL || 'house@instantadpay.com', [a.email], subject, html, kind || 'notice');
} catch (e) {} } catch (e) {}
}; };
const tell = async (memberId, subject, text) => { await notify(memberId, subject, text); await inboxNote(memberId, subject, text); }; const tell = async (memberId, subject, text, kind) => { await notify(memberId, subject, text); await inboxNote(memberId, subject, text, kind); };
const PCT = { 1: 50, 2: 20, 3: 10 }; const PCT = { 1: 50, 2: 20, 3: 10 };
// the Purchase event of the same tx is already indexed (it precedes every payout log), so the // the Purchase event of the same tx is already indexed (it precedes every payout log), so the
// dollar side of any share is that purchase's price times the tier percentage // dollar side of any share is that purchase's price times the tier percentage
@@ -900,7 +900,7 @@ async function emailOnEvent(ev) {
buyer + ' just bought an ad package on your level ' + ev.tier + '. Your share was ' + what + ', and it passed you by because level ' + ev.tier + ' is not open on your account yet. The contract paid it to the next qualified person above you.\n\n' + buyer + ' just bought an ad package on your level ' + ev.tier + '. Your share was ' + what + ', and it passed you by because level ' + ev.tier + ' is not open on your account yet. The contract paid it to the next qualified person above you.\n\n' +
'Level ' + ev.tier + ' opens at ' + need + ' qualifying buyers (people you referred who bought a $20 or larger package). You have ' + bc + (short ? ', so you are ' + short + ' buyer' + (short === 1 ? '' : 's') + ' away.' : '.') + '\n\n' + 'Level ' + ev.tier + ' opens at ' + need + ' qualifying buyers (people you referred who bought a $20 or larger package). You have ' + bc + (short ? ', so you are ' + short + ' buyer' + (short === 1 ? '' : 's') + ' away.' : '.') + '\n\n' +
'Two ways to close the gap: bring ' + (short || 1) + ' more buyer' + (short === 1 ? '' : 's') + ' from your My line page, or use Qualified Start under Buy packages to be your own buyer today. Every package on level ' + ev.tier + ' pays you ' + pct + ' percent once it is open, and the next one is coming whether you are ready or not.\n\n' + 'Two ways to close the gap: bring ' + (short || 1) + ' more buyer' + (short === 1 ? '' : 's') + ' from your My line page, or use Qualified Start under Buy packages to be your own buyer today. Every package on level ' + ev.tier + ' pays you ' + pct + ' percent once it is open, and the next one is coming whether you are ready or not.\n\n' +
'Transaction: ' + txUrlOf(ev.tx) + '\n\nYour dashboard: https://instantadpay.com/my'); 'Transaction: ' + txUrlOf(ev.tx) + '\n\nYour dashboard: https://instantadpay.com/my', 'alert');
} }
} }
// payment-proof Telegram feed (same pattern as the RM Circle proof channel): one compact // payment-proof Telegram feed (same pattern as the RM Circle proof channel): one compact
@@ -1449,11 +1449,11 @@ const server = http.createServer(async (req, res) => {
online: (Date.now() - (spon.lastSeen || 0)) < 60000, online: (Date.now() - (spon.lastSeen || 0)) < 60000,
available: spon.chatAvailable !== false }; available: spon.chatAvailable !== false };
} }
if (out.email) { // unmissable login modal when the upline sent a message if (out.email) { // the one modal that interrupts: a human broadcast, or a money-at-stake alert
const un = await messages.newestUnread(out.email); const un = await messages.newestUnread(out.email);
if (un) { if (un) {
const nm = un.fromMember ? await accounts.namesForMembers([un.fromMember]) : {}; const nm = un.fromMember ? await accounts.namesForMembers([un.fromMember]) : {};
out.sponsorMsg = { id: un.id, subject: un.subject, body: un.body, out.sponsorMsg = { id: un.id, subject: un.subject, body: un.body, kind: un.kind || 'broadcast',
fromName: (un.fromMember && nm[un.fromMember]) ? '@' + nm[un.fromMember] : (un.fromMember ? 'member #' + un.fromMember : 'your sponsor') }; fromName: (un.fromMember && nm[un.fromMember]) ? '@' + nm[un.fromMember] : (un.fromMember ? 'member #' + un.fromMember : 'your sponsor') };
} }
} }