diff --git a/client/admin/index.html b/client/admin/index.html index cdaa570af..cae4dc735 100644 --- a/client/admin/index.html +++ b/client/admin/index.html @@ -103,6 +103,7 @@
Question Management
Update Set Update Packet + Edit Question
diff --git a/client/admin/question-management/question.html b/client/admin/question-management/question.html new file mode 100644 index 000000000..d1219f17b --- /dev/null +++ b/client/admin/question-management/question.html @@ -0,0 +1,175 @@ + + + + + QB Reader + + + + + + + + + + + + + + + + +
+
+ Back to admin page +
+

Edit Question by ID

+
+ + +
+
+ +
+ + +
+
Please enter a valid question _id.
+
+ +
+
+
+
+
+
+ +
+
+ + +
Allowed HTML tags: <b>, <i>, <u>
+
+
+ + +
Allowed HTML tags: <b>, <i>, <u>
+
+ +
+ +
+
+ + +
Allowed HTML tags: <b>, <i>, <u>
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
Allowed HTML tags: <b>, <i>, <u>
+ +
+
+ + + + + + + diff --git a/client/admin/question-management/question.js b/client/admin/question-management/question.js new file mode 100644 index 000000000..8812b4b25 --- /dev/null +++ b/client/admin/question-management/question.js @@ -0,0 +1,136 @@ +const questionTypeSelect = document.getElementById('question-type'); +const questionIdInput = document.getElementById('question-id'); +const loadButton = document.getElementById('load-question'); +const previewDiv = document.getElementById('question-preview'); +const previewHeader = document.getElementById('preview-header'); +const previewBody = document.getElementById('preview-body'); +const tossupForm = document.getElementById('tossup-form'); +const bonusForm = document.getElementById('bonus-form'); + +function escapeHTML (text) { + const div = document.createElement('div'); + div.appendChild(document.createTextNode(text)); + return div.innerHTML; +} + +function showPreview (type, data) { + previewDiv.classList.remove('d-none'); + if (type === 'tossup') { + const tossup = data; + const alternateSubcategory = tossup.alternate_subcategory ? ' (' + tossup.alternate_subcategory + ')' : ''; + previewHeader.innerHTML = `${escapeHTML(tossup.set.name)} | ${escapeHTML(tossup.category)} | ${escapeHTML(tossup.subcategory)}${escapeHTML(alternateSubcategory)} | ${tossup.difficulty}`; + previewBody.innerHTML = `${tossup.question}
ANSWER: ${tossup.answer}
`; + } else { + const bonus = data; + const alternateSubcategory = bonus.alternate_subcategory ? ' (' + bonus.alternate_subcategory + ')' : ''; + previewHeader.innerHTML = `${escapeHTML(bonus.set.name)} | ${escapeHTML(bonus.category)} | ${escapeHTML(bonus.subcategory)}${escapeHTML(alternateSubcategory)} | ${bonus.difficulty}`; + let bodyHTML = `
${bonus.leadin}
`; + for (let i = 0; i < bonus.parts.length; i++) { + bodyHTML += `
[10] ${bonus.parts[i]}
`; + bodyHTML += `
ANSWER: ${bonus.answers[i]}
`; + } + previewBody.innerHTML = bodyHTML; + } +} + +loadButton.addEventListener('click', async function () { + const questionType = questionTypeSelect.value; + const questionId = questionIdInput.value.trim(); + + if (!questionId) { + questionIdInput.classList.add('is-invalid'); + return; + } + questionIdInput.classList.remove('is-invalid'); + + tossupForm.classList.add('d-none'); + bonusForm.classList.add('d-none'); + previewDiv.classList.add('d-none'); + + const endpoint = questionType === 'tossup' ? '/api/tossup' : '/api/bonus'; + const response = await fetch(`${endpoint}?_id=${encodeURIComponent(questionId)}`); + if (!response.ok) { + window.alert(`Error loading question: ${await response.text()}`); + return; + } + + const data = await response.json(); + + if (questionType === 'tossup') { + const tossup = data.tossup; + showPreview('tossup', tossup); + document.getElementById('tossup-question').value = tossup.question; + document.getElementById('tossup-answer').value = tossup.answer; + tossupForm.classList.remove('d-none'); + } else { + const bonus = data.bonus; + showPreview('bonus', bonus); + document.getElementById('bonus-leadin').value = bonus.leadin; + for (let i = 0; i < bonus.parts.length; i++) { + document.getElementById(`bonus-part-${i}`).value = bonus.parts[i]; + document.getElementById(`bonus-answer-${i}`).value = bonus.answers[i]; + } + bonusForm.classList.remove('d-none'); + } +}); + +tossupForm.addEventListener('submit', async function (event) { + event.preventDefault(); + event.stopPropagation(); + + const questionId = questionIdInput.value.trim(); + const question = document.getElementById('tossup-question').value; + const answer = document.getElementById('tossup-answer').value; + + document.getElementById('tossup-submit').disabled = true; + document.getElementById('tossup-submit').textContent = 'Saving...'; + + const response = await fetch('/api/admin/question-management/question/update-tossup', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ _id: questionId, question, answer }) + }); + + if (response.ok) { + window.alert('Tossup updated successfully'); + loadButton.click(); + } else { + window.alert(`Error updating tossup: ${await response.text()}`); + } + + document.getElementById('tossup-submit').disabled = false; + document.getElementById('tossup-submit').textContent = 'Save Changes'; +}); + +bonusForm.addEventListener('submit', async function (event) { + event.preventDefault(); + event.stopPropagation(); + + const questionId = questionIdInput.value.trim(); + const leadin = document.getElementById('bonus-leadin').value; + const parts = []; + const answers = []; + for (let i = 0; i < 3; i++) { + parts.push(document.getElementById(`bonus-part-${i}`).value); + answers.push(document.getElementById(`bonus-answer-${i}`).value); + } + + document.getElementById('bonus-submit').disabled = true; + document.getElementById('bonus-submit').textContent = 'Saving...'; + + const response = await fetch('/api/admin/question-management/question/update-bonus', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ _id: questionId, leadin, parts, answers }) + }); + + if (response.ok) { + window.alert('Bonus updated successfully'); + loadButton.click(); + } else { + window.alert(`Error updating bonus: ${await response.text()}`); + } + + document.getElementById('bonus-submit').disabled = false; + document.getElementById('bonus-submit').textContent = 'Save Changes'; +}); diff --git a/database/qbreader/admin/update-question.js b/database/qbreader/admin/update-question.js new file mode 100644 index 000000000..c697c527e --- /dev/null +++ b/database/qbreader/admin/update-question.js @@ -0,0 +1,91 @@ +import { tossups, bonuses } from '../collections.js'; + +import createDOMPurify from 'dompurify'; +import { JSDOM } from 'jsdom'; + +const window = new JSDOM('').window; +const DOMPurify = createDOMPurify(window); + +const ALLOWED_TAGS = ['b', 'i', 'u']; + +/** + * Sanitizes a string to only allow , , HTML tags. + * @param {string} text + * @returns {string} + */ +function sanitizeHTML (text) { + return DOMPurify.sanitize(text, { ALLOWED_TAGS }); +} + +/** + * Strips all HTML tags from a string. + * @param {string} text + * @returns {string} + */ +function stripHTML (text) { + return DOMPurify.sanitize(text, { ALLOWED_TAGS: [] }); +} + +/** + * Updates a tossup's text fields by its _id. + * @param {import('mongodb').ObjectId} _id + * @param {object} fields + * @param {string} [fields.question] + * @param {string} [fields.answer] + * @returns {Promise} + */ +async function updateTossup (_id, { question, answer }) { + const $set = { updatedAt: new Date() }; + + if (typeof question === 'string') { + $set.question = sanitizeHTML(question); + $set.question_sanitized = stripHTML(question); + } + + if (typeof answer === 'string') { + $set.answer = sanitizeHTML(answer); + $set.answer_sanitized = stripHTML(answer); + } + + if (Object.keys($set).length === 1) { + return null; + } + + return await tossups.updateOne({ _id }, { $set }); +} + +/** + * Updates a bonus's text fields by its _id. + * @param {import('mongodb').ObjectId} _id + * @param {object} fields + * @param {string} [fields.leadin] + * @param {string[]} [fields.parts] + * @param {string[]} [fields.answers] + * @returns {Promise} + */ +async function updateBonus (_id, { leadin, parts, answers }) { + const $set = { updatedAt: new Date() }; + + if (typeof leadin === 'string') { + $set.leadin = sanitizeHTML(leadin); + $set.leadin_sanitized = stripHTML(leadin); + } + + if (Array.isArray(parts)) { + $set.parts = parts.map(sanitizeHTML); + $set.parts_sanitized = parts.map(stripHTML); + } + + if (Array.isArray(answers)) { + $set.answers = answers.map(sanitizeHTML); + $set.answers_sanitized = answers.map(stripHTML); + } + + if (Object.keys($set).length === 1) { + return null; + } + + return await bonuses.updateOne({ _id }, { $set }); +} + +export { updateTossup, updateBonus }; diff --git a/routes/api/admin/question-management/index.js b/routes/api/admin/question-management/index.js index 1723f3ea3..00ef483cd 100644 --- a/routes/api/admin/question-management/index.js +++ b/routes/api/admin/question-management/index.js @@ -1,4 +1,5 @@ import packetRouter from './packet.js'; +import questionRouter from './question.js'; import setRouter from './set.js'; import { Router } from 'express'; @@ -6,6 +7,7 @@ import { Router } from 'express'; const router = Router(); router.use('/packet', packetRouter); +router.use('/question', questionRouter); router.use('/set', setRouter); export default router; diff --git a/routes/api/admin/question-management/question.js b/routes/api/admin/question-management/question.js new file mode 100644 index 000000000..dd4dedb2d --- /dev/null +++ b/routes/api/admin/question-management/question.js @@ -0,0 +1,86 @@ +import { updateTossup, updateBonus } from '../../../../database/qbreader/admin/update-question.js'; + +import { Router } from 'express'; +import { ObjectId } from 'mongodb'; + +const router = Router(); + +router.put('/update-tossup', async (req, res) => { + const { _id, question, answer } = req.body; + + if (typeof _id !== 'string') { + return res.status(400).send('Missing or invalid _id'); + } + + let objectId; + try { objectId = new ObjectId(_id); } catch (e) { return res.status(400).send('Invalid _id'); } + + if (question !== undefined && typeof question !== 'string') { + return res.status(400).send('Invalid question field'); + } + + if (answer !== undefined && typeof answer !== 'string') { + return res.status(400).send('Invalid answer field'); + } + + if (question === undefined && answer === undefined) { + return res.status(400).send('No fields to update'); + } + + const result = await updateTossup(objectId, { question, answer }); + + if (result === null) { + return res.status(400).send('No fields to update'); + } + + if (result.matchedCount === 0) { + return res.status(404).send('Tossup not found'); + } + + res.sendStatus(200); +}); + +router.put('/update-bonus', async (req, res) => { + const { _id, leadin, parts, answers } = req.body; + + if (typeof _id !== 'string') { + return res.status(400).send('Missing or invalid _id'); + } + + let objectId; + try { objectId = new ObjectId(_id); } catch (e) { return res.status(400).send('Invalid _id'); } + + if (leadin !== undefined && typeof leadin !== 'string') { + return res.status(400).send('Invalid leadin field'); + } + + if (parts !== undefined) { + if (!Array.isArray(parts) || parts.length !== 3 || parts.some(p => typeof p !== 'string')) { + return res.status(400).send('Invalid parts field: must be an array of 3 strings'); + } + } + + if (answers !== undefined) { + if (!Array.isArray(answers) || answers.length !== 3 || answers.some(a => typeof a !== 'string')) { + return res.status(400).send('Invalid answers field: must be an array of 3 strings'); + } + } + + if (leadin === undefined && parts === undefined && answers === undefined) { + return res.status(400).send('No fields to update'); + } + + const result = await updateBonus(objectId, { leadin, parts, answers }); + + if (result === null) { + return res.status(400).send('No fields to update'); + } + + if (result.matchedCount === 0) { + return res.status(404).send('Bonus not found'); + } + + res.sendStatus(200); +}); + +export default router;