fix(safety): team notifications skip members who block the author
All checks were successful
PR Checks / client-build (pull_request) Successful in 1m14s
PR Checks / server-tests (pull_request) Successful in 6m4s
PR Checks / bot-tests (pull_request) Successful in 56s

#233 left this open: the forum hid a blocked author's posts from a
member, but the member was still pushed, put in the inbox and emailed
about them.

- teamNotify.forumPost excludes every member who blocks the post's
  author, or the member who started the thread (the forum 404s that
  thread for them). One recipient list, so push, the inbox and email
  skip the same people. userSafety.blockersOf answers it, and a staff
  author is blocked by nobody (S8). A failed lookup notifies without it.
- The daily digest applies each recipient's forumFilter at send time:
  blocked authors, blocked thread starters, hidden threads and posts.
  digestPostsSince now also returns the thread's created_by.
- A reply's notify payload carries threadAuthorUserId.

The Team activity feed needed nothing: forum posts are never written to
it (membership, renames and module game events only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-31 00:23:18 -05:00
parent f13b8fe1fe
commit 673701c91b
11 changed files with 167 additions and 6 deletions

View File

@@ -145,7 +145,12 @@ test('the digest mail names a deleted author "[deleted player]"', async () => {
const worker = require('../src/utils/teamDigestWorker')
const teamNotify = require('../src/model/teams/teamNotify.model')
const templates = require('../src/engagement/templates')
const safety = require('../src/model/safety/userSafety.model')
let items = null
patch(safety, 'forumFilter', async () => ({
blockedUserIds: new Set(),
hidden: { team_forum_thread: new Set(), team_forum_post: new Set() },
}))
patch(teamNotify, 'digestPostsSince', async () => [
{ id: 1, thread_id: 5, body_html: '<p>a</p>', created_at: new Date(), author_user_id: null, author_username: 'Ravenna', title: 'Raid night', type: 'discussion' },
{ id: 2, thread_id: 5, body_html: '<p>b</p>', created_at: new Date(), author_user_id: 4, author_username: 'Thorne', title: 'Raid night', type: 'discussion' },

View File

@@ -28,6 +28,7 @@ const notifyDb = require('../src/model/teams/teamNotify.db')
const forumSettings = require('../src/model/teams/teamForumSettings.model')
const pushDispatch = require('../src/utils/pushDispatch')
const mailer = require('../src/utils/mailer')
const safety = require('../src/model/safety/userSafety.model')
const saved = new Map()
@@ -59,6 +60,7 @@ beforeEach(() => {
patch(notifyDb, 'emailRecipients', async () => [])
patch(forumSettings, 'forumsEnabled', async () => true)
patch(mailer, 'isConfigured', async () => false)
patch(safety, 'blockersOf', async () => [])
})
afterEach(restore)

View File

@@ -34,6 +34,7 @@ const rulesDb = require('../src/model/engagement/engagementRules.db')
const sendsDb = require('../src/model/engagement/engagementSends.db')
const templates = require('../src/engagement/templates')
const registries = require('../src/modules/registries')
const safety = require('../src/model/safety/userSafety.model')
const saved = new Map()
@@ -71,6 +72,8 @@ function stub({
recipients = [10, 11],
emailRows = [],
sendOk = true,
blockers = {}, // authorUserId → the members who block them
readerFilter = {}, // userId → { blocked: [...], hiddenPosts: [...], hiddenThreads: [...] }
} = {}) {
sent = []
emitted = []
@@ -108,6 +111,14 @@ function stub({
// No module registered: the default in most tests, so the link-building ones
// have to opt in and the absence is exercised rather than assumed.
patch(registries, 'registeredTeamProvider', () => null)
patch(safety, 'blockersOf', async (authorId) => blockers[authorId] || [])
patch(safety, 'forumFilter', async (userId) => {
const f = readerFilter[userId] || {}
return {
blockedUserIds: new Set(f.blocked || []),
hidden: { team_forum_thread: new Set(f.hiddenThreads || []), team_forum_post: new Set(f.hiddenPosts || []) },
}
})
}
beforeEach(() => stub())
@@ -142,6 +153,32 @@ test('the author is not among the tickled, nor among the emitted audience', asyn
assert.deepEqual(emitted[0].recipientUserIds, [11])
})
// USER_SAFETY.md §4.1: the forum leaves a blocked author's posts out of a reader's
// view, so the notification must not point that reader at one.
test('a member who blocks the author is not tickled or told', async () => {
stub({ recipients: [10, 11, 12], blockers: { 10: [12] } })
await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
assert.deepEqual(sent[0].userIds, [11])
assert.deepEqual(emitted[0].recipientUserIds, [11])
})
test('a member who blocks the thread’s starter is not told about replies in it', async () => {
// A reply by 11 in a thread 10 started; 12 blocks 10, so cannot open the thread.
stub({ recipients: [10, 11, 12], blockers: { 10: [12] } })
await notify.forumPost({
team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 11, threadAuthorUserId: 10,
})
assert.deepEqual(sent[0].userIds, [10])
assert.deepEqual(emitted[0].recipientUserIds, [10])
})
test('a block lookup that fails still notifies everyone else', async () => {
patch(safety, 'blockersOf', async () => { throw new Error('database is on fire') })
const res = await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
assert.equal(res.push, 1)
assert.deepEqual(emitted[0].recipientUserIds, [11])
})
test('roster events fire one tickle for the run, not one per member', async () => {
await notify.memberJoined(TEAM)
await notify.leadershipChanged(TEAM)
@@ -312,6 +349,29 @@ test('the digest renders the rule’s digest template, not its instant one', asy
assert.match(mails[0].rendered.subject, /^\[notify\.digest\]/)
})
test('a digest leaves out what the forum leaves out for that reader', async () => {
stub({ readerFilter: { 11: { blocked: [40, 50], hiddenPosts: [3], hiddenThreads: [9] } } })
stubDigest({
posts: [
{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>kept</p>', author_user_id: 20, author_username: 'twenty' },
{ id: 2, thread_id: 7, title: 'Raid', body_html: '<p>blocked author</p>', author_user_id: 40, author_username: 'forty' },
{ id: 3, thread_id: 7, title: 'Raid', body_html: '<p>hidden post</p>', author_user_id: 20, author_username: 'twenty' },
{ id: 4, thread_id: 9, title: 'Hidden', body_html: '<p>hidden thread</p>', author_user_id: 20, author_username: 'twenty' },
{ id: 5, thread_id: 8, title: 'Theirs', body_html: '<p>blocked starter</p>', author_user_id: 20, author_username: 'twenty', thread_created_by: 50 },
],
})
await digest.tick(new Date())
assert.deepEqual(mails[0].rendered.values.items.map((i) => i.excerpt), ['kept'])
})
test('a digest whose every post is filtered out sends nothing and stamps nothing', async () => {
stub({ readerFilter: { 11: { blocked: [40] } } })
stubDigest({ posts: [{ id: 2, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_user_id: 40, author_username: 'forty' }] })
const res = await digest.tick(new Date())
assert.equal(res.sent, 0)
assert.equal(world.stamped.length, 0)
})
test('a recipient with nothing new gets no mail and no stamp', async () => {
stubDigest({ posts: [] })
const res = await digest.tick(new Date())

View File

@@ -201,6 +201,20 @@ test('a signed-out reader has no blocks and no hides', async () => {
assert.equal((await safety.forumFilter(null)).blockedUserIds.size, 0)
})
test('blockersOf answers who blocks an author, and nobody for a staff author (S8)', async () => {
patch(safetyDb, 'blockersOf', async () => [21, 22])
patch(safetyDb, 'userById', async (id) => ({ id, username: 'rusty', role: 'player' }))
assert.deepEqual(await safety.blockersOf(12), [21, 22])
patch(safetyDb, 'userById', async (id) => ({ id, username: 'mod', role: 'moderator' }))
assert.deepEqual(await safety.blockersOf(2), [])
// A deleted account or no author at all: nothing to look up.
patch(safetyDb, 'userById', async () => null)
assert.deepEqual(await safety.blockersOf(99), [])
assert.deepEqual(await safety.blockersOf(null), [])
})
test('staffAmong takes only positive integer ids', async () => {
let asked
patch(safetyDb, 'staffAmong', async (ids) => { asked = ids; return [] })