From 61fd21c1baf0839821b3d9791b584e05d7ffa7b8 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 15 Mar 2026 06:07:45 +0000
Subject: [PATCH 1/3] Initial plan
From 2448f13cef4acef361be19c61524ad7495d33e97 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 15 Mar 2026 06:12:05 +0000
Subject: [PATCH 2/3] Add admin question management portal for editing tossups
and bonuses by ID
Co-authored-by: geoffrey-wu <42471355+geoffrey-wu@users.noreply.github.com>
---
client/admin/index.html | 1 +
.../admin/question-management/question.html | 175 ++++++++++++++++++
client/admin/question-management/question.js | 136 ++++++++++++++
database/qbreader/admin/update-question.js | 91 +++++++++
routes/api/admin/question-management/index.js | 2 +
.../api/admin/question-management/question.js | 86 +++++++++
6 files changed, 491 insertions(+)
create mode 100644 client/admin/question-management/question.html
create mode 100644 client/admin/question-management/question.js
create mode 100644 database/qbreader/admin/update-question.js
create mode 100644 routes/api/admin/question-management/question.js
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
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Edit Question by ID
+
+
+
+
+
+
+
+
+
+
+
Please enter a valid question _id.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..ae601a80a
--- /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.some(p => typeof p !== 'string')) {
+ return res.status(400).send('Invalid parts field');
+ }
+ }
+
+ if (answers !== undefined) {
+ if (!Array.isArray(answers) || answers.some(a => typeof a !== 'string')) {
+ return res.status(400).send('Invalid answers field');
+ }
+ }
+
+ 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;
From c440538b8883b2b44e72a8c0f40d7c6c53f84e6c Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 15 Mar 2026 06:13:02 +0000
Subject: [PATCH 3/3] Add array length validation for bonus parts and answers
Co-authored-by: geoffrey-wu <42471355+geoffrey-wu@users.noreply.github.com>
---
routes/api/admin/question-management/question.js | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/routes/api/admin/question-management/question.js b/routes/api/admin/question-management/question.js
index ae601a80a..dd4dedb2d 100644
--- a/routes/api/admin/question-management/question.js
+++ b/routes/api/admin/question-management/question.js
@@ -55,14 +55,14 @@ router.put('/update-bonus', async (req, res) => {
}
if (parts !== undefined) {
- if (!Array.isArray(parts) || parts.some(p => typeof p !== 'string')) {
- return res.status(400).send('Invalid parts field');
+ 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.some(a => typeof a !== 'string')) {
- return res.status(400).send('Invalid answers field');
+ 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');
}
}