`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>
344 lines
24 KiB
JavaScript
344 lines
24 KiB
JavaScript
// 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
|