c5a846bd91
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>
76 lines
4.0 KiB
JavaScript
76 lines
4.0 KiB
JavaScript
// The sign-in modal must interrupt for a message a PERSON wrote, and never for a
|
|
// system notice. Regression for the 2026-09-18 report: a member viewing daily ads got a
|
|
// fresh popup after every single view, because every payout notice was stored as a
|
|
// 'broadcast' and loadDashboard() re-pops on each refresh.
|
|
//
|
|
// node qa/messages-notice.mjs (JSON mode, no DB needed)
|
|
import { createRequire } from 'node:module';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
const require = createRequire(import.meta.url);
|
|
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'iap-msg-'));
|
|
const messages = require('../messages.js');
|
|
messages.init({ dataDir: dir });
|
|
|
|
const ok = [], bad = [];
|
|
const t = (n, c, extra) => { (c ? ok : bad).push(n + (c || !extra ? '' : ' -> ' + extra)); };
|
|
|
|
const ME = 'earner@example.com';
|
|
|
|
// a real sponsor writing to their team
|
|
await messages.deliver(36, 'sponsor@example.com', [ME], 'Welcome to my line', '<p>hi</p>');
|
|
// the system, reporting money that moved
|
|
await messages.deliver(1, 'house@instantadpay.com', [ME], 'You just got paid 40.8923 POL on InstantAdPay', '<p>paid</p>', 'notice');
|
|
await messages.deliver(1, 'house@instantadpay.com', [ME], 'You missed 43.06 POL on InstantAdPay', '<p>missed</p>', 'notice');
|
|
|
|
const box = await messages.inbox(ME);
|
|
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('an unknown kind falls back to a human broadcast', (await messages.inbox(ME))[0] !== undefined);
|
|
|
|
// the whole point
|
|
const un = await messages.newestUnread(ME);
|
|
t('the modal picks the human message, not the payout notice',
|
|
un && un.subject === 'Welcome to my line', un && un.subject);
|
|
|
|
// acknowledge it; nothing should be left to interrupt with
|
|
await messages.markRead(ME, un.id);
|
|
const after = await messages.newestUnread(ME);
|
|
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,
|
|
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
|
|
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');
|
|
}
|
|
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 === 29,
|
|
String((await messages.inbox(ME)).length));
|
|
|
|
// an untagged deliver is still treated as a human broadcast (back-compat)
|
|
await messages.deliver(36, 'sponsor@example.com', [ME], 'Team call tonight', '<p>call</p>');
|
|
const un2 = await messages.newestUnread(ME);
|
|
t('an untagged message still interrupts, as before', un2 && un2.subject === 'Team call tonight', un2 && un2.subject);
|
|
|
|
// chat never pops the modal and never lands in the inbox list
|
|
await messages.sendChat(36, 'sponsor@example.com', ME, 'you around?');
|
|
t('chat stays out of the inbox list', (await messages.inbox(ME)).length === 30,
|
|
String((await messages.inbox(ME)).length));
|
|
t('chat is counted separately', (await messages.chatUnread(ME)) === 1, String(await messages.chatUnread(ME)));
|
|
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
console.log('PASS ' + ok.length);
|
|
for (const b of bad) console.log('FAIL ' + b);
|
|
process.exit(bad.length ? 1 : 0);
|