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:
@@ -217,7 +217,17 @@ async function createThread({ team, actor, type, title, body }) {
|
||||
authorUsername: actor.username,
|
||||
bodyHtml: cleaned,
|
||||
})
|
||||
return { ok: true, threadId, postId }
|
||||
// `notify` is what the CONTROLLER needs to fan a notification out, and it is a
|
||||
// separate key rather than more fields on the result because the controller
|
||||
// spreads the result straight into the response body — a notification's excerpt
|
||||
// is not part of the API's answer to "did my post save".
|
||||
//
|
||||
// The notification itself is fired from the controller and not from here, on
|
||||
// this file's own rule (see the header): everything in it takes an
|
||||
// already-resolved access decision and reads no membership table. The fan-out
|
||||
// reads both, so importing it here would make the forum model transitively
|
||||
// depend on exactly what it exists not to touch.
|
||||
return { ok: true, threadId, postId, notify: { threadId, title, type, bodyHtml: cleaned } }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,7 +266,11 @@ async function createPost({ team, threadId, actor, body }) {
|
||||
authorUsername: actor.username,
|
||||
bodyHtml: cleaned,
|
||||
})
|
||||
return { ok: true, threadId, postId }
|
||||
// The thread's OWN title and type, not the reply's — a reply has neither, and
|
||||
// what a recipient needs to know is which conversation moved. `type` is always
|
||||
// 'discussion' here (an announcement takes no replies) and is carried anyway so
|
||||
// the controller has one shape to hand the fan-out from both routes.
|
||||
return { ok: true, threadId, postId, notify: { threadId, title: thread.title, type: thread.type, bodyHtml: cleaned } }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,6 +33,7 @@ const teamsDb = require('./teams.db')
|
||||
const teamProvider = require('./teamProvider')
|
||||
const moderation = require('./teamModeration.model')
|
||||
const activity = require('./teamActivity.model')
|
||||
const teamNotify = require('../../utils/teamNotify')
|
||||
const { slugify, uniqueSlug } = require('./teamSlug')
|
||||
const settings = require('../settings/settings.model')
|
||||
const log = require('../../utils/logger')('teams')
|
||||
@@ -191,6 +192,34 @@ async function logRosterActivity(team, { joined, left, promoted, demoted }) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The push half of the same roster run (TEAMS.md §6.2, phase 6).
|
||||
*
|
||||
* **At most one tickle per stream per run, not one per member.** A tickle is
|
||||
* content-free — it says "something happened in this Team" and the app pulls the
|
||||
* rest — so five people joining in one sweep is five identical notifications and
|
||||
* one piece of information. The feed above is per-member because it is a record;
|
||||
* this is per-run because it is a nudge.
|
||||
*
|
||||
* **Suppressed on a Team's FIRST roster, exactly as the feed is**, and this is the
|
||||
* half where it matters more: importing a 155-member guild would otherwise wake
|
||||
* every one of their phones. `roster_synced_at IS NULL` is the same condition, read
|
||||
* from the same row before the same stamp moves.
|
||||
*
|
||||
* Never throws — the fan-out swallows its own failures, and this adds the guard
|
||||
* for anything the surrounding read could raise. A roster sync is the source of
|
||||
* truth; a notification about it is not.
|
||||
*/
|
||||
async function notifyRoster(team, { joined, promoted, demoted }) {
|
||||
if (!team.roster_synced_at) return
|
||||
try {
|
||||
if (joined.length > 0) await teamNotify.memberJoined(team)
|
||||
if (promoted.length > 0 || demoted.length > 0) await teamNotify.leadershipChanged(team)
|
||||
} catch (err) {
|
||||
log.warn('roster notification not sent', { teamId: team.id, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync one Team's roster and leadership. Gates 3 and 4 live here.
|
||||
*
|
||||
@@ -285,8 +314,10 @@ async function syncRoster(team) {
|
||||
}
|
||||
|
||||
await teamsDb.recount(team.id)
|
||||
// Read before `markRosterSynced` moves the stamp this decision turns on.
|
||||
// Both read before `markRosterSynced` moves the stamp their first-roster
|
||||
// suppression turns on.
|
||||
await logRosterActivity(team, { joined, left: left.filter(Boolean), promoted, demoted })
|
||||
await notifyRoster(team, { joined, promoted, demoted })
|
||||
await teamsDb.markRosterSynced(team.id)
|
||||
return true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user