feat(teams): the Team read API, the moderation routes, and Admin -> Teams
The eighteen routes of docs/website/TEAMS.md §2.11, their OpenAPI annotations,
and the staff screen that drives them.
Two rules shape the read model. Hidden means absent from every public surface --
the index, the lookup and the roster alike, and a hidden Team 404s
indistinguishably from one that does not exist, because "absent" includes not
confirming it is there. And staleness is surfaced rather than silent: every
public payload carries { configured, stale, lastSyncAt }, so a page can say how
recently the projection was confirmed instead of presenting stale data as
current.
The public roster withholds both the member key and the user id -- one is a
game-internal identifier, the other names a site account. `linked` answers the
only question a public page has without publishing which account. The module's
per-audience field projection is phase 3's; this is a conservative core one.
The §2.9 gate is enforced per REQUEST, not per route. A moderator may call all
eighteen; three of them mean something different when they do, and the server
decides from the role it re-validates on every request rather than from a token
claim. The client has no "file as request" argument to get wrong.
Found by booting the real server against the real database, and not by any test:
**the index and the by-slug lookup disagreed about what exists.** listPublic was
keyed on a registered team provider while findBySlug is not, so with no module
installed `/teams` returned an empty list while `/teams/:slug/members` served a
full roster -- the index denying a Team that direct URLs answered for in full.
The rows are core's and they outlive the module that filled them: an uninstalled
module leaves a projection that is unmaintained, not one that stopped existing,
and `configured: false` is how a client learns that. The read side no longer
takes the provider into account at all. There is now a test named for the
property.
Also verified live: the public routes answer anonymously, an unknown and a hidden
slug both 404, the player and admin tiers 401 an anonymous caller, a seeded
roster projects correctly, and the reconciler logs that it is staying idle with
no provider registered rather than failing a boot.
Process obligations, all done: #swagger.* annotations on every route, `npm run
swagger` regenerated (18 paths in the spec, no dangling $refs, and the schemas
they reference added), `npm run routes:manifest` regenerated -- additions only,
184 public routes -- and BACKEND_DESIGN.md updated across the schema section and
all three tier tables.
Admin -> Teams follows the ModulesAdmin precedent: everything that decides what a
row SAYS lives in lib/teamAdmin.js, which is plain JS with tests, and the view
renders it. That split earns itself here specifically -- the screen's job is to
make "the shard has no Teams" and "core has not been able to ask for two hours"
impossible to confuse, and those two produce the same empty table. The four
freshness states are named and tested for exactly that reason, and the last
provider error is shown verbatim rather than paraphrased.
The button labels follow the caller's role: a moderator sees "Request publish",
so the pending result is not a surprise. Hiding is offered to everyone with no
gate, matching the server.
Server 894 passed, client 206 passed, client build clean. 17 route tests, 20
client display tests.
Refs docs/website/TEAMS.md §2.11, Part 12 phase 2
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,20 @@ async function activeByModule(moduleId) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every ACTIVE team, whichever module owns it.
|
||||
*
|
||||
* For the READ side, which must not be keyed on a provider being registered. The
|
||||
* rows are core's and they outlive the module that filled them — a module
|
||||
* uninstalled or disabled leaves a projection that is unmaintained, not one that
|
||||
* stopped existing. Listing by provider made `/teams` empty while
|
||||
* `/teams/:slug/members` still answered in full, since the lookup goes by slug:
|
||||
* the index denied a Team that direct URLs served.
|
||||
*/
|
||||
async function allActive() {
|
||||
return query(`SELECT ${TEAM_COLUMNS} FROM teams WHERE status = 'active' ORDER BY id`)
|
||||
}
|
||||
|
||||
/** The ACTIVE row for an external id, or undefined. At most one, by uq_teams_active. */
|
||||
async function findActive(moduleId, externalId) {
|
||||
const rows = await query(
|
||||
@@ -270,6 +284,7 @@ async function setPendingEmpty(moduleId, since) {
|
||||
|
||||
module.exports = {
|
||||
activeByModule,
|
||||
allActive,
|
||||
findActive,
|
||||
findById,
|
||||
findBySlug,
|
||||
|
||||
296
server/src/model/teams/teams.model.js
Normal file
296
server/src/model/teams/teams.model.js
Normal file
@@ -0,0 +1,296 @@
|
||||
// ── The Team read model ────────────────────────────────────────────────────
|
||||
//
|
||||
// What the three API tiers are allowed to see (TEAMS.md §2.11), assembled from
|
||||
// the projection, the resolver and the sync state.
|
||||
//
|
||||
// **Two rules shape every function here.**
|
||||
//
|
||||
// 1. *Hidden means absent from every public surface* (§2.8.3) — the index, the
|
||||
// lookup, the roster. Not archived, not deleted, and completely functional for
|
||||
// its own members. A hidden Team that 404s publicly but answers for a member
|
||||
// is the intended behaviour, not an inconsistency.
|
||||
//
|
||||
// 2. *Staleness is surfaced, never silent* (§2.4). Every public payload carries
|
||||
// `{ stale, lastSyncAt }`, so a page can say "roster last confirmed 14 minutes
|
||||
// ago" rather than presenting a stale roster as current. A projection nobody
|
||||
// can tell is stale is worse than one that is obviously old.
|
||||
//
|
||||
// The per-audience FIELD projection of a roster row is the module's, not core's
|
||||
// (§10.5, §3.3) — the visibility framework and its config are module-owned. This
|
||||
// phase serves a conservative core projection: a public roster carries in-game
|
||||
// display names and never a site account id or a game member key. The module's
|
||||
// rung-aware projection lands with the Team pages in phase 3.
|
||||
|
||||
const teamsDb = require('./teams.db')
|
||||
const teamProvider = require('./teamProvider')
|
||||
const access = require('./teamAccess.model')
|
||||
const teamSync = require('./teamSync.model')
|
||||
|
||||
// Past this multiple of the poll interval a projection is reported stale. Two
|
||||
// intervals rather than one, so an ordinary late poll does not make every page
|
||||
// cry wolf — the threshold has to mean "something is wrong", not "a run is due".
|
||||
const STALE_INTERVALS = 2
|
||||
|
||||
/** The public shape of a Team. Deliberately small. */
|
||||
function publicTeam(row) {
|
||||
return {
|
||||
slug: row.slug,
|
||||
// What is DISPLAYED may have been overridden by staff; what the row IS never
|
||||
// changes (§2.2, §2.8.3). Public callers only ever see the former.
|
||||
name: row.display_name_override || row.name,
|
||||
abbr: row.abbr,
|
||||
memberCount: row.member_count,
|
||||
linkedCount: row.linked_count,
|
||||
onlineCount: row.online_count,
|
||||
meta: row.meta ?? null,
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
rosterSyncedAt: row.roster_synced_at,
|
||||
...(row.status === 'archived' ? { archivedAt: row.archived_at, archivedReason: row.archived_reason } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The public shape of a roster row.
|
||||
*
|
||||
* `member_key` and `user_id` are both withheld: the first is a game-internal
|
||||
* identifier and the second names a site account. `linked` answers the only
|
||||
* question a public page has — whether this character has an account behind it —
|
||||
* without publishing which one.
|
||||
*/
|
||||
function publicMember(row) {
|
||||
return {
|
||||
displayName: row.display_name,
|
||||
rankLabel: row.rank_label,
|
||||
isLeader: Boolean(row.is_leader),
|
||||
online: Boolean(row.online),
|
||||
linked: row.user_id != null,
|
||||
}
|
||||
}
|
||||
|
||||
/** The admin shape: everything, including what a decision overrode. */
|
||||
function adminTeam(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
moduleId: row.module_id,
|
||||
externalId: row.external_id,
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
displayName: row.display_name_override || row.name,
|
||||
displayNameOverride: row.display_name_override,
|
||||
abbr: row.abbr,
|
||||
status: row.status,
|
||||
hidden: Boolean(row.hidden),
|
||||
hiddenReason: row.hidden_reason,
|
||||
hiddenTerm: row.hidden_term,
|
||||
nameReviewedAt: row.name_reviewed_at,
|
||||
memberCount: row.member_count,
|
||||
linkedCount: row.linked_count,
|
||||
onlineCount: row.online_count,
|
||||
rosterSyncedAt: row.roster_synced_at,
|
||||
membersEmptySince: row.members_empty_since,
|
||||
succeededBy: row.succeeded_by,
|
||||
createdAt: row.created_at,
|
||||
archivedAt: row.archived_at,
|
||||
archivedReason: row.archived_reason,
|
||||
meta: row.meta ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function adminMember(row) {
|
||||
return {
|
||||
memberKey: row.member_key,
|
||||
displayName: row.display_name,
|
||||
userId: row.user_id,
|
||||
rankLabel: row.rank_label,
|
||||
isLeader: Boolean(row.is_leader),
|
||||
isLeaderSynced: Boolean(row.is_leader_synced),
|
||||
leaderOverride: row.leader_override || null,
|
||||
online: Boolean(row.online),
|
||||
status: row.status,
|
||||
firstSeenAt: row.first_seen_at,
|
||||
lastSeenAt: row.last_seen_at,
|
||||
departedAt: row.departed_at,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Freshness, as every public payload reports it.
|
||||
*
|
||||
* With no provider registered there is nothing to be stale ABOUT, so this reports
|
||||
* `stale: false` and a null timestamp rather than "very stale" — a deployment
|
||||
* with no game module is not a broken one.
|
||||
*/
|
||||
async function syncStatus() {
|
||||
const moduleId = teamProvider.providerModuleId()
|
||||
if (!moduleId) return { stale: false, lastSyncAt: null, configured: false }
|
||||
|
||||
const [state, intervalS] = await Promise.all([
|
||||
teamsDb.syncState(moduleId),
|
||||
teamSync.intervalSeconds(),
|
||||
])
|
||||
const lastSyncAt = state ? state.last_success_at : null
|
||||
const ageS = lastSyncAt ? (Date.now() - new Date(lastSyncAt).getTime()) / 1000 : Infinity
|
||||
return {
|
||||
configured: true,
|
||||
lastSyncAt,
|
||||
// Never synced at all is stale: a page must not present an empty projection
|
||||
// as a confirmed empty shard.
|
||||
stale: ageS > intervalS * STALE_INTERVALS,
|
||||
consecutiveFailures: state ? state.consecutive_failures : 0,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function listPublic({ limit = 50, offset = 0 } = {}) {
|
||||
// Every active Team, not just the registered provider's. The rows are core's
|
||||
// and they outlive the module that filled them: keying the index on a provider
|
||||
// made an uninstalled module's Teams vanish from /teams while
|
||||
// /teams/:slug/members still served them in full, because the lookup goes by
|
||||
// slug. `configured: false` is how a client learns the projection is no longer
|
||||
// being maintained -- an empty list would have said something untrue instead.
|
||||
const [rows, sync] = await Promise.all([teamsDb.allActive(), syncStatus()])
|
||||
const visible = rows.filter((r) => !r.hidden)
|
||||
return {
|
||||
teams: visible.slice(offset, offset + limit).map(publicTeam),
|
||||
total: visible.length,
|
||||
...sync,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One Team by slug, for a public caller.
|
||||
*
|
||||
* An ARCHIVED Team resolves rather than 404ing (§2.2): a bookmark or a Discord
|
||||
* link from before a rename must land somewhere that explains itself. A HIDDEN
|
||||
* one does not resolve at all — that is the difference between retired and
|
||||
* suppressed.
|
||||
*/
|
||||
async function getPublic(slug) {
|
||||
const row = await teamsDb.findBySlug(slug)
|
||||
if (!row || row.hidden) return null
|
||||
const sync = await syncStatus()
|
||||
const successor = row.succeeded_by ? await teamsDb.findById(row.succeeded_by) : null
|
||||
return {
|
||||
...publicTeam(row),
|
||||
...sync,
|
||||
successor: successor && !successor.hidden
|
||||
? { slug: successor.slug, name: successor.display_name_override || successor.name }
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
async function rosterPublic(slug) {
|
||||
const row = await teamsDb.findBySlug(slug)
|
||||
if (!row || row.hidden) return null
|
||||
const [members, sync] = await Promise.all([
|
||||
access.rosterWithOverrides(row.id),
|
||||
syncStatus(),
|
||||
])
|
||||
return { members: members.map(publicMember), ...sync, rosterSyncedAt: row.roster_synced_at }
|
||||
}
|
||||
|
||||
// ── Player ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The caller's Teams — membership and grants — each with the REASON it is listed.
|
||||
*
|
||||
* The two are read from their own tables and merged here rather than by a query
|
||||
* that unions them, so the reason survives into the payload. `both` is a real
|
||||
* state and the UI needs it: a member who also holds a historical grant should
|
||||
* see membership as the current reason without the grant vanishing.
|
||||
*
|
||||
* A hidden Team IS listed here. Suppression is a public-surface rule; a member is
|
||||
* not a member of the public.
|
||||
*/
|
||||
async function listForUser(userId) {
|
||||
const memberships = await teamsDb.activeTeamsForUser(userId)
|
||||
const byId = new Map()
|
||||
|
||||
for (const row of memberships) {
|
||||
byId.set(row.id, { ...publicTeam(row), reason: 'membership', isLeader: Boolean(row.is_leader) })
|
||||
}
|
||||
|
||||
// Grants are per Team, so the visible set is walked rather than queried the
|
||||
// other way round; the population is small (a user's Teams), and it keeps path
|
||||
// 3's read on path 3's table.
|
||||
const all = await teamsDb.allActive()
|
||||
for (const row of all) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const resolved = await access.forumAccess(row.id, userId)
|
||||
if (!resolved.viaGrant) continue
|
||||
const existing = byId.get(row.id)
|
||||
if (existing) existing.reason = 'both'
|
||||
else byId.set(row.id, { ...publicTeam(row), reason: 'grant', isLeader: false })
|
||||
}
|
||||
|
||||
return { teams: [...byId.values()], ...(await syncStatus()) }
|
||||
}
|
||||
|
||||
/** The caller's own resolved access on one Team. */
|
||||
async function accessForUser(slug, userId) {
|
||||
const row = await teamsDb.findBySlug(slug)
|
||||
if (!row) return null
|
||||
const resolved = await access.forumAccess(row.id, userId)
|
||||
return { slug: row.slug, ...resolved }
|
||||
}
|
||||
|
||||
// ── Admin ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function listAdmin({ includeArchived = false } = {}) {
|
||||
const moduleId = teamProvider.providerModuleId()
|
||||
const rows = await teamsDb.allActive()
|
||||
const sync = await syncStatus()
|
||||
const state = moduleId ? await teamsDb.syncState(moduleId) : null
|
||||
return {
|
||||
teams: rows.map(adminTeam),
|
||||
...sync,
|
||||
// Shown verbatim on Admin → Teams, including the last error: an operator
|
||||
// debugging a stale projection needs what the provider actually said.
|
||||
syncState: state
|
||||
? {
|
||||
moduleId: state.module_id,
|
||||
lastAttemptAt: state.last_attempt_at,
|
||||
lastSuccessAt: state.last_success_at,
|
||||
consecutiveFailures: state.consecutive_failures,
|
||||
lastError: state.last_error,
|
||||
pendingEmptySince: state.pending_empty_since,
|
||||
}
|
||||
: null,
|
||||
includeArchived,
|
||||
}
|
||||
}
|
||||
|
||||
async function getAdmin(id) {
|
||||
const row = await teamsDb.findById(id)
|
||||
if (!row) return null
|
||||
const [members, grants, pending] = await Promise.all([
|
||||
access.rosterWithOverrides(row.id, { includeDeparted: true }),
|
||||
access.grantLedger(row.id),
|
||||
// eslint-disable-next-line global-require
|
||||
require('./teamModeration.model').pendingForTeam(row.id),
|
||||
])
|
||||
return {
|
||||
...adminTeam(row),
|
||||
members: members.map(adminMember),
|
||||
grants,
|
||||
pendingRequests: pending,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listPublic,
|
||||
getPublic,
|
||||
rosterPublic,
|
||||
listForUser,
|
||||
accessForUser,
|
||||
listAdmin,
|
||||
getAdmin,
|
||||
syncStatus,
|
||||
publicTeam,
|
||||
publicMember,
|
||||
adminTeam,
|
||||
adminMember,
|
||||
STALE_INTERVALS,
|
||||
}
|
||||
Reference in New Issue
Block a user