// 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 ────────────────────────────────────────────────────────────── // 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, ) 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