Files
API/src/models/feedback/admin/questions.admin.service.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

99 lines
3.1 KiB
TypeScript

import {NachklangFeedbackDB} from '../Feedback.db.js';
import {QuestionType} from '../feedback.interface.js';
import {AdminQuestion} from './admin.interface.js';
const mapRow = (row: any): AdminQuestion => ({
questionId: row.question_id,
label: row.label,
helpText: row.help_text,
questionType: row.question_type,
isArchived: !!row.is_archived
});
export const listQuestions = async (includeArchived: boolean): Promise<AdminQuestion[]> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const query = includeArchived
? 'SELECT * FROM questions ORDER BY created_at DESC'
: 'SELECT * FROM questions WHERE is_archived = 0 ORDER BY created_at DESC';
const rows = await conn.query(query);
return rows.map(mapRow);
} finally {
await conn.end();
}
};
export const createQuestion = async (label: string, helpText: string | null, questionType: QuestionType): Promise<number> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
const res = await conn.query(
'INSERT INTO questions (label, help_text, question_type) VALUES (?,?,?) RETURNING question_id',
[label, helpText, questionType]
);
await conn.commit();
return res[0].question_id;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
/**
* Edits label/help text only. question_type is immutable after creation -
* changing it would invalidate existing answers' question_type_snapshot
* semantics. The admin UI offers "archive and create new" instead.
*/
export const updateQuestion = async (questionId: number, label: string, helpText: string | null): Promise<boolean> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
const res = await conn.query('UPDATE questions SET label = ?, help_text = ? WHERE question_id = ?', [label, helpText, questionId]);
await conn.commit();
return res.affectedRows > 0;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export type RemoveQuestionResult = 'ARCHIVED' | 'DELETED' | 'NOT_FOUND';
/**
* Archives (soft delete) a question. Hard-deletes it instead if it has
* never been assigned to any event, so an admin's typo doesn't have to
* live forever in the library.
*/
export const removeQuestion = async (questionId: number): Promise<RemoveQuestionResult> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
const existsRows = await conn.query('SELECT 1 FROM questions WHERE question_id = ?', [questionId]);
if (existsRows.length === 0) {
await conn.rollback();
return 'NOT_FOUND';
}
const usageRows = await conn.query('SELECT 1 FROM event_questions WHERE question_id = ? LIMIT 1', [questionId]);
if (usageRows.length === 0) {
await conn.query('DELETE FROM questions WHERE question_id = ?', [questionId]);
await conn.commit();
return 'DELETED';
}
await conn.query('UPDATE questions SET is_archived = 1 WHERE question_id = ?', [questionId]);
await conn.commit();
return 'ARCHIVED';
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};