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
+18 -12
View File
@@ -30,12 +30,18 @@ async function lastBroadcastAt(fromEmail) {
//
// `kind` separates the two things that used to share this channel:
// '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).
// 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.
// 'alert' — the system generated it AND the member loses money by not acting: a sale
// blocked because payouts are off, a payout that passed them by. Rare, and
// the whole reason this channel interrupts at all.
// '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) {
const k = kind === 'notice' ? 'notice' : 'broadcast';
const k = ['notice', 'alert'].includes(kind) ? kind : 'broadcast';
const now = Date.now();
let n = 0;
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.
const isBroadcast = i => (i.kind || 'broadcast') !== 'chat';
// ...but only a message a PERSON wrote may interrupt with the modal.
const isHuman = i => (i.kind || 'broadcast') === 'broadcast';
// ...but only a human broadcast or a money-at-stake alert may interrupt with the modal.
const popsModal = i => ['broadcast', 'alert'].includes(i.kind || 'broadcast');
const isChat = i => i.kind === 'chat';
async function inbox(email) {
const e = String(email || '').toLowerCase();
@@ -166,15 +172,15 @@ async function unreadCount(email) {
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 kind='broadcast' AND read_ts IS NULL ORDER BY sent DESC LIMIT 1`, [e]);
const rows = await db.q(`SELECT id, from_member, subject, body, sent, kind FROM sponsor_messages
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;
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();
const u = J.db.items.filter(i => i.toEmail === e && isHuman(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;
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, kind: u.kind || 'broadcast' } : null;
}
async function markRead(email, id) {
const e = String(email || '').toLowerCase();