Files
API/src/models/feedback/admin/questions.admin.router.ts
T
Paddy 3ea9e630ed Migrate the API to native ESM and vitest; pin Node 26
Prep PR for the admin auth module (docs/plan-admin-auth.md step 1).
better-auth 1.7 ships ESM only, so the API moves off CommonJS:

- "type": "module", module nodenext, target ES2024, .js suffixes on all
  relative imports, require('mariadb'|'cors') replaced by imports, and
  export= packages (winston, app-root-path, bcrypt) consumed via default
  imports. The logger now uses appRoot.path explicitly.
- TypeScript 5.9, @types/node 26, tslint removed. Node 26 pinned via
  engines and .nvmrc (Plesk runs 26).
- Jest 28 + ts-jest replaced by vitest 5. Eight test files depend on
  hoisted module mocks with static imports and resetModules + require,
  which Jest's ESM mode does not support; vitest keeps them nearly
  verbatim. Coverage via @vitest/coverage-v8 (lcov), Sonar generic report
  via vitest-sonar-reporter, so sonar-project.properties is unchanged.
  vitest.config.ts sets FEEDBACK_IP_SALT so the suite passes without a
  local .env.
- dotenv 8 -> 16 and axios 0.24 -> 1.x: their old typings are not
  resolvable under nodenext.
- autoCommit: false dropped from the pool configs; it is not a mariadb
  connector option and was silently ignored.

tsc clean, 96/96 tests green, compiled app boots and serves /, /docs and
CORS under Node ESM.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 16:02:26 +02:00

176 lines
6.0 KiB
TypeScript

/**
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import * as QuestionsAdminService from './questions.admin.service.js';
import {sendServerError} from '../feedback.errors.js';
/**
* Router Definition
*/
export const questionsAdminRouter = express.Router();
/**
* @swagger
* /feedback/admin/questions:
* get:
* summary: List the question library
* tags: [feedback-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: query
* name: includeArchived
* schema:
* type: boolean
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/AdminQuestion'
* 401:
* description: Unauthorized
* post:
* summary: Create a question
* tags: [feedback-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [label, questionType]
* properties:
* label:
* type: string
* helpText:
* type: string
* questionType:
* $ref: '#/components/schemas/QuestionType'
* responses:
* 201:
* description: Created
* 400:
* description: Missing or invalid fields
* 401:
* description: Unauthorized
*/
questionsAdminRouter.get('/', async (req: Request, res: Response) => {
try {
const includeArchived = req.query.includeArchived === 'true';
res.status(200).send(await QuestionsAdminService.listQuestions(includeArchived));
} catch (e: any) {
sendServerError(res, e);
}
});
const VALID_TYPES = ['SONG_PICK', 'SONG_RATING', 'FREE_TEXT'];
questionsAdminRouter.post('/', async (req: Request, res: Response) => {
try {
const {label, helpText, questionType} = req.body || {};
if (!label || !VALID_TYPES.includes(questionType)) {
res.status(400).send({status: 'BAD_REQUEST', message: 'label and a valid questionType are required'});
return;
}
const questionId = await QuestionsAdminService.createQuestion(label, helpText || null, questionType);
res.status(201).send({questionId});
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /feedback/admin/questions/{questionId}:
* put:
* summary: Edit a question's label/help text
* description: question_type is immutable after creation - the admin UI offers "archive and create new" instead.
* tags: [feedback-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: questionId
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [label]
* properties:
* label:
* type: string
* helpText:
* type: string
* responses:
* 200:
* description: Updated
* 400:
* description: Missing label
* 404:
* description: Unknown question
* 401:
* description: Unauthorized
* delete:
* summary: Archive (or hard-delete) a question
* description: Archives the question if it has ever been used; hard-deletes it if it has never been assigned to any event.
* tags: [feedback-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: questionId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Archived or deleted
* 404:
* description: Unknown question
* 401:
* description: Unauthorized
*/
questionsAdminRouter.put('/:questionId', async (req: Request, res: Response) => {
try {
const {label, helpText} = req.body || {};
if (!label) {
res.status(400).send({status: 'BAD_REQUEST', message: 'label is required'});
return;
}
const updated = await QuestionsAdminService.updateQuestion(Number(req.params.questionId), label, helpText || null);
if (!updated) {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
res.status(200).send({status: 'OK'});
} catch (e: any) {
sendServerError(res, e);
}
});
questionsAdminRouter.delete('/:questionId', async (req: Request, res: Response) => {
try {
const result = await QuestionsAdminService.removeQuestion(Number(req.params.questionId));
if (result === 'NOT_FOUND') {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
res.status(200).send({status: result});
} catch (e: any) {
sendServerError(res, e);
}
});