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:
223
server/src/router/v1/admin/teams.router.js
Normal file
223
server/src/router/v1/admin/teams.router.js
Normal file
@@ -0,0 +1,223 @@
|
||||
// Admin · Teams — sync state, the review queue, the approval queue, and the staff
|
||||
// actions on a Team (TEAMS.md §2.11).
|
||||
//
|
||||
// Mounted at /api/v1/admin/teams by admin/index.js, which already applied
|
||||
// `noindex, isLoggedIn, staffOnly`. Staff-wide, like /admin/activity: a moderator
|
||||
// runs the review queue, and the three actions that PUBLISH untrusted
|
||||
// game-sourced strings are gated per request inside the controller rather than
|
||||
// per route here — a moderator may call them, and calling them files a request
|
||||
// instead of applying one.
|
||||
//
|
||||
// **Declaration order matters in this file.** `/review`, `/requests` and `/resync`
|
||||
// are literal paths that would otherwise be captured by `/:id`, so every literal
|
||||
// route is declared before the first :param route. Express is first-match-wins and
|
||||
// a `/:id` ahead of `/review` would silently turn a queue into a lookup for a Team
|
||||
// whose id is "review".
|
||||
|
||||
const express = require('express')
|
||||
const { body, param, query } = require('express-validator')
|
||||
|
||||
const ctrl = require('./teams.controller')
|
||||
const validate = require('../../../middleware/validate')
|
||||
|
||||
const teamsRouter = express.Router()
|
||||
|
||||
// ── Literal paths, first ───────────────────────────────────────────────────
|
||||
|
||||
teamsRouter.get(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'List Teams with sync state'
|
||||
// #swagger.description = 'Includes hidden Teams and the module’s sync state verbatim — last attempt, last success, consecutive failures and the last error — which is what an operator debugging a stale projection needs.'
|
||||
// #swagger.parameters['archived'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Set to 1 to include archived Teams.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Teams and sync state', content: { "application/json": { schema: { $ref: "#/components/schemas/AdminTeamList" } } } } */
|
||||
query('archived').optional().isIn(['0', '1']),
|
||||
validate,
|
||||
ctrl.listTeams,
|
||||
)
|
||||
|
||||
teamsRouter.post(
|
||||
'/resync',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Run a reconciliation now'
|
||||
// #swagger.description = 'Awaited, so the response carries the outcome including the provider’s own error when it refused. The four refusal gates still apply — a manual resync cannot make core act on an answer it does not trust.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The reconciliation result', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamResyncResult" } } } } */
|
||||
ctrl.resync,
|
||||
)
|
||||
|
||||
teamsRouter.get(
|
||||
'/review',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'The reserved-name review queue'
|
||||
// #swagger.description = 'Teams auto-hidden because their name matched a reserved term, each showing which term matched. A Team a human has already ruled on leaves the queue and is never re-hidden by a later sweep.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Auto-hidden Teams awaiting review', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamReviewQueue" } } } } */
|
||||
ctrl.reviewQueue,
|
||||
)
|
||||
|
||||
teamsRouter.get(
|
||||
'/requests',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'The moderation approval queue'
|
||||
// #swagger.description = 'Requests filed by moderators for the three actions that publish untrusted game-sourced strings. Decided rows are kept — the record that a moderator asked to publish a name and an admin refused is the part worth having.'
|
||||
// #swagger.parameters['status'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'pending (default) | approved | rejected | withdrawn | all' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Moderation requests', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamRequestQueue" } } } } */
|
||||
query('status').optional().isIn(['pending', 'approved', 'rejected', 'withdrawn', 'all']),
|
||||
validate,
|
||||
ctrl.listRequests,
|
||||
)
|
||||
|
||||
teamsRouter.post(
|
||||
'/requests/:id/decide',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Approve or reject a moderation request (admin only)'
|
||||
// #swagger.description = 'Admin only, checked live against the database rather than from a token claim. Approving applies the action; rejecting keeps the row and changes nothing. A request already decided returns 409, so two admins deciding at once cannot double-apply.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Request id.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamDecideRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Decided', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Only an admin may decide a request', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such request', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Already decided', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
body('status').isIn(['approved', 'rejected']),
|
||||
body('note').optional().isString().trim().isLength({ max: 255 }),
|
||||
validate,
|
||||
ctrl.decideRequest,
|
||||
)
|
||||
|
||||
// ── :id paths ──────────────────────────────────────────────────────────────
|
||||
|
||||
teamsRouter.get(
|
||||
'/:id',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Get one Team, with its roster, grant ledger and pending requests'
|
||||
// #swagger.description = 'The roster carries the resolved leadership and what the game actually said, so an override is visible as a decision rather than presented as fact. Departed members are included.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The Team', content: { "application/json": { schema: { $ref: "#/components/schemas/AdminTeam" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
validate,
|
||||
ctrl.getTeam,
|
||||
)
|
||||
|
||||
teamsRouter.get(
|
||||
'/:id/grants',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'The full forum-grant ledger for a Team, revoked rows included'
|
||||
// #swagger.description = 'The structured record the access resolver reads. The grant/revoke flow itself lands in the forum phase; this is the read side.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The grant ledger', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamGrantLedger" } } } } */
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
validate,
|
||||
ctrl.grants,
|
||||
)
|
||||
|
||||
teamsRouter.post(
|
||||
'/:id/archive',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Archive a Team (staff)'
|
||||
// #swagger.description = 'Not gated: archiving withdraws a Team from public surfaces rather than publishing anything.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamReasonRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Archived', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
body('reason').optional().isString().trim().isLength({ max: 255 }),
|
||||
validate,
|
||||
ctrl.archive,
|
||||
)
|
||||
|
||||
teamsRouter.post(
|
||||
'/:id/hide',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Hide a Team from public surfaces (staff)'
|
||||
// #swagger.description = 'Deliberately NOT gated. Publishing untrusted data needs a second pair of eyes; withdrawing it needs to be possible at once, by whoever is on duty.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamReasonRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Hidden', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
body('reason').optional().isString().trim().isLength({ max: 255 }),
|
||||
validate,
|
||||
ctrl.hide,
|
||||
)
|
||||
|
||||
teamsRouter.post(
|
||||
'/:id/unhide',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Un-hide a Team — admin applies, moderator requests'
|
||||
// #swagger.description = 'One of the three gated actions: it publishes a name that tripped the impersonation list. An admin applies it at once; a moderator files a pending request and nothing changes publicly until an admin approves.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamReasonRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Applied, or filed for approval — see `pending`', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamModerationResult" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
body('reason').optional().isString().trim().isLength({ max: 255 }),
|
||||
validate,
|
||||
ctrl.unhide,
|
||||
)
|
||||
|
||||
teamsRouter.post(
|
||||
'/:id/display-name',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Set or clear a Team’s display name — admin applies, moderator requests'
|
||||
// #swagger.description = 'Gated for the same reason as un-hiding: it substitutes free text into the same public surfaces. Identity is untouched — the Team’s `name` stays frozen for the life of the row, and only what is rendered changes. An empty displayName clears the override.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamDisplayNameRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Applied, or filed for approval — see `pending`', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamModerationResult" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
body('displayName').optional({ nullable: true }).isString().trim().isLength({ max: 160 }),
|
||||
body('reason').optional().isString().trim().isLength({ max: 255 }),
|
||||
validate,
|
||||
ctrl.displayName,
|
||||
)
|
||||
|
||||
teamsRouter.post(
|
||||
'/:id/leader-override',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Grant or deny leadership for one member (staff)'
|
||||
// #swagger.description = 'Applied on top of the synced value at READ time; the projection is never mutated. That is what makes an override survive a resync — one written into team_members would be undone by the next reconciliation. Not gated: it publishes no game-sourced string.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamLeaderOverrideRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Override set', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
body('memberKey').isString().trim().isLength({ min: 1, max: 191 }),
|
||||
body('effect').isIn(['grant', 'deny']),
|
||||
body('reason').optional().isString().trim().isLength({ max: 255 }),
|
||||
validate,
|
||||
ctrl.setLeaderOverride,
|
||||
)
|
||||
|
||||
teamsRouter.delete(
|
||||
'/:id/leader-override/:memberKey',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Clear a leadership override (staff)'
|
||||
// #swagger.description = 'The member reverts to whatever the game says at the next read; nothing in the projection changes, because nothing in it was ever changed.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
|
||||
// #swagger.parameters['memberKey'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The module’s member key.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Override cleared', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such override', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
param('memberKey').isString().trim().isLength({ min: 1, max: 191 }),
|
||||
validate,
|
||||
ctrl.clearLeaderOverride,
|
||||
)
|
||||
|
||||
module.exports = teamsRouter
|
||||
Reference in New Issue
Block a user