feat(teams): the activity feed, its two writers and its retention
TEAMS.md Part 4. `team_activity` takes items from two sources and treats them
identically on the read path: core writes its own membership and rename items
with source='core', and a module pushes game items through
`ctx.teams.activity.push`, which stops throwing and starts working.
Core writing here too is deliberate — the rendering path is exercised by core's
own content from day one, so the feed is never empty on a deployment whose
module pushes nothing.
Three rules shape the model:
- core never composes a summary. It arrives already rendered and is stored
verbatim; core cannot phrase "gained 15,000 gold" for a game whose
vocabulary it does not know.
- visibility fails closed. An item with no stated visibility is `members`.
- a push never throws at its call site. It is called from inside a game-event
handler, and a storage problem of core's must not become the module's
control flow.
Core emits four of the five kinds §4.2 names — `core.forum.thread` has nothing
to emit it until the forum lands in phase 4 — and emits none of them for a
Team's FIRST roster: importing a 155-member guild is one Team arriving, not 155
people joining, and a join per member would bury every real event under the
import and reach the row cap on day one.
Retention ships with the feed rather than after someone notices. A nightly
worker applies an age horizon and a per-Team row cap, both settings; either
alone has a hole, since age lets one busy guild write a million rows inside the
window and a cap keeps a dead Team's feed forever.
The sync now reads member ROWS rather than keys, replacing the `memberKeys`
call rather than adding to it: the feed needs each changing member's display
name and prior `is_leader`, and the upsert is about to overwrite both.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,7 @@
|
||||
const teamsDb = require('./teams.db')
|
||||
const teamProvider = require('./teamProvider')
|
||||
const moderation = require('./teamModeration.model')
|
||||
const activity = require('./teamActivity.model')
|
||||
const { slugify, uniqueSlug } = require('./teamSlug')
|
||||
const settings = require('../settings/settings.model')
|
||||
const log = require('../../utils/logger')('teams')
|
||||
@@ -130,9 +131,66 @@ async function applyRename(moduleId, existing, team) {
|
||||
log.info('team renamed; previous row archived', {
|
||||
externalId: team.externalId, from: existing.name, to: team.name, archivedId: existing.id, successorId,
|
||||
})
|
||||
// §4.2's `core.team.renamed`, written to the SUCCESSOR rather than to the row
|
||||
// that was renamed: the archived row is a read-only record of what happened
|
||||
// before the rename (§2.2), and the person who wants to know a Team used to be
|
||||
// called something else is looking at the live page.
|
||||
//
|
||||
// The old name is core's own, not game-sourced text a module handed us this
|
||||
// run — it is the `name` column core has been serving all along — so §2.9's
|
||||
// approval gate does not apply. It can still be a name staff suppressed, which
|
||||
// is why a hidden Team's feed is not served publicly (teamActivity.feedFor).
|
||||
await activity.logCore({
|
||||
teamId: successorId,
|
||||
kind: activity.CORE_KINDS.TEAM_RENAMED,
|
||||
summary: `Renamed from ${existing.display_name_override || existing.name}`,
|
||||
dedupeKey: `renamed:${existing.id}`,
|
||||
}).catch((err) => log.warn('rename activity not recorded', { message: err.message }))
|
||||
return successorId
|
||||
}
|
||||
|
||||
/** Never a game-internal member key on a public page: that identifier is not published (§3.2). */
|
||||
const memberLabel = (row) => (row && row.display_name) || 'A member'
|
||||
|
||||
/**
|
||||
* Core's own membership items for one roster run (§4.2).
|
||||
*
|
||||
* **Suppressed on a Team's FIRST roster.** Importing a 155-member guild is one
|
||||
* Team arriving, not 155 people joining, and emitting a join per member would
|
||||
* bury every real event under the import and blow through the row cap on day one.
|
||||
* `roster_synced_at IS NULL` is exactly "core has never held a roster for this
|
||||
* Team", so the same condition covers a newly created Team and a newly installed
|
||||
* module adopting an existing one.
|
||||
*
|
||||
* Never throws: the feed is a rendering of the sync, and a feed write failing
|
||||
* must not abort the sync that is the actual source of truth.
|
||||
*/
|
||||
async function logRosterActivity(team, { joined, left, promoted, demoted }) {
|
||||
if (!team.roster_synced_at) return
|
||||
|
||||
const items = [
|
||||
...joined.map((row) => ({ kind: activity.CORE_KINDS.MEMBER_JOINED, row, verb: 'joined' })),
|
||||
...left.map((row) => ({ kind: activity.CORE_KINDS.MEMBER_LEFT, row, verb: 'left' })),
|
||||
...promoted.map((row) => ({ kind: activity.CORE_KINDS.LEADER_CHANGED, row, verb: 'became a leader' })),
|
||||
...demoted.map((row) => ({ kind: activity.CORE_KINDS.LEADER_CHANGED, row, verb: 'stepped down as a leader' })),
|
||||
]
|
||||
|
||||
for (const { kind, row, verb } of items) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await activity.logCore({
|
||||
teamId: team.id,
|
||||
kind,
|
||||
summary: `${memberLabel(row)} ${verb}`,
|
||||
actorMemberKey: row.member_key,
|
||||
actorUserId: row.user_id ?? null,
|
||||
})
|
||||
} catch (err) {
|
||||
log.warn('roster activity not recorded', { teamId: team.id, kind, message: err.message })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync one Team's roster and leadership. Gates 3 and 4 live here.
|
||||
*
|
||||
@@ -152,7 +210,13 @@ async function syncRoster(team) {
|
||||
return false
|
||||
}
|
||||
|
||||
const known = await teamsDb.memberKeys(team.id)
|
||||
// The full rows rather than just the keys: the activity feed needs the display
|
||||
// name and the prior `is_leader` of everyone who is about to change, and both
|
||||
// are gone once the upsert below has run. One read either way — this replaces
|
||||
// the `memberKeys` call rather than adding to it.
|
||||
const knownRows = await teamsDb.membersByTeam(team.id)
|
||||
const knownByKey = new Map(knownRows.map((row) => [row.member_key, row]))
|
||||
const known = knownRows.map((row) => row.member_key)
|
||||
|
||||
// Gate 4, the per-Team twin of gate 2.
|
||||
if (answer.complete && answer.members.length === 0 && known.length > 0) {
|
||||
@@ -184,17 +248,35 @@ async function syncRoster(team) {
|
||||
})
|
||||
}
|
||||
|
||||
// Anyone the module reports that core was not already holding. Read from the
|
||||
// module's shape, since a joiner has no row yet.
|
||||
const joined = answer.members
|
||||
.filter((m) => !knownByKey.has(m.memberKey))
|
||||
.map((m) => ({ member_key: m.memberKey, display_name: m.displayName, user_id: m.userId }))
|
||||
|
||||
// Removals only from a COMPLETE answer. `complete: false` means "valid but
|
||||
// partial", so additions and updates apply and nothing is taken away.
|
||||
let left = []
|
||||
if (answer.complete) {
|
||||
const seen = new Set(answer.members.map((m) => m.memberKey))
|
||||
await teamsDb.markDeparted(team.id, known.filter((key) => !seen.has(key)))
|
||||
const departedKeys = known.filter((key) => !seen.has(key))
|
||||
left = departedKeys.map((key) => knownByKey.get(key))
|
||||
await teamsDb.markDeparted(team.id, departedKeys)
|
||||
}
|
||||
|
||||
// Leadership is a separate question with a separate answer, and a provider that
|
||||
// cannot answer it leaves the synced value alone rather than demoting everyone.
|
||||
const leaders = await teamProvider.getTeamLeaders(team.external_id)
|
||||
let promoted = []
|
||||
let demoted = []
|
||||
if (leaders.ok) {
|
||||
// Diffed against the PRIOR rows, before setLeaders overwrites them. A member
|
||||
// who joined this run as a leader is reported as joining, not as being
|
||||
// promoted — they were never anything else here.
|
||||
const nowLeader = new Set(leaders.leaders)
|
||||
const departed = new Set(left.map((row) => row && row.member_key))
|
||||
promoted = knownRows.filter((row) => nowLeader.has(row.member_key) && !row.is_leader)
|
||||
demoted = knownRows.filter((row) => !nowLeader.has(row.member_key) && row.is_leader && !departed.has(row.member_key))
|
||||
await teamsDb.setLeaders(team.id, leaders.leaders)
|
||||
} else {
|
||||
log.warn('leadership left untouched; provider could not answer', {
|
||||
@@ -203,6 +285,8 @@ async function syncRoster(team) {
|
||||
}
|
||||
|
||||
await teamsDb.recount(team.id)
|
||||
// Read before `markRosterSynced` moves the stamp this decision turns on.
|
||||
await logRosterActivity(team, { joined, left: left.filter(Boolean), promoted, demoted })
|
||||
await teamsDb.markRosterSynced(team.id)
|
||||
return true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user