From f26ae43008df88ee71033dda180170da42df5fa8 Mon Sep 17 00:00:00 2001 From: Mohammad Elwan Date: Mon, 10 Aug 2026 12:50:02 +0200 Subject: [PATCH 1/7] feat : add admin tools nav element --- ...10090011-extend-nav_element-admin_tools.js | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 backend/db/migrations/20260810090011-extend-nav_element-admin_tools.js diff --git a/backend/db/migrations/20260810090011-extend-nav_element-admin_tools.js b/backend/db/migrations/20260810090011-extend-nav_element-admin_tools.js new file mode 100644 index 000000000..e317952d7 --- /dev/null +++ b/backend/db/migrations/20260810090011-extend-nav_element-admin_tools.js @@ -0,0 +1,54 @@ +'use strict'; + +/** + * Add Admin Tools page to the Settings nav group. + * Extensible dashboard surface for admin file/system utilities. + * + * @author Mohammad Elwan + */ + +const navElements = [ + { + name: 'Admin Tools', + groupId: 'Settings', + icon: 'tools', + order: 4, + admin: true, + path: 'admin_tools', + component: 'AdminTools', + }, +]; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + await queryInterface.bulkInsert( + 'nav_element', + await Promise.all( + navElements.map(async (element) => { + const groupId = await queryInterface.rawSelect( + 'nav_group', + { where: { name: element.groupId } }, + ['id'] + ); + + return { + ...element, + groupId, + createdAt: new Date(), + updatedAt: new Date(), + }; + }) + ), + {} + ); + }, + + async down(queryInterface) { + await queryInterface.bulkDelete( + 'nav_element', + { name: navElements.map((element) => element.name) }, + {} + ); + }, +}; From 613988cf3708b9dd5bd47cac790d892fa5b53f85 Mon Sep 17 00:00:00 2001 From: Mohammad Elwan Date: Mon, 10 Aug 2026 12:50:37 +0200 Subject: [PATCH 2/7] feat : add document file replace socket for admins --- backend/webserver/sockets/document.js | 126 ++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/backend/webserver/sockets/document.js b/backend/webserver/sockets/document.js index cfa4538f1..825095d48 100644 --- a/backend/webserver/sockets/document.js +++ b/backend/webserver/sockets/document.js @@ -1613,6 +1613,131 @@ class DocumentSocket extends Socket { } + /** + * Replace the PDF or ZIP file of an existing document on disk. + * + * Keeps the same document id and hash. Used by admins to correct a wrong + * submission file without creating a new document. For PDFs, existing CARE + * annotations and comments on the document are removed. + * + * @author Mohammad Elwan + * @param {Object} data - The input data from the frontend + * @param {number} data.documentId - The ID of the document to replace + * @param {Buffer} data.file - The binary content of the replacement file + * @param {string} data.name - The original filename (used to check the extension) + * @param {Object} options - Additional configuration parameter + * @param {Object} options.transaction - Sequelize DB transaction options + * @returns {Promise} + * @throws {Error} - If the user is not an admin or the file type does not match the document + */ + async replaceDocumentFile(data, options) { + if (!(await this.isAdmin())) { + throw new Error("You do not have permission to replace document files."); + } + + if (!data || !data.file) { + throw new Error("No file uploaded"); + } + + if (!data.documentId) { + throw new Error("documentId is required"); + } + + if (!data.name || typeof data.name !== "string") { + throw new Error("File name is required"); + } + + const document = await this.validateDocument(data.documentId, "id", false); + const extensionMap = { + [docTypes.DOC_TYPE_PDF]: ".pdf", + [docTypes.DOC_TYPE_ZIP]: ".zip", + }; + const expectedExtension = extensionMap[document.type]; + + if (!expectedExtension) { + throw new Error("Only PDF and ZIP documents can be replaced with this tool."); + } + + const fileExtension = data.name.substring(data.name.lastIndexOf(".")).toLowerCase(); + if (fileExtension !== expectedExtension) { + throw new Error( + `File type mismatch: document requires ${expectedExtension}, got ${fileExtension || "(none)"}` + ); + } + + if (document.type === docTypes.DOC_TYPE_PDF) { + await this.clearDocumentAnnotations(document.id, options); + } + + const target = path.join(UPLOAD_PATH, `${document.hash}${expectedExtension}`); + await this.writeReplacementFile(document, data.file, target, expectedExtension); + + return { + documentId: document.id, + hash: document.hash, + message: `Replaced ${expectedExtension} file for document #${document.id}.`, + }; + } + + /** + * Soft-delete CARE annotations and comments for a document. + * Matches the cascade used when a PDF document is deleted. + * + * @author Mohammad Elwan + * @param {number} documentId - The document whose annotations should be removed + * @param {Object} options - Additional configuration parameter + * @param {Object} options.transaction - Sequelize DB transaction options + * @returns {Promise} + */ + async clearDocumentAnnotations(documentId, options) { + const annotations = await this.models["annotation"].getAllByKey("documentId", documentId); + const uniqueAnnotationIds = [...new Set((annotations || []).map((annotation) => annotation.id))]; + for (const annotationId of uniqueAnnotationIds) { + await this.models["annotation"].deleteById(annotationId, {transaction: options.transaction}); + } + + const comments = await this.models["comment"].getAllByKey("documentId", documentId); + const uniqueCommentIds = [...new Set((comments || []).map((comment) => comment.id))]; + for (const commentId of uniqueCommentIds) { + await this.models["comment"].deleteById(commentId, {transaction: options.transaction}); + } + } + + /** + * Write a replacement file to the document's existing hash path. + * For PDFs, best-effort strip embedded annotations via PDFRPC (same as documentAdd). + * + * @author Mohammad Elwan + * @param {Object} document - The document record + * @param {Buffer} fileData - The uploaded file content + * @param {string} target - The filesystem path to overwrite + * @param {string} expectedExtension - The expected file extension + * @returns {Promise} + */ + async writeReplacementFile(document, fileData, target, expectedExtension) { + if (expectedExtension === ".pdf") { + try { + const {file} = await this.server.rpcs["PDFRPC"].deleteAllAnnotations({ + file: fileData, + document, + }); + if (!file) { + throw new Error("Couldn't delete original annotations"); + } + fs.writeFileSync(target, file); + return; + } catch (annotationRpcErr) { + this.logger.error( + "Error deleting annotations during document file replace: " + annotationRpcErr.message + ); + fs.writeFileSync(target, fileData); + return; + } + } + + fs.writeFileSync(target, fileData); + } + /** * Subscribe the client's socket to a document-specific communication channel. * @@ -1675,6 +1800,7 @@ class DocumentSocket extends Socket { this.createSocket("documentGet", this.getDocument, {}, false); this.createSocket("documentCreate", this.createDocument, {}, true); this.createSocket("documentAdd", this.addDocument, {}, true); + this.createSocket("documentReplaceFile", this.replaceDocumentFile, {}, true); this.createSocket("documentUpdate", this.updateDocument, {}, true); this.createSocket("documentGetMoodleSubmissions", this.documentGetMoodleSubmissions, {}, false); this.createSocket("documentDownloadMoodleSubmissions", this.downloadMoodleSubmissions, {}, false); From 3d9f22cfd6440ccffdfe6e915d1e462204afacf2 Mon Sep 17 00:00:00 2001 From: Mohammad Elwan Date: Mon, 10 Aug 2026 12:51:07 +0200 Subject: [PATCH 3/7] feat : add admin tools page with document file replace modal --- .../src/components/dashboard/AdminTools.vue | 70 ++++ .../admin_tools/ReplaceDocumentFileModal.vue | 331 ++++++++++++++++++ 2 files changed, 401 insertions(+) create mode 100644 frontend/src/components/dashboard/AdminTools.vue create mode 100644 frontend/src/components/dashboard/admin_tools/ReplaceDocumentFileModal.vue diff --git a/frontend/src/components/dashboard/AdminTools.vue b/frontend/src/components/dashboard/AdminTools.vue new file mode 100644 index 000000000..4905049b2 --- /dev/null +++ b/frontend/src/components/dashboard/AdminTools.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/frontend/src/components/dashboard/admin_tools/ReplaceDocumentFileModal.vue b/frontend/src/components/dashboard/admin_tools/ReplaceDocumentFileModal.vue new file mode 100644 index 000000000..9b566fc2e --- /dev/null +++ b/frontend/src/components/dashboard/admin_tools/ReplaceDocumentFileModal.vue @@ -0,0 +1,331 @@ + + + + + From 68ebae075e010fd373a52c6dc313cfd4f93c9b14 Mon Sep 17 00:00:00 2001 From: Mohammad Elwan Date: Mon, 10 Aug 2026 13:29:07 +0200 Subject: [PATCH 4/7] fix: pass replace-file reads through the transaction and write after commit --- backend/webserver/sockets/document.js | 89 ++++++++++++++++++++------- 1 file changed, 66 insertions(+), 23 deletions(-) diff --git a/backend/webserver/sockets/document.js b/backend/webserver/sockets/document.js index 825095d48..68deb75f7 100644 --- a/backend/webserver/sockets/document.js +++ b/backend/webserver/sockets/document.js @@ -1670,7 +1670,7 @@ class DocumentSocket extends Socket { } const target = path.join(UPLOAD_PATH, `${document.hash}${expectedExtension}`); - await this.writeReplacementFile(document, data.file, target, expectedExtension); + await this.writeReplacementFile(document, data.file, target, expectedExtension, options); return { documentId: document.id, @@ -1690,13 +1690,21 @@ class DocumentSocket extends Socket { * @returns {Promise} */ async clearDocumentAnnotations(documentId, options) { - const annotations = await this.models["annotation"].getAllByKey("documentId", documentId); + const annotations = await this.models["annotation"].getAllByKey( + "documentId", + documentId, + {transaction: options.transaction} + ); const uniqueAnnotationIds = [...new Set((annotations || []).map((annotation) => annotation.id))]; for (const annotationId of uniqueAnnotationIds) { await this.models["annotation"].deleteById(annotationId, {transaction: options.transaction}); } - const comments = await this.models["comment"].getAllByKey("documentId", documentId); + const comments = await this.models["comment"].getAllByKey( + "documentId", + documentId, + {transaction: options.transaction} + ); const uniqueCommentIds = [...new Set((comments || []).map((comment) => comment.id))]; for (const commentId of uniqueCommentIds) { await this.models["comment"].deleteById(commentId, {transaction: options.transaction}); @@ -1704,38 +1712,73 @@ class DocumentSocket extends Socket { } /** - * Write a replacement file to the document's existing hash path. - * For PDFs, best-effort strip embedded annotations via PDFRPC (same as documentAdd). + * Prepare replacement file bytes (best-effort PDFRPC strip for PDFs). + * + * @author Mohammad Elwan + * @param {Object} document - The document record + * @param {Buffer} fileData - The uploaded file content + * @param {string} expectedExtension - The expected file extension + * @returns {Promise} Bytes to write after the DB transaction commits + */ + async prepareReplacementFileBytes(document, fileData, expectedExtension) { + if (expectedExtension !== ".pdf") { + return fileData; + } + + try { + const {file} = await this.server.rpcs["PDFRPC"].deleteAllAnnotations({ + file: fileData, + document, + }); + if (!file) { + throw new Error("Couldn't delete original annotations"); + } + return file; + } catch (annotationRpcErr) { + this.logger.error( + "Error deleting annotations during document file replace: " + annotationRpcErr.message + ); + return fileData; + } + } + + /** + * Schedule writing a replacement file after the DB transaction commits. + * Prepares bytes (and strips embedded PDF annotations) before commit, but only + * mutates the live hash path in afterCommit so a rollback cannot leave disk and DB out of sync. * * @author Mohammad Elwan * @param {Object} document - The document record * @param {Buffer} fileData - The uploaded file content * @param {string} target - The filesystem path to overwrite * @param {string} expectedExtension - The expected file extension + * @param {Object} options - Additional configuration parameter + * @param {Object} options.transaction - Sequelize DB transaction options * @returns {Promise} */ - async writeReplacementFile(document, fileData, target, expectedExtension) { - if (expectedExtension === ".pdf") { + async writeReplacementFile(document, fileData, target, expectedExtension, options) { + const bytes = await this.prepareReplacementFileBytes(document, fileData, expectedExtension); + const tempPath = `${target}.replace-tmp`; + + options.transaction.afterCommit(() => { try { - const {file} = await this.server.rpcs["PDFRPC"].deleteAllAnnotations({ - file: fileData, - document, - }); - if (!file) { - throw new Error("Couldn't delete original annotations"); - } - fs.writeFileSync(target, file); - return; - } catch (annotationRpcErr) { + fs.writeFileSync(tempPath, bytes); + fs.renameSync(tempPath, target); + } catch (err) { this.logger.error( - "Error deleting annotations during document file replace: " + annotationRpcErr.message + `Error writing replacement file for document #${document.id}: ${err.message}` ); - fs.writeFileSync(target, fileData); - return; + try { + if (fs.existsSync(tempPath)) { + fs.unlinkSync(tempPath); + } + } catch (cleanupErr) { + this.logger.error( + `Error cleaning up temp replacement file: ${cleanupErr.message}` + ); + } } - } - - fs.writeFileSync(target, fileData); + }); } /** From 1bc985f582b37f38164c67402796b017bcf9180a Mon Sep 17 00:00:00 2001 From: Mohammad Elwan Date: Mon, 10 Aug 2026 13:29:23 +0200 Subject: [PATCH 5/7] docs: add JSDoc to replace document file modal methods --- .../dashboard/admin_tools/ReplaceDocumentFileModal.vue | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frontend/src/components/dashboard/admin_tools/ReplaceDocumentFileModal.vue b/frontend/src/components/dashboard/admin_tools/ReplaceDocumentFileModal.vue index 9b566fc2e..5afc7fecf 100644 --- a/frontend/src/components/dashboard/admin_tools/ReplaceDocumentFileModal.vue +++ b/frontend/src/components/dashboard/admin_tools/ReplaceDocumentFileModal.vue @@ -242,6 +242,7 @@ export default { /** * Clear selection when the selected document is no longer in the filtered table. + * @returns {void} */ clearSelectionIfFilteredOut() { if (!this.selectedRows || this.selectedRows.length === 0) { @@ -254,12 +255,18 @@ export default { } }, + /** + * Store the selected replacement file from the form upload control. + * @param {File} file - The file chosen by the user, or null when cleared + * @returns {void} + */ handleFileChange(file) { this.selectedFile = file || null; }, /** * Open the modal and reset form state. + * @returns {void} */ open() { this.replacing = false; @@ -274,6 +281,7 @@ export default { /** * Validate client-side type match and emit documentReplaceFile. + * @returns {void} */ replace() { if (!this.canSubmit) { From 6ae67c94bbf9ecc385a2d0b889353a1f27acd569 Mon Sep 17 00:00:00 2001 From: Mohammad Elwan Date: Mon, 10 Aug 2026 13:29:53 +0200 Subject: [PATCH 6/7] docs: document documentReplaceFile socket and admin tools user story --- docs/source/for_developers/backend/socket.rst | 18 ++++++++++++ .../for_developers/basics/user_stories.rst | 28 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/docs/source/for_developers/backend/socket.rst b/docs/source/for_developers/backend/socket.rst index 629a05d12..36d72826c 100644 --- a/docs/source/for_developers/backend/socket.rst +++ b/docs/source/for_developers/backend/socket.rst @@ -445,6 +445,24 @@ In these cases, you can register a manual `afterCommit` hook on the transaction When using ``autoTable`` models, commit-time change tracking and ``Refresh`` emits are automatic. Details in :ref:`Store Updates &
Refresh> Events `. +.. _document-replace-file: + +documentReplaceFile +~~~~~~~~~~~~~~~~~~~ + +Admin-only socket used by the Admin Tools dashboard to correct a wrong PDF or ZIP on disk +without creating a new document row. + +- Event name: ``documentReplaceFile`` +- Registered with ``createSocket(..., true)`` (transactional) +- Input: ``documentId``, ``file`` (binary), ``name`` (original filename for extension checks) +- Keeps the same document ``id`` and ``hash``; overwrites ``files/{hash}.pdf`` or ``files/{hash}.zip`` +- Only PDF and ZIP documents are supported; the upload extension must match the document type +- For PDFs: soft-deletes existing CARE annotations and comments on that document, and best-effort + strips embedded PDF annotations via PDFRPC (falls back to the raw upload if strip fails) +- Destructive disk write runs in ``afterCommit`` (temp file then rename) so a transaction rollback + cannot leave the live hash path replaced while annotation deletes are undone + .. _document-create-example: Example Lifecycle diff --git a/docs/source/for_developers/basics/user_stories.rst b/docs/source/for_developers/basics/user_stories.rst index 5bd3b6105..923d61b8b 100644 --- a/docs/source/for_developers/basics/user_stories.rst +++ b/docs/source/for_developers/basics/user_stories.rst @@ -1722,6 +1722,34 @@ Manage Configurations ----- +Replace Document File from Admin Tools +-------------------------------------- + +.. container:: user-story + + :Story: + As an **Admin**, I want to replace the PDF or ZIP file of an existing document from + Admin Tools, so that I can correct a wrong submission file without creating a new + document or breaking existing links that use the same document id and hash. + + :Acceptance: + - From Settings → Admin Tools, I can open the replace-document-file tool. + - I can filter and search documents, select one PDF or ZIP document, and upload a + replacement file of the same type. + - After a successful replace, the document keeps the same id and hash; only the file + bytes on disk change. + - When I replace a PDF, existing CARE annotations and comments on that document are + removed. + + **Negative cases:** + + - When I am not an admin, I cannot use Admin Tools or the replace socket. + - When the uploaded file extension does not match the selected document type, the + replace is rejected and the stored file is unchanged. + - When the selected document is not a PDF or ZIP, the tool does not allow replace. + +----- + Export User Statistics ----------------------- From 8f67c669c4a4b4a0cf9d72da7ae407a08eb5ec4a Mon Sep 17 00:00:00 2001 From: Mohammad Elwan Date: Mon, 10 Aug 2026 13:41:22 +0200 Subject: [PATCH 7/7] fix: write replace-file temp before commit and notify on rename failure --- backend/webserver/sockets/document.js | 25 ++++++++----------- docs/source/for_developers/backend/socket.rst | 5 ++-- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/backend/webserver/sockets/document.js b/backend/webserver/sockets/document.js index 68deb75f7..2206fd663 100644 --- a/backend/webserver/sockets/document.js +++ b/backend/webserver/sockets/document.js @@ -1743,9 +1743,10 @@ class DocumentSocket extends Socket { } /** - * Schedule writing a replacement file after the DB transaction commits. - * Prepares bytes (and strips embedded PDF annotations) before commit, but only - * mutates the live hash path in afterCommit so a rollback cannot leave disk and DB out of sync. + * Write a replacement temp file, then rename onto the live hash path after commit. + * Temp write happens before commit (failure rolls back annotation deletes). Only the + * rename runs in afterCommit. If rename fails after commit, the socket callback fails + * so the admin is notified (temp file is left for manual recovery). * * @author Mohammad Elwan * @param {Object} document - The document record @@ -1755,28 +1756,24 @@ class DocumentSocket extends Socket { * @param {Object} options - Additional configuration parameter * @param {Object} options.transaction - Sequelize DB transaction options * @returns {Promise} + * @throws {Error} - If the temp file cannot be written, or if rename fails after commit */ async writeReplacementFile(document, fileData, target, expectedExtension, options) { const bytes = await this.prepareReplacementFileBytes(document, fileData, expectedExtension); const tempPath = `${target}.replace-tmp`; + fs.writeFileSync(tempPath, bytes); options.transaction.afterCommit(() => { try { - fs.writeFileSync(tempPath, bytes); fs.renameSync(tempPath, target); } catch (err) { this.logger.error( - `Error writing replacement file for document #${document.id}: ${err.message}` + `Error promoting replacement file for document #${document.id}: ${err.message}` + ); + throw new Error( + `Annotations were updated but the file could not be replaced for document #${document.id}. ` + + `The new file is at ${tempPath}; rename it to ${target} manually. ${err.message}` ); - try { - if (fs.existsSync(tempPath)) { - fs.unlinkSync(tempPath); - } - } catch (cleanupErr) { - this.logger.error( - `Error cleaning up temp replacement file: ${cleanupErr.message}` - ); - } } }); } diff --git a/docs/source/for_developers/backend/socket.rst b/docs/source/for_developers/backend/socket.rst index 36d72826c..fa0952469 100644 --- a/docs/source/for_developers/backend/socket.rst +++ b/docs/source/for_developers/backend/socket.rst @@ -460,8 +460,9 @@ without creating a new document row. - Only PDF and ZIP documents are supported; the upload extension must match the document type - For PDFs: soft-deletes existing CARE annotations and comments on that document, and best-effort strips embedded PDF annotations via PDFRPC (falls back to the raw upload if strip fails) -- Destructive disk write runs in ``afterCommit`` (temp file then rename) so a transaction rollback - cannot leave the live hash path replaced while annotation deletes are undone +- Writes ``files/{hash}.{ext}.replace-tmp`` before commit (failure rolls back DB changes); renames + onto the live hash path in ``afterCommit``. If rename fails after commit, the socket returns an + error so the admin is notified and the temp file is left for manual recovery. .. _document-create-example: