fix(safety): team notifications skip members who block the author
#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:
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user