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:
133
server/src/model/teams/teamActivity.db.js
Normal file
133
server/src/model/teams/teamActivity.db.js
Normal file
@@ -0,0 +1,133 @@
|
||||
// SQL for the per-Team activity feed (TEAMS.md §4.2). Statements only; every
|
||||
// decision about what a caller may SEE lives in teamActivity.model.js.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
const ACTIVITY_COLUMNS = `
|
||||
id, team_id, source, kind, summary, visibility,
|
||||
actor_member_key, actor_user_id, payload, occurred_at, created_at`
|
||||
|
||||
/**
|
||||
* Insert one item, idempotently when it carries a dedupe key.
|
||||
*
|
||||
* INSERT IGNORE against uq_team_activity_dedupe is what makes replay safe: a
|
||||
* sidecar reconnect backfills a window of events it already delivered, and
|
||||
* without this every reconnect would double-post the feed. The same trick
|
||||
* `shard_events` uses, for the same reason.
|
||||
*
|
||||
* The unique key is (team_id, dedupe_key) and MariaDB treats NULL as distinct in
|
||||
* a unique index, so items WITHOUT a key never collide with each other — an
|
||||
* un-keyed push is always an insert, which is the documented contract (§4.1:
|
||||
* `dedupeKey` is optional and "makes replay idempotent", so omitting it opts out).
|
||||
*
|
||||
* IGNORE would also swallow a genuine error — a bad FK, an over-long summary. The
|
||||
* model validates and truncates before calling, so what reaches here can only fail
|
||||
* on the dedupe key, and `affectedRows` reports which happened.
|
||||
*/
|
||||
async function insert(item) {
|
||||
const res = await query(
|
||||
`INSERT IGNORE INTO team_activity
|
||||
(team_id, source, kind, summary, visibility, actor_member_key, actor_user_id, payload, occurred_at, dedupe_key)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
item.teamId,
|
||||
item.source,
|
||||
item.kind,
|
||||
item.summary,
|
||||
item.visibility,
|
||||
item.actorMemberKey,
|
||||
item.actorUserId,
|
||||
item.payload === null ? null : JSON.stringify(item.payload),
|
||||
new Date(item.occurredAt),
|
||||
item.dedupeKey,
|
||||
],
|
||||
)
|
||||
return Number(res.affectedRows) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of a Team's feed, already narrowed to the visibilities the caller may
|
||||
* see.
|
||||
*
|
||||
* `visibilities` is always supplied by the model and never by a request
|
||||
* parameter — a caller naming its own visibility filter is the whole bug this
|
||||
* table's ENUM exists to prevent. Ordered newest first by `occurred_at`, the
|
||||
* game's clock, not `created_at`: a backfill that arrives late still sorts where
|
||||
* it happened.
|
||||
*/
|
||||
async function page(teamId, visibilities, { limit, offset }) {
|
||||
const slots = visibilities.map(() => '?').join(', ')
|
||||
return query(
|
||||
`SELECT ${ACTIVITY_COLUMNS} FROM team_activity
|
||||
WHERE team_id = ? AND visibility IN (${slots})
|
||||
ORDER BY occurred_at DESC, id DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[teamId, ...visibilities, limit, offset],
|
||||
)
|
||||
}
|
||||
|
||||
/** Total matching rows, for the same filter — so a client can page honestly. */
|
||||
async function count(teamId, visibilities) {
|
||||
const slots = visibilities.map(() => '?').join(', ')
|
||||
const rows = await query(
|
||||
`SELECT COUNT(*) AS n FROM team_activity WHERE team_id = ? AND visibility IN (${slots})`,
|
||||
[teamId, ...visibilities],
|
||||
)
|
||||
return Number(rows[0] ? rows[0].n : 0)
|
||||
}
|
||||
|
||||
/** Everything older than the retention horizon, across every Team. */
|
||||
async function deleteOlderThan(days) {
|
||||
const res = await query(
|
||||
'DELETE FROM team_activity WHERE occurred_at < (NOW() - INTERVAL ? DAY)',
|
||||
[days],
|
||||
)
|
||||
return Number(res.affectedRows) || 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Which Teams currently exceed the per-Team row cap, and by how much.
|
||||
*
|
||||
* Asked first so the trim only runs for Teams that need it. A feed fed by a game
|
||||
* loop is the obvious unbounded-growth failure (§4.2), and on a shard with one
|
||||
* busy guild and fifty quiet ones this keeps the nightly job proportional to the
|
||||
* problem rather than to the number of Teams.
|
||||
*/
|
||||
async function overCap(cap) {
|
||||
return query(
|
||||
`SELECT team_id, COUNT(*) AS n FROM team_activity
|
||||
GROUP BY team_id HAVING n > ?`,
|
||||
[cap],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim one Team back to the newest `cap` rows.
|
||||
*
|
||||
* Expressed as "delete everything at or below the id of the cap-th newest row"
|
||||
* rather than as a correlated subquery on the same table, which MariaDB refuses
|
||||
* inside a DELETE (error 1093). The derived table is what makes it legal — the
|
||||
* subquery is materialised before the delete runs.
|
||||
*/
|
||||
async function trimToCap(teamId, cap) {
|
||||
const rows = await query(
|
||||
`SELECT id FROM team_activity
|
||||
WHERE team_id = ? ORDER BY occurred_at DESC, id DESC LIMIT 1 OFFSET ?`,
|
||||
[teamId, cap],
|
||||
)
|
||||
if (!rows[0]) return 0
|
||||
const res = await query(
|
||||
'DELETE FROM team_activity WHERE team_id = ? AND id <= ?',
|
||||
[teamId, rows[0].id],
|
||||
)
|
||||
return Number(res.affectedRows) || 0
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
insert,
|
||||
page,
|
||||
count,
|
||||
deleteOlderThan,
|
||||
overCap,
|
||||
trimToCap,
|
||||
}
|
||||
312
server/src/model/teams/teamActivity.model.js
Normal file
312
server/src/model/teams/teamActivity.model.js
Normal file
@@ -0,0 +1,312 @@
|
||||
// ── The per-Team activity feed (TEAMS.md Part 4) ───────────────────────────
|
||||
//
|
||||
// Two writers, one table. A module pushes game items through
|
||||
// `ctx.teams.activity.push` (§4.1); core writes its own membership and rename
|
||||
// items directly (§4.2). Both land in `team_activity` with a `source`, and the
|
||||
// read path treats them identically — which is the point of core writing here at
|
||||
// all, since it means the rendering path is exercised from day one on a
|
||||
// deployment whose module pushes nothing.
|
||||
//
|
||||
// **Three rules shape this file.**
|
||||
//
|
||||
// 1. *Core never composes a summary.* `summary` arrives already rendered and is
|
||||
// stored verbatim (§4.1). Core cannot phrase "gained 15,000 gold" for a game
|
||||
// whose vocabulary it does not know, and a core that templated it would have
|
||||
// re-acquired the game semantics the module system exists to remove. Core's OWN
|
||||
// five kinds are the sole exception, and they are about membership and renames
|
||||
// — platform facts, not game ones.
|
||||
//
|
||||
// 2. *Visibility fails closed.* An item with no stated visibility is `members`,
|
||||
// and the read path resolves what a caller may see from their access rather
|
||||
// than from anything they send.
|
||||
//
|
||||
// 3. *A push never throws at its call site.* `ctx.teams.activity.push` is awaited
|
||||
// by a module inside a game-event handler. A bad item is dropped and logged;
|
||||
// an unknown Team is dropped and logged. The alternative — rejecting the batch
|
||||
// — makes core's storage problem into the module's control flow, and the
|
||||
// contract (MODULE_API.md §2.3) is that ctx pushes are fire-and-forget.
|
||||
|
||||
const activityDb = require('./teamActivity.db')
|
||||
const teamsDb = require('./teams.db')
|
||||
const access = require('./teamAccess.model')
|
||||
const settings = require('../settings/settings.model')
|
||||
|
||||
const log = require('../../utils/logger')('teams')
|
||||
|
||||
// Column widths from schema.sql. Truncating rather than refusing: an over-long
|
||||
// summary is a module being verbose, not a module being wrong, and dropping the
|
||||
// item would lose a real event over a display detail.
|
||||
const MAX_SUMMARY = 255
|
||||
const MAX_KIND = 64
|
||||
const MAX_MEMBER_KEY = 191
|
||||
// CHAR(40) — a sha1 hex is the natural fit and what §4.1's example looks like,
|
||||
// but the column is opaque and any stable string within the width works.
|
||||
const MAX_DEDUPE = 40
|
||||
|
||||
const VISIBILITIES = ['public', 'members']
|
||||
|
||||
// Retention (§4.2). Both are settings so an operator can tighten a busy shard
|
||||
// without a deploy; the defaults are the doc's.
|
||||
const DEFAULT_RETAIN_DAYS = 90
|
||||
const DEFAULT_ROW_CAP = 2000
|
||||
|
||||
/**
|
||||
* Core's own kinds (§4.2).
|
||||
*
|
||||
* `core.forum.thread` is named in the doc and lands with the forum in phase 4 —
|
||||
* there is nothing to emit it from yet. The four here are all core knows how to
|
||||
* say without asking a game anything.
|
||||
*/
|
||||
const CORE_KINDS = {
|
||||
MEMBER_JOINED: 'core.member.joined',
|
||||
MEMBER_LEFT: 'core.member.left',
|
||||
LEADER_CHANGED: 'core.leader.changed',
|
||||
TEAM_RENAMED: 'core.team.renamed',
|
||||
}
|
||||
|
||||
const clamp = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null)
|
||||
|
||||
/**
|
||||
* Normalise one pushed item, or return null to drop it.
|
||||
*
|
||||
* `teamId` is resolved by the caller, not carried on the item: a module names its
|
||||
* own `externalId` and core maps it (§4.1), so a module can never write into
|
||||
* another module's Team by guessing an integer.
|
||||
*/
|
||||
function normalise(item, source, teamId) {
|
||||
if (!item || typeof item !== 'object') return null
|
||||
|
||||
const kind = clamp(item.kind, MAX_KIND)
|
||||
const summary = clamp(item.summary, MAX_SUMMARY)
|
||||
// Both are load-bearing and neither has a safe default: an item with no kind
|
||||
// cannot be filtered or rendered by a slot, and one with no summary is a blank
|
||||
// row on a public page.
|
||||
if (!kind || !summary) return null
|
||||
|
||||
// `occurredAt` is the game's clock and the feed's sort key. A missing or
|
||||
// unparseable one becomes now — the item is real even when its timestamp is
|
||||
// not, and dropping it would lose an event over metadata.
|
||||
const occurredAt = Number.isFinite(item.occurredAt) ? Number(item.occurredAt) : Date.now()
|
||||
|
||||
return {
|
||||
teamId,
|
||||
source,
|
||||
kind,
|
||||
summary,
|
||||
visibility: VISIBILITIES.includes(item.visibility) ? item.visibility : 'members',
|
||||
actorMemberKey: clamp(item.actorMemberKey, MAX_MEMBER_KEY),
|
||||
// Resolved BY THE MODULE, like every other user id crossing this boundary
|
||||
// (§2.3) — core takes the number and never looks it up.
|
||||
actorUserId: Number.isInteger(item.actorUserId) && item.actorUserId > 0 ? item.actorUserId : null,
|
||||
payload: item.payload && typeof item.payload === 'object' ? item.payload : null,
|
||||
occurredAt,
|
||||
dedupeKey: clamp(item.dedupeKey, MAX_DEDUPE),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `ctx.teams.activity.push` — a module's whole write access to the feed.
|
||||
*
|
||||
* Items name their Team by the module's own `externalId`, and only ACTIVE Teams
|
||||
* owned by THAT module resolve. An archived Team is deliberately not writable: its
|
||||
* feed is a read-only record of what happened before the rename or the disband
|
||||
* (§2.2), and letting a late-arriving event append to it would make a closed
|
||||
* record grow.
|
||||
*
|
||||
* Returns the number of items actually stored. Dropped items are logged with the
|
||||
* reason and never raised — see rule 3 above.
|
||||
*/
|
||||
async function push(source, items) {
|
||||
if (!Array.isArray(items)) {
|
||||
log.warn('teams activity push: not an array', { source })
|
||||
return 0
|
||||
}
|
||||
if (!items.length) return 0
|
||||
|
||||
// One lookup per distinct externalId, not one per item: a champion spawn
|
||||
// completing pushes a batch for a single Team, and re-resolving it per item
|
||||
// would be a query per row.
|
||||
const teamIds = new Map()
|
||||
let stored = 0
|
||||
let dropped = 0
|
||||
|
||||
for (const item of items) {
|
||||
const externalId = item && typeof item.externalId === 'string' ? item.externalId.trim() : ''
|
||||
if (!externalId) { dropped += 1; continue }
|
||||
|
||||
if (!teamIds.has(externalId)) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const row = await teamsDb.findActive(source, externalId)
|
||||
teamIds.set(externalId, row ? row.id : null)
|
||||
}
|
||||
const teamId = teamIds.get(externalId)
|
||||
if (!teamId) { dropped += 1; continue }
|
||||
|
||||
const normalised = normalise(item, source, teamId)
|
||||
if (!normalised) { dropped += 1; continue }
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const inserted = await activityDb.insert(normalised)
|
||||
// A dedupe collision is a SUCCESSFUL no-op, not a drop — it is the mechanism
|
||||
// working. Counted as stored so a module replaying a backfill does not read
|
||||
// its own idempotence as data loss.
|
||||
if (inserted) stored += 1
|
||||
}
|
||||
|
||||
if (dropped) {
|
||||
log.warn('teams activity push: dropped items', { source, dropped, offered: items.length })
|
||||
}
|
||||
return stored
|
||||
}
|
||||
|
||||
/**
|
||||
* Core's own write path (§4.2), used by the reconciler and the rename rule.
|
||||
*
|
||||
* Separate from `push` because core names a Team by its own primary key — it is
|
||||
* already holding the row — and because core's items are always `public`: a
|
||||
* member joining or a Team being renamed is exactly what a public Team page is
|
||||
* for. Nothing here is game vocabulary.
|
||||
*/
|
||||
async function logCore({ teamId, kind, summary, actorMemberKey = null, actorUserId = null, occurredAt = Date.now(), dedupeKey = null }) {
|
||||
if (!teamId || !kind || !summary) return false
|
||||
return activityDb.insert({
|
||||
teamId,
|
||||
source: 'core',
|
||||
kind: clamp(kind, MAX_KIND),
|
||||
summary: clamp(summary, MAX_SUMMARY),
|
||||
visibility: 'public',
|
||||
actorMemberKey: clamp(actorMemberKey, MAX_MEMBER_KEY),
|
||||
actorUserId: Number.isInteger(actorUserId) && actorUserId > 0 ? actorUserId : null,
|
||||
payload: null,
|
||||
occurredAt,
|
||||
dedupeKey: clamp(dedupeKey, MAX_DEDUPE),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Which visibilities a caller may see (§4.3).
|
||||
*
|
||||
* `members` items go to members and to forum-granted users — the same two
|
||||
* authority paths `forumAccess` already resolves, reused rather than re-derived
|
||||
* so the feed can never disagree with the forum about who is inside a Team.
|
||||
* Anyone else, including every anonymous caller, sees `public` only.
|
||||
*/
|
||||
async function visibilitiesFor(teamId, userId) {
|
||||
if (!userId) return ['public']
|
||||
const resolved = await access.forumAccess(teamId, userId)
|
||||
return resolved.allowed ? ['public', 'members'] : ['public']
|
||||
}
|
||||
|
||||
/** The rendered shape. `payload` rides along for the module's slot (§4.3). */
|
||||
function publicItem(row) {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
source: row.source,
|
||||
kind: row.kind,
|
||||
summary: row.summary,
|
||||
visibility: row.visibility,
|
||||
occurredAt: row.occurred_at,
|
||||
payload: row.payload ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of a Team's feed for one viewer.
|
||||
*
|
||||
* A HIDDEN Team's feed is not served publicly, for the same reason its roster is
|
||||
* not (§2.8.3): hidden means absent from every public surface, and a feed that
|
||||
* answered while the page 404s would republish the suppressed name in every
|
||||
* `core.team.renamed` summary.
|
||||
*/
|
||||
async function feedFor(slug, userId, { limit = 50, offset = 0 } = {}) {
|
||||
const row = await teamsDb.findBySlug(slug)
|
||||
if (!row) return null
|
||||
|
||||
const visibilities = await visibilitiesFor(row.id, userId)
|
||||
// A member of a hidden Team still sees its feed — suppression is a
|
||||
// public-surface rule, and a member is not a member of the public (§2.11).
|
||||
if (row.hidden && visibilities.length === 1) return null
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
activityDb.page(row.id, visibilities, { limit, offset }),
|
||||
activityDb.count(row.id, visibilities),
|
||||
])
|
||||
return {
|
||||
items: rows.map(publicItem),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
// So a client can render "members-only items are hidden" rather than
|
||||
// presenting a filtered feed as the whole one.
|
||||
scope: visibilities.includes('members') ? 'members' : 'public',
|
||||
}
|
||||
}
|
||||
|
||||
// ── Retention (§4.2) ───────────────────────────────────────────────────────
|
||||
|
||||
const RETAIN_KEY = 'team_activity_retain_days'
|
||||
const CAP_KEY = 'team_activity_row_cap'
|
||||
|
||||
/**
|
||||
* Read both limits, falling back to the defaults on anything unreadable.
|
||||
*
|
||||
* Wrapped in a try like `teamSync.intervalSeconds`, and for the same reason: this
|
||||
* runs on a timer with nobody watching, and a settings table that is briefly
|
||||
* unavailable must yield the default rather than an exception that kills the
|
||||
* nightly job. A misconfigured value fails the same way — a zero or a negative
|
||||
* retention would delete the whole feed, so it is rejected rather than honoured.
|
||||
*/
|
||||
async function retentionConfig() {
|
||||
let rawDays
|
||||
let rawCap
|
||||
try {
|
||||
;[rawDays, rawCap] = await Promise.all([settings.get(RETAIN_KEY), settings.get(CAP_KEY)])
|
||||
} catch {
|
||||
return { days: DEFAULT_RETAIN_DAYS, cap: DEFAULT_ROW_CAP }
|
||||
}
|
||||
const days = Number.parseInt(rawDays, 10)
|
||||
const cap = Number.parseInt(rawCap, 10)
|
||||
return {
|
||||
days: Number.isFinite(days) && days > 0 ? days : DEFAULT_RETAIN_DAYS,
|
||||
cap: Number.isFinite(cap) && cap > 0 ? cap : DEFAULT_ROW_CAP,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The nightly prune: an age horizon AND a per-Team row cap.
|
||||
*
|
||||
* Both, because either alone has a hole. Age alone lets one busy guild write a
|
||||
* million rows inside the window; a cap alone keeps a dead Team's feed forever.
|
||||
* Unbounded growth on a per-Team feed fed by a game loop is the obvious failure
|
||||
* here and it is cheaper to bound it now than to discover it at cutover.
|
||||
*/
|
||||
async function prune() {
|
||||
const { days, cap } = await retentionConfig()
|
||||
const byAge = await activityDb.deleteOlderThan(days)
|
||||
|
||||
let byCap = 0
|
||||
const over = await activityDb.overCap(cap)
|
||||
for (const row of over) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
byCap += await activityDb.trimToCap(row.team_id, cap)
|
||||
}
|
||||
|
||||
if (byAge || byCap) log.info('teams activity prune', { byAge, byCap, days, cap })
|
||||
return { byAge, byCap, days, cap }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
push,
|
||||
logCore,
|
||||
feedFor,
|
||||
visibilitiesFor,
|
||||
publicItem,
|
||||
prune,
|
||||
retentionConfig,
|
||||
RETAIN_KEY,
|
||||
CAP_KEY,
|
||||
CORE_KINDS,
|
||||
VISIBILITIES,
|
||||
DEFAULT_RETAIN_DAYS,
|
||||
DEFAULT_ROW_CAP,
|
||||
}
|
||||
@@ -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