Files
website/server/src/router/v1/admin/teams.router.js
Claude e25e7ade80
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 26s
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / server-tests (pull_request) Successful in 10m42s
fix(swagger): hoist the one inline predicate that makes the generator run away
`npm run swagger` cannot run on this tree. It dies with swagger-autogen's
"FATAL ERROR: invalid array length - Allocation failed", generating
nothing, and it reproduces on a pristine checkout under both Node 20 and
Node 24 — so the committed spec cannot be regenerated by anyone, and any
PR that adds or changes a route is unable to meet the standing obligation
to update it.

Bisected to one statement in `teams.router.js`:

  param('teamId').custom((v) => v === 'default' || TEAM_ID.test(v))

Hoisting that arrow to a named const fixes it outright. Nothing else
changes and the regenerated spec is byte-identical to the committed one,
so this is a generator fix, not a spec change.

The diagnosis worth keeping, because the file's own comment recorded a
different one. Phase 8 shipped a bare regex LITERAL before `.test(` and
phase 9 hoisted the regex, blaming a per-file route limit measured at
twenty statements; the file has sat at nineteen ever since on the theory
that it was one under the edge. That theory is wrong. Probing every
router file individually, `teams.router.js` at nineteen statements dies
while a THREE-route file carrying only this one route also dies — so the
trigger is the inline arrow reaching `.test(`, not the count. Hoisting
the regex was half the fix; the predicate around it needed hoisting too.

The comments in `teams.router.js`, `teamsVoice.router.js` and
`admin/index.js` are corrected to say so, since all three currently tell
the next person to keep counting statements.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 20:47:25 -05:00

344 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 { requireRole } = require('../../../utils/auth')
const teamsRouter = express.Router()
// The one ADMIN-only corner of a staff-wide router (§7.2, phase 8). Configuring
// where a Team's events leave the site for is not the §2.9 kind of decision a
// moderator files a request for; it is deployment configuration, and it sits with
// the role that already holds the bot token.
// Both of these are hoisted rather than written inline, and both have to stay
// that way. **Nothing that reaches `.test(` may sit inside a route statement**:
// swagger-autogen's static parser runs away on it and `npm run swagger` dies with
// "invalid array length - Allocation failed", generating no spec at all.
//
// Two rounds of that. Phase 8 shipped the regex as a bare LITERAL before `.test(`
// and phase 9 hoisted it — but left the PREDICATE inline, which is the same
// runaway even with the regex behind a const, and the generator stayed broken.
// Bisected to exactly the `param('teamId').custom(...)` statement below: a
// three-route file carrying only it dies, so this is not about how much is in the
// file.
//
// That last point corrects what this file used to say. Phase 9 read the symptom
// as a per-file ROUTE LIMIT, measured it at twenty `teamsRouter.*` statements,
// split `teamsVoice.router.js` out and left this file sitting at nineteen "one
// under the edge". There is no such edge to sit under — the count was a proxy for
// how much text the parser chewed before hitting the real trigger.
const TEAM_ID = /^[0-9]+$/
const isTeamIdOrDefault = (v) => v === 'default' || TEAM_ID.test(v)
const adminOnly = requireRole('admin')
// ── 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 ──────────────────────────────────────────────────────────────
// Both literal, and both under '/forum' rather than '/:id/forum', so they cannot
// be captured by the '/:id' lookup below — 'forum' is not an integer, but relying
// on the validator to reject it would mean the route table's meaning depended on
// a param check three lines further down.
teamsRouter.get(
'/forum/uploads',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Upload attribution across every Team forum'
// #swagger.description = 'Who uploaded what, when and how much. This view is why an attribution table exists at all: the liability an operator accepts before enabling uploads is meaningless if "who uploaded this" cannot be answered afterwards. Deleted rows are excluded unless `deleted=1` — a soft-deleted upload still has bytes on disk until the sweep runs.'
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size (default 100).' }
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' }
// #swagger.parameters['deleted'] = { in: 'query', required: false, schema: { type: 'string', enum: ['0','1'] }, description: 'Include soft-deleted uploads.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Uploads with their attribution', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumUploadList" } } } } */
query('limit').optional().isInt({ min: 1, max: 500 }).toInt(),
query('offset').optional().isInt({ min: 0 }).toInt(),
query('deleted').optional().isIn(['0', '1']),
validate,
ctrl.forumUploads,
)
teamsRouter.get(
'/forum/settings',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'The forum switch, the image policy, and the acknowledgement’s state'
// #swagger.description = 'The two settings themselves ride the ordinary admin settings endpoint and are published to every client; this route adds the one thing that is NOT public — whether the uploads acknowledgement has been given, by whom, and whether the notice has been reworded since. A stale acknowledgement does not disable uploads: it raises a banner and freezes every other forum setting until it is re-given.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Forum settings state', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumSettingsState" } } } } */
ctrl.forumSettingsState,
)
// ── The integration bridge (§7.2) — literal, and before /:id ──────────────
teamsRouter.get(
'/integrations',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'The Team notification bridge’s configuration (admin only)'
// #swagger.description = 'Every configured destination for the platform, the deployment-wide default first, alongside the events that may be bridged and which of them are members-only. A members-only event carries content nobody outside the Team may read, so enabling one requires an acknowledgement that the destination channel is restricted to that Team’s members — recorded here with who gave it.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Bridge configuration', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamIntegrationConfig" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
ctrl.integrationConfig,
)
teamsRouter.put(
'/integrations',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Create or replace one bridge destination (admin only)'
// #swagger.description = 'Omit teamId (or send null) to edit the deployment-wide default; a per-Team row overrides it. Enabling a bridge that carries team.forum.post or team.announcement without membersAck is refused 422 — the events are members-only always, and core cannot see a Discord channel’s permissions, so the operator’s acknowledgement is the only thing that can stand in for the check. Changing the channel clears a previous acknowledgement: it was given for a destination, not for a row.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The saved row', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamIntegrationRow" } } } } */
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[422] = { description: 'Not enableable — no channel, no events, or a members-only event without the acknowledgement', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
body('teamId').optional({ nullable: true }).isInt({ min: 1 }).toInt(),
body('events').isArray({ max: 8 }),
body('channelRef').optional({ nullable: true }).isString().trim().isLength({ max: 64 }),
body('enabled').optional().isBoolean().toBoolean(),
body('membersAck').optional().isBoolean().toBoolean(),
validate,
ctrl.saveIntegrationConfig,
)
teamsRouter.delete(
'/integrations/:teamId',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Remove one bridge destination (admin only)'
// #swagger.description = 'Pass the literal string default to remove the deployment-wide row. Removing a per-Team override makes that Team fall back to the default, which is not the same as disabling it — disable the row instead if that is what is wanted.'
// #swagger.parameters['teamId'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Team id, or the literal string default.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Removed', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
/* #swagger.responses[404] = { description: 'Nothing configured for that Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
param('teamId').custom(isTeamIdOrDefault),
validate,
ctrl.deleteIntegrationConfig,
)
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.get(
'/:id/forum/moderation',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'A Team’s forum moderation ledger'
// #swagger.description = 'Append-only, and deliberately separate from the site’s mod_actions/appeals pair (§5.3): that one is Discord-sanction-shaped and bot-owned, and routing a guild leader locking a thread through it would make ordinary housekeeping an appealable sanction. `actorRole` records which authority was exercised — a leader’s action appears only here, a staffer’s appears here AND in activity_log. Answers whether or not the forum is switched on.'
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The Team id.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The ledger, newest first', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumModerationLedger" } } } } */
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }).toInt(),
validate,
ctrl.forumModeration,
)
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