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
+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(() => {});
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('');
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) {}
}
// 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
// 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.
const inboxNote = async (memberId, subject, text) => {
const inboxNote = async (memberId, subject, text, kind) => {
if (!memberId) return;
try {
const a = await accounts.byMemberId(memberId);
if (!a || !a.email) return;
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>';
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) {}
};
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 };
// 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
@@ -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' +
'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' +
'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
@@ -1449,11 +1449,11 @@ const server = http.createServer(async (req, res) => {
online: (Date.now() - (spon.lastSeen || 0)) < 60000,
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);
if (un) {
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') };
}
}