Files
website/server/src/utils/teamDigestWorker.js
wtclaude 673701c91b
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
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
2026-10-31 00:23:18 -05:00

283 lines
12 KiB
JavaScript

// ── Team forum digest worker (TEAMS.md §6.4; migrated in ENGAGEMENT.md Phase 6) ──
//
// Daily, per (user, Team): "here is what you missed". The same in-process shape as
// utils/teamActivityPrune and utils/announceWorker — setInterval + unref + stop(),
// wired into server.js start/shutdown. There is no cron in this stack.
//
// **It computes at send time and keeps no queue, and Phase 6 deliberately did not
// change that.** §4.2b's decision was to generalize this worker's STATE, not its
// absence of one. Three properties fall out of computing at send time, and they
// are why the design chose it over a pending-items table — and, now, over the
// engine's own outbox:
//
// 1. A deployment that was down for two days sends ONE correct digest, not two
// days of replay.
// 2. A post a moderator hid after it was written is not in the query, so it is
// not in the mail. A queue written at publish time would have to remember to
// go back and remove it.
// 3. A user who lost forum access between the post and the send is no longer in
// the recipient set, so they are not emailed content they can no longer read.
// This is the one that would have been a security bug.
//
// An outbox row carries a snapshot of the payload taken at emit time and has none
// of the three. That is why `engine.subscribedTo` enqueues only `instant`
// recipients and leaves `digest` to this file.
//
// **What DID change is everything around the query.** The state is
// `engagement_digest_state` rather than a column on the preferences row; the body
// is the `notify.digest` template an operator can edit rather than a literal in
// `mailer`; the unsubscribe link is a v2 token naming the email channel; and the
// whole worker is gated on an ENABLED rule, so an operator who switches Team
// email off switches off both halves of it rather than the immediate half only.
//
// **The first run is delayed, for the same reason the prune's is**: a boot that is
// crash-looping must not send mail on every loop.
const teamNotify = require('../model/teams/teamNotify.model')
const forumSettings = require('../model/teams/teamForumSettings.model')
const rulesDb = require('../model/engagement/engagementRules.db')
const sendsDb = require('../model/engagement/engagementSends.db')
const digestDb = require('../model/engagement/engagementDigest.db')
const templates = require('../engagement/templates')
const emailChannel = require('../engagement/emailChannel')
const mailer = require('./mailer')
const notify = require('./teamNotify')
const safety = require('../model/safety/userSafety.model')
const { DELETED_PLAYER } = require('./displayAuthor')
const log = require('./logger')('team-digest')
const INTERVAL_MS = Number(process.env.TEAM_DIGEST_INTERVAL_MS) || 24 * 60 * 60 * 1000
const FIRST_RUN_MS = Number(process.env.TEAM_DIGEST_DELAY_MS) || 10 * 60 * 1000
// How far back a recipient with no `last_digest_at` reaches. A first digest must
// not be the entire history of the forum, and this is also the clamp that stops a
// long outage producing one enormous mail — `since` is never older than this,
// however long ago the last send was.
const MAX_LOOKBACK_MS = 7 * 24 * 60 * 60 * 1000
// Posts per digest. Beyond this the mail is a list, not a summary, and the link
// to the Team is the better answer.
const MAX_ITEMS = 20
// The two triggers a Team forum digest summarises. A rule on either, enabled and
// naming the email channel, is what turns this worker on.
const DIGEST_TRIGGERS = ['team.forum.post', 'team.announcement']
// The digest's own template key, and the rule's `template_keys.digest` overrides
// it. A digest is not the same message as the instant mail and must not silently
// borrow `template_keys.email`: that template is written for one event and would
// render a day's worth of posts as a single missing `{{threadTitle}}`.
const DEFAULT_TEMPLATE = 'notify.digest'
let timer = null
let firstRun = null
const clampSince = (last, now) => {
const floor = new Date(now.getTime() - MAX_LOOKBACK_MS)
// No previous send: reach back one interval, not to the floor. A brand-new
// subscriber's first digest should cover today, not the past week.
if (!last) return new Date(now.getTime() - INTERVAL_MS)
const at = new Date(last)
return at < floor ? floor : at
}
/**
* The enabled email rule that authorises Team forum digests, or null.
*
* **The gate is why this worker did not simply keep running.** Phase 6's decision
* 3 is that Team notifications become rules an operator turns on; if the immediate
* mail were rule-gated and the digest were not, disabling the rule would stop one
* kind of Team mail and leave a daily summary arriving indefinitely — which reads
* as the switch being broken.
*
* The FIRST matching rule wins, and the tie-break is not interesting because what
* is read off it is one template key. Two rules disagreeing about the template of
* a digest neither of them describes is a configuration an operator can see in the
* rules list.
*/
async function digestRule() {
for (const triggerId of DIGEST_TRIGGERS) {
const rules = await rulesDb.enabledForTrigger(triggerId)
const rule = rules.find((r) => (r.channels || []).includes('email'))
if (rule) return rule
}
return null
}
/**
* One recipient's digest for one Team. Returns true if a mail went out.
*
* Stamps `last_digest_at` ONLY on a successful send. A failed SMTP call leaves the
* stamp alone so the next run tries the same window again — the alternative,
* stamping first, silently eats a day of somebody's notifications every time the
* mail provider has a bad minute.
*/
async function sendOne(team, recipient, rule, now) {
const scopeKey = notify.scopeKey(team)
const since = clampSince(recipient.last_digest_at, now)
const all = await teamNotify.digestPostsSince(team.id, since, MAX_ITEMS)
// What the forum would leave out for THIS recipient stays out of their mail too:
// posts by an account they block, posts in a thread whose starter they block, and
// anything they hid (USER_SAFETY.md §4.1). Same filter, read at send time like
// the rest of the digest.
const filter = await safety.forumFilter(recipient.user_id)
const posts = all.filter((p) =>
!filter.hidden.team_forum_post.has(Number(p.id))
&& !filter.hidden.team_forum_thread.has(Number(p.thread_id))
&& !(p.author_user_id != null && filter.blockedUserIds.has(Number(p.author_user_id)))
&& !(p.thread_created_by != null && filter.blockedUserIds.has(Number(p.thread_created_by))))
// Nothing new for THIS recipient — which is not the same as nothing new for the
// Team, because each recipient has their own `since`. No mail, and no stamp: a
// stamp here would move the window past posts they have not been told about.
if (posts.length === 0) return false
const label = notify.teamLabel(team)
const unsub = emailChannel.unsubscribeUrls(recipient.user_id, scopeKey)
const key = (rule && rule.template_keys && rule.template_keys.digest) || DEFAULT_TEMPLATE
const rendered = await templates.renderByKey(key, {
periodLabel: `${label}: ${posts.length} new post${posts.length === 1 ? '' : 's'}`,
intro: `Since your last digest, ${posts.length} new post${posts.length === 1 ? '' : 's'} in ${label}:`,
items: posts.map((p) => ({
// A digest goes to members, who see a deleted author as "[deleted player]"
// (ACCOUNT_DELETION.md AD5). The post outlives its author on purpose; the
// name in it does not reach a member's inbox.
heading: `${p.title} — ${p.author_user_id === null ? DELETED_PLAYER : p.author_username || 'someone'}`,
excerpt: notify.excerpt(p.body_html),
url: notify.threadPath(team, p.thread_id),
})),
scopeUrl: notify.teamPagePath(team),
...unsub,
})
if (!rendered) {
log.warn('digest template is missing and has no shipped default', { key })
return false
}
const result = await mailer.sendNotification({ to: recipient.email, rendered, ...unsub })
// Recorded in `engagement_sends` like every other message the platform sends,
// which is G15's whole point: "did user X get the digest?" was unanswerable
// before this phase because the digest went out through a sender that wrote
// nothing down. `outbox_id` is null because a digest has no outbox row — see
// this file's header — and that null is the honest record of a different path,
// not a missing value.
await sendsDb
.record({
outbox_id: null,
rule_id: rule ? rule.id : null,
trigger_id: DIGEST_TRIGGERS[0],
user_id: recipient.user_id,
channel: 'email',
transport: result.transport ?? null,
// The same hash the instant path writes, and it has to be the same
// function: a bounce (Phase 9) arrives with an address and is matched
// against this column, so a digest row without one is a delivery that
// cannot be correlated. Found by reading the send log on the live rig,
// where the instant row had a hash and the digest row beside it did not.
address_hash: emailChannel.hashAddress(recipient.email),
status: result.ok ? 'sent' : 'failed',
detail: result.ok ? null : result.detail,
})
.catch((err) => log.warn('digest send not logged', { message: err.message }))
if (!result.ok) return false
await digestDb.stamp(recipient.user_id, 'email', scopeKey, now)
return true
}
/**
* One sweep. Never throws — it runs on a timer with nobody to catch it.
*
* Returns a small summary so a test (and the log line) can tell "nothing to do"
* from "did nothing".
*/
async function tick(now = new Date()) {
const summary = { teams: 0, sent: 0, skipped: null }
try {
// Three cheap gates before any query that costs anything. Forums switched off
// means the content this digest summarises is not readable on the site
// either; un-configured email means there is no sink at all (§6.4); and no
// enabled rule means the operator has not turned Team email on.
if (!(await forumSettings.forumsEnabled())) return { ...summary, skipped: 'forums-disabled' }
if (!(await mailer.isConfigured())) return { ...summary, skipped: 'email-unconfigured' }
const rule = await digestRule()
if (!rule) return { ...summary, skipped: 'no-enabled-rule' }
const floor = new Date(now.getTime() - MAX_LOOKBACK_MS)
const teams = await teamNotify.teamsWithForumActivitySince(floor)
summary.teams = teams.length
for (const team of teams) {
// eslint-disable-next-line no-await-in-loop
const rows = await teamNotify.emailRecipients(team.id)
const wanted = rows.filter((x) => x.email_mode === 'digest')
if (!wanted.length) continue
// The stamps now live in their own table, so they are read here rather than
// arriving on the recipient row. One query per Team, not one per recipient.
// eslint-disable-next-line no-await-in-loop
const stamps = await digestDb.stampsFor(
wanted.map((r) => r.user_id),
'email',
notify.scopeKey(team),
)
for (const r of wanted) {
try {
// Serial, like the instant path and for the same reason: one SMTP
// conversation at a time against a provider with its own rate limits.
// eslint-disable-next-line no-await-in-loop
const recipient = { ...r, last_digest_at: stamps.get(Number(r.user_id)) ?? null }
// eslint-disable-next-line no-await-in-loop
if (await sendOne(team, recipient, rule, now)) summary.sent += 1
} catch (err) {
// One recipient's failure must not end the sweep for the rest. The
// unstamped window means the next run retries this one.
log.warn('digest send failed', { teamId: team.id, userId: r.user_id, message: err.message })
}
}
}
if (summary.sent > 0) log.info('team digests sent', summary)
return summary
} catch (err) {
log.error('team digest sweep failed', { message: err.message })
return { ...summary, skipped: 'error' }
}
}
function start() {
if (timer || firstRun) return timer
firstRun = setTimeout(() => {
firstRun = null
tick()
timer = setInterval(() => { tick() }, INTERVAL_MS)
if (timer.unref) timer.unref()
}, FIRST_RUN_MS)
if (firstRun.unref) firstRun.unref()
log.info('team forum digests started', { intervalMs: INTERVAL_MS, firstRunMs: FIRST_RUN_MS })
return timer
}
function stop() {
if (firstRun) {
clearTimeout(firstRun)
firstRun = null
}
if (timer) {
clearInterval(timer)
timer = null
}
}
module.exports = {
start,
stop,
tick,
sendOne,
clampSince,
digestRule,
MAX_LOOKBACK_MS,
MAX_ITEMS,
DIGEST_TRIGGERS,
DEFAULT_TEMPLATE,
}