feat(teams): fire the four events, and the routes that configure them
The roster sync tickles at most ONCE per stream per run, not once per member: a tickle is content-free, so five people joining in one sweep is five identical notifications and one piece of information. Suppressed on a Team's FIRST roster, the same condition the activity feed uses and the half where it matters more — importing a 155-member guild would otherwise wake every one of their phones. Forum notifications fire from the CONTROLLER, not from the forum model. That file takes an already-resolved access decision and reads no membership table by design; the fan-out reads both to compute its recipients, so calling it from inside would make the forum model transitively depend on exactly what its header says it must not touch. The model returns a `notify` key the controller destructures out before the response, so the API's answer to "did my post save" is unchanged. `pageUrlTemplate` joins the team provider — the one thing phase 6 found that the design of record had not anticipated. Phase 3 left core with no Team page and therefore no way to LINK to one, so a notification email could name a Team and not take you to it. It is data rather than a callback: a function would put a module hook on the mail path to produce a string that never varies. Relative paths only, and protocol-relative is refused with absolute. The unsubscribe endpoint is the only write in the public tier and the only route with no `siteMode` — the reader is in their mail client, and the mail went out before the site went into maintenance. POST always answers 200, valid token or forged: distinguishing them would be an oracle for which (user, Team) pairs exist. GET redirects and acts on nothing, so a mail client's link scanner cannot mute Teams nobody asked to leave. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@ const forumSettings = require('../../../model/teams/teamForumSettings.model')
|
||||
const uploads = require('../../../model/teams/teamForumUploads.model')
|
||||
const reports = require('../../../model/reports/contentReports.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const teamNotify = require('../../../utils/teamNotify')
|
||||
|
||||
const log = require('../../../utils/logger')('teams')
|
||||
|
||||
@@ -86,6 +87,34 @@ async function viewerFor(ctx, user) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan a new thread or reply out to the Team (TEAMS.md Part 6, phase 6).
|
||||
*
|
||||
* **Here rather than in the forum model**, because the model takes an
|
||||
* already-resolved access decision and reads no membership table by design, and
|
||||
* the fan-out reads both to compute its recipients. A notification call inside the
|
||||
* model would make it transitively depend on what its own header says it must not.
|
||||
*
|
||||
* **Awaited, and it still cannot fail the request.** `teamNotify.forumPost` catches
|
||||
* everything and returns; awaiting it costs the response the time of one recipient
|
||||
* query plus, in `immediate` mode, the SMTP calls — which is why the alternative
|
||||
* (fire-and-forget) is tempting and wrong here: an un-awaited rejection in an
|
||||
* Express handler is an unhandled rejection, and the tests would have no moment at
|
||||
* which to assert the fan-out happened.
|
||||
*/
|
||||
async function announce(ctx, actor, notify) {
|
||||
if (!notify) return
|
||||
await teamNotify.forumPost({
|
||||
team: ctx.team,
|
||||
threadId: notify.threadId,
|
||||
threadTitle: notify.title,
|
||||
type: notify.type,
|
||||
authorUserId: actor.id,
|
||||
authorName: actor.username,
|
||||
bodyHtml: notify.bodyHtml,
|
||||
})
|
||||
}
|
||||
|
||||
// ── threads ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function listThreads(req, res) {
|
||||
@@ -151,13 +180,14 @@ async function createThread(req, res) {
|
||||
return res.status(403).json({ message: 'Only Team leaders may post announcements' })
|
||||
}
|
||||
|
||||
const result = await forum.createThread({
|
||||
const { notify, ...result } = await forum.createThread({
|
||||
team: ctx.team,
|
||||
actor: req.user,
|
||||
type,
|
||||
title: req.body.title,
|
||||
body: req.body.body,
|
||||
})
|
||||
if (result.ok) await announce(ctx, req.user, notify)
|
||||
return send(res, result)
|
||||
} catch (err) {
|
||||
return fail(res, err, 'create thread')
|
||||
@@ -170,12 +200,14 @@ async function createPost(req, res) {
|
||||
const ctx = await resolveForum(req)
|
||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||
|
||||
return send(res, await forum.createPost({
|
||||
const { notify, ...result } = await forum.createPost({
|
||||
team: ctx.team,
|
||||
threadId: Number(req.params.id),
|
||||
actor: req.user,
|
||||
body: req.body.body,
|
||||
}))
|
||||
})
|
||||
if (result.ok) await announce(ctx, req.user, notify)
|
||||
return send(res, result)
|
||||
} catch (err) {
|
||||
return fail(res, err, 'create post')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user