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) }, + {} + ); + }, +}; diff --git a/backend/webserver/sockets/document.js b/backend/webserver/sockets/document.js index cfa4538f1..2206fd663 100644 --- a/backend/webserver/sockets/document.js +++ b/backend/webserver/sockets/document.js @@ -1613,6 +1613,171 @@ 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, options); + + 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, + {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, + {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}); + } + } + + /** + * 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; + } + } + + /** + * 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 + * @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} + * @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.renameSync(tempPath, target); + } catch (err) { + this.logger.error( + `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}` + ); + } + }); + } + /** * Subscribe the client's socket to a document-specific communication channel. * @@ -1675,6 +1840,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); diff --git a/docs/source/for_developers/backend/socket.rst b/docs/source/for_developers/backend/socket.rst index 629a05d12..fa0952469 100644 --- a/docs/source/for_developers/backend/socket.rst +++ b/docs/source/for_developers/backend/socket.rst @@ -445,6 +445,25 @@ 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) +- 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: 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 ----------------------- 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..5afc7fecf --- /dev/null +++ b/frontend/src/components/dashboard/admin_tools/ReplaceDocumentFileModal.vue @@ -0,0 +1,339 @@ + + + + +