From e5d9d1f828f1f95537fe630cb28dfb071756a39b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A9lissa=20Loew?= Date: Mon, 22 Jun 2026 19:18:59 +0200 Subject: [PATCH 1/8] feat: export with document support, consent and user selection --- Makefile | 7 - backend/db/MetaModel.js | 21 +- ...basic-configuration-expose_assessment.json | 68 +-- ...onfiguration-expose_assessment_german.json | 1 - ...1-basic-configuration-expose_feedback.json | 54 +- ...-configuration-expose_feedback_german.json | 1 - .../20260316122741-create-assignment.js | 122 ---- ...16124704-extend-submission-assignmentId.js | 21 - ...316130035-extend-nav_element-assignment.js | 50 -- ...325144936-extend-assignment-view_rights.js | 71 --- ...42000-extend-document-study_usage_count.js | 16 - ...000-extend-assignment-admin-view-rights.js | 87 --- ...2500-extend-submission-name-description.js | 23 - ...30000-transform-submissions-nav-default.js | 43 -- ...etting-email_template_submission_upload.js | 38 -- ...email_template_type_7_submission_upload.js | 70 --- .../20260418000000-create-assignment_share.js | 77 --- ...60425001238-create-assignment-nav_group.js | 95 --- ...000000-add-guest-submissions-view-right.js | 51 -- ...ting-displayName-email_templates-type_7.js | 35 -- backend/db/models/assignment.js | 171 ------ backend/db/models/assignment_share.js | 56 -- backend/db/models/document.js | 33 - backend/db/models/study.js | 7 - backend/db/models/study_step.js | 44 +- backend/db/models/submission.js | 24 +- backend/db/models/template.js | 14 +- backend/db/models/user_role.js | 1 - backend/db/plugins.js | 1 - backend/package-lock.json | 335 +++++++++- backend/utils/emailHelper.js | 6 - backend/utils/templateResolver.js | 18 +- backend/webserver/Socket.js | 106 ++-- backend/webserver/routes/export.js | 500 ++++++++------- backend/webserver/sockets/app.js | 8 +- backend/webserver/sockets/document.js | 391 +----------- backend/webserver/sockets/submission.js | 32 +- backend/webserver/sockets/template.js | 26 +- .../for_developers/before_you_start.rst | 19 +- files/email-fallbacks/submissionUpload.txt | 13 - .../submissionUploadConfirmation.txt | 13 - frontend/package-lock.json | 14 + frontend/src/auth/SetupWizard.vue | 2 +- frontend/src/basic/dashboard/card/Card.vue | 12 +- frontend/src/basic/editor/Modal.vue | 6 +- frontend/src/basic/form/Slider.vue | 37 +- .../src/components/dashboard/Assignments.vue | 485 --------------- frontend/src/components/dashboard/Study.vue | 8 - .../src/components/dashboard/Submissions.vue | 571 ++++++++++++------ .../src/components/dashboard/Templates.vue | 5 +- frontend/src/components/dashboard/Users.vue | 22 +- .../dashboard/assignments/AssignmentModal.vue | 277 --------- .../AssignmentSubmissionsModal.vue | 197 ------ .../AssignmentSubmissionsTable.vue | 352 ----------- .../assignments/AssignmentUploadModal.vue | 333 ---------- .../dashboard/projects/ExportModal.vue | 159 +++-- .../projects/export/StepConfirmDownload.vue | 23 +- .../projects/export/StepOptionsDocuments.vue | 109 ++++ ...Options.vue => StepOptionsSubmissions.vue} | 32 +- .../projects/export/StepSelectStudents.vue | 147 ----- .../projects/export/StepSelectUsers.vue | 169 ++++++ .../dashboard/settings/SettingItem.vue | 5 +- .../dashboard/settings/SettingsSection.vue | 10 +- .../dashboard/submission/ImportModal.vue | 123 ++-- .../dashboard/submission/UploadModal.vue | 42 +- .../templates/PublicTemplatesModal.vue | 1 - .../dashboard/templates/PublishModal.vue | 2 +- .../dashboard/users/RoleManagementModal.vue | 138 ----- frontend/src/components/editor/Editor.vue | 4 +- .../editor/sidebar/TemplateConfigurator.vue | 11 +- frontend/src/router.js | 6 +- .../modules/editor-delta-conversion/index.js | 24 +- .../editor-delta-conversion/package-lock.json | 14 +- .../editor-delta-conversion/package.json | 3 +- 74 files changed, 1657 insertions(+), 4455 deletions(-) delete mode 100644 backend/db/migrations/20260316122741-create-assignment.js delete mode 100644 backend/db/migrations/20260316124704-extend-submission-assignmentId.js delete mode 100644 backend/db/migrations/20260316130035-extend-nav_element-assignment.js delete mode 100644 backend/db/migrations/20260325144936-extend-assignment-view_rights.js delete mode 100644 backend/db/migrations/20260329142000-extend-document-study_usage_count.js delete mode 100644 backend/db/migrations/20260330103000-extend-assignment-admin-view-rights.js delete mode 100644 backend/db/migrations/20260330112500-extend-submission-name-description.js delete mode 100644 backend/db/migrations/20260331130000-transform-submissions-nav-default.js delete mode 100644 backend/db/migrations/20260406152554-basic-setting-email_template_submission_upload.js delete mode 100644 backend/db/migrations/20260406152625-basic-placeholder-email_template_type_7_submission_upload.js delete mode 100644 backend/db/migrations/20260418000000-create-assignment_share.js delete mode 100644 backend/db/migrations/20260425001238-create-assignment-nav_group.js delete mode 100644 backend/db/migrations/20260511000000-add-guest-submissions-view-right.js delete mode 100644 backend/db/migrations/20260530135251-extend-setting-displayName-email_templates-type_7.js delete mode 100644 backend/db/models/assignment.js delete mode 100644 backend/db/models/assignment_share.js delete mode 100644 files/email-fallbacks/submissionUpload.txt delete mode 100644 files/email-fallbacks/submissionUploadConfirmation.txt delete mode 100644 frontend/src/components/dashboard/Assignments.vue delete mode 100644 frontend/src/components/dashboard/assignments/AssignmentModal.vue delete mode 100644 frontend/src/components/dashboard/assignments/AssignmentSubmissionsModal.vue delete mode 100644 frontend/src/components/dashboard/assignments/AssignmentSubmissionsTable.vue delete mode 100644 frontend/src/components/dashboard/assignments/AssignmentUploadModal.vue create mode 100644 frontend/src/components/dashboard/projects/export/StepOptionsDocuments.vue rename frontend/src/components/dashboard/projects/export/{StepOptions.vue => StepOptionsSubmissions.vue} (66%) delete mode 100644 frontend/src/components/dashboard/projects/export/StepSelectStudents.vue create mode 100644 frontend/src/components/dashboard/projects/export/StepSelectUsers.vue delete mode 100644 frontend/src/components/dashboard/users/RoleManagementModal.vue diff --git a/Makefile b/Makefile index 3eb860d5d..f8c059462 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,6 @@ help: @echo "make doc Build the documentation" @echo "make dev-build Build the frontend (make dev-build-frontend) and run the backend in development mode" @echo "make dev-backend Run backend in development mode" - @echo "make dev-backend-wizard Run backend in development mode with setup wizard enabled" @echo "make dev-backend-watch Run backend in development mode with nodemon (auto-restart)" @echo "make dev-frontend Run frontend in development mode" @echo "make dev-build-frontend Build frontend in development mode" @@ -99,16 +98,10 @@ dev-build: backend/node_modules/.uptodate build-frontend cd backend && npm run start .PHONY: dev-backend -dev-backend: DEV_SKIP_WIZARD=true dev-backend: backend/node_modules/.uptodate - cd backend && npm run start - -.PHONY: dev-backend-wizard -dev-backend-wizard: backend/node_modules/.uptodate cd backend && npm run start .PHONY: dev-backend-watch -dev-backend-watch: DEV_SKIP_WIZARD=true dev-backend-watch: backend/node_modules/.uptodate cd backend && npm run start:watch diff --git a/backend/db/MetaModel.js b/backend/db/MetaModel.js index 070fff4c6..d54998081 100644 --- a/backend/db/MetaModel.js +++ b/backend/db/MetaModel.js @@ -245,29 +245,10 @@ module.exports = class MetaModel extends Model { * Delete db entry by id * @param {number} id * @param {Object} [options={}] - Optional Sequelize query options - * @param {boolean} [options.force=false] - If true, permanently destroys the record instead of soft-deleting. * @return {Promise} */ static async deleteById(id, options = {}) { - const { force = false, ...restOptions } = options; - if (force) { - return await this.destroyById(id, restOptions); - } - return await this.updateById(id, {deleted: true}, restOptions); - } - - /** - * Physically destroy a db entry by id, firing all instance-level hooks - * (beforeDestroy, afterDestroy) so that plugins like GlobalChangeTrackingPlugin - * can track the change automatically. - * @param {number} id - * @param {Object} [options={}] - Optional Sequelize query options (e.g. transaction) - * @return {Promise} - */ - static async destroyById(id, options = {}) { - const instance = await this.findOne({ where: { id }, transaction: options.transaction }); - if (!instance) return; - return await instance.destroy(options); + return await this.updateById(id, {deleted: true}, options); } /** diff --git a/backend/db/migrations/20250919125851-basic-configuration-expose_assessment.json b/backend/db/migrations/20250919125851-basic-configuration-expose_assessment.json index 6629c183e..41780f631 100644 --- a/backend/db/migrations/20250919125851-basic-configuration-expose_assessment.json +++ b/backend/db/migrations/20250919125851-basic-configuration-expose_assessment.json @@ -6,7 +6,7 @@ "rubrics": [ { "name": "Language/Quality", - "code": "LA", + "code": "language", "description": "Assessment of language quality and structural coherence of the text", "calculation": "sum", "maxPoints": 4, @@ -14,7 +14,6 @@ "criteria": [ { "name": "Language quality", - "code": "LA1", "description": "Evaluation of linguistic accuracy and sentence clarity", "minPoints": 0, "maxPoints": 2, @@ -38,7 +37,6 @@ }, { "name": "Common Thread", - "code": "LA2", "description": "Assessment of structural continuity and coherence throughout the text", "maxPoints": 2, "minPoints": 0, @@ -64,7 +62,7 @@ }, { "name": "Metadata", - "code": "ME", + "code": "meta", "maxPoints": 3, "minPoints": 0, "description": "Evaluation of title appropriateness and creativity", @@ -72,7 +70,6 @@ "criteria": [ { "name": "Preliminary title", - "code": "ME1", "description": "Assessment of how well the title matches and represents the topic", "maxPoints": 2, "minPoints": 0, @@ -90,13 +87,12 @@ }, { "points": 2, - "description": "Title fits the topic; creative title changed in a novel way and/or fits into the story of the exposé" + "description": "Title fits the topic; creative title changed in a novel way and/or fits into the story of the synopsis" } ] }, { "name": "Metadata available", - "code": "ME2", "description": "Assessment of proper meta information", "maxPoints": 1, "minPoints": 0, @@ -118,7 +114,7 @@ }, { "name": "Form/Structure", - "code": "ST", + "code": "structure", "calculation": "sum", "maxPoints": 2, "minPoints": 0, @@ -126,7 +122,6 @@ "criteria": [ { "name": "Template used", - "code": "ST1", "description": "LaTeX template used", "maxPoints": 1, "minPoints": 0, @@ -146,7 +141,6 @@ }, { "name": "Number of pages", - "code": "ST2", "description": "Page limitation for more focused writing", "maxPoints": 1, "minPoints": 0, @@ -168,7 +162,7 @@ }, { "name": "Motivation", - "code": "MO", + "code": "motivation", "description": "Assessment of problem identification, context establishment, and research question formulation", "calculation": "sum", "maxPoints": 9, @@ -176,7 +170,6 @@ "criteria": [ { "name": "Hook existing", - "code": "MO1", "description": "Evaluation of the presence and suitability of an engaging opening", "maxPoints": 1, "minPoints": 0, @@ -196,7 +189,6 @@ }, { "name": "Anker", - "code": "MO2", "description": "Assessment of problem statement detail and comprehensiveness", "maxPoints": 2, "minPoints": 0, @@ -220,7 +212,6 @@ }, { "name": "Domain", - "code": "MO3", "description": "Evaluation of context and domain specification for the problem", "maxPoints": 1, "minPoints": 0, @@ -240,7 +231,6 @@ }, { "name": "Problem relevance", - "code": "MO4", "description": "Assessment of how well the importance and impact of the problem is communicated", "maxPoints": 1, "minPoints": 0, @@ -260,7 +250,6 @@ }, { "name": "Problem handling", - "code": "MO5", "description": "Evaluation of current approaches or criticisms regarding the problem", "maxPoints": 1, "minPoints": 0, @@ -280,7 +269,6 @@ }, { "name": "Teaser (RQ)", - "code": "MO6", "description": "Assessment of research question clarity and alignment with problem description", "function": "auto_grading", "maxPoints": 2, @@ -304,7 +292,6 @@ }, { "name": "RQ Limitation", - "code": "MO7", "description": "Evaluation of research question scope and feasibility for Bachelor's thesis context", "maxPoints": 1, "minPoints": 0, @@ -326,7 +313,7 @@ }, { "name": "Approach", - "code": "AP", + "code": "approach", "maxPoints": 11, "minPoints": 0, "calculation": "sum", @@ -334,7 +321,6 @@ "criteria": [ { "name": "SOTA", - "code": "AP1", "description": "Evaluation of state of the art presence and proper citation", "maxPoints": 1, "minPoints": 0, @@ -354,7 +340,6 @@ }, { "name": "SOTA Relevance", - "code": "AP2", "description": "Assessment of how well the relevance of state of the art is demonstrated", "maxPoints": 2, "minPoints": 0, @@ -378,7 +363,6 @@ }, { "name": "SOTA Weaknesses", - "code": "AP3", "description": "Evaluation of identification and explanation of state of the art limitations", "maxPoints": 2, "minPoints": 0, @@ -402,7 +386,6 @@ }, { "name": "SOTA Delimitation", - "code": "AP4", "description": "Assessment of differentiation between own work and existing research", "function": "auto_grading", "maxPoints": 1, @@ -422,7 +405,6 @@ }, { "name": "SOTA Combination", - "code": "AP5", "description": "Evaluation of meaningful combination and consolidation of existing material", "function": "auto_grading", "maxPoints": 1, @@ -442,7 +424,6 @@ }, { "name": "Theoretical Framework", - "code": "AP6", "description": "Assessment of theoretical framework structure and clarity", "function": "auto_grading", "maxPoints": 2, @@ -466,7 +447,6 @@ }, { "name": "Relevance of theoretical framework", - "code": "AP7", "description": "Evaluation of theoretical framework connection to research topic", "function": "auto_grading", "maxPoints": 1, @@ -486,7 +466,6 @@ }, { "name": "Methodology Availability", - "code": "AP8", "description": "Assessment of methodology presence and suitability for research questions", "function": "auto_grading", "maxPoints": 1, @@ -508,7 +487,7 @@ }, { "name": "Methodology", - "code": "MD", + "code": "methodology", "maxPoints": 5, "minPoints": 0, "calculation": "min", @@ -516,7 +495,6 @@ "criteria": [ { "name": "Methodology Completeness", - "code": "MD1", "description": "Evaluation of methodology coverage of all research questions", "function": "auto_grading", "maxPoints": 1, @@ -536,7 +514,6 @@ }, { "name": "Methodology Relevance", - "code": "MD2", "description": "Assessment of methodology relevance and contribution to research project", "function": "auto_grading", "maxPoints": 1, @@ -556,7 +533,6 @@ }, { "name": "Methodology Target group", - "code": "MD3", "description": "Evaluation of target group specification and relevance", "function": "auto_grading", "maxPoints": 1, @@ -576,7 +552,6 @@ }, { "name": "Methodology Existing Material", - "code": "MD4", "description": "Assessment of appropriate reference to existing material in methodology", "function": "auto_grading", "maxPoints": 1, @@ -596,7 +571,6 @@ }, { "name": "Methodology Difficulties", - "code": "MD5", "description": "Evaluation of identification and solutions for potential implementation challenges", "function": "auto_grading", "maxPoints": 1, @@ -616,7 +590,6 @@ }, { "name": "Methodology Possibilities/restrictions", - "code": "MD6", "description": "Assessment of methodology strengths, limitations, and critical reflection", "function": "auto_grading", "maxPoints": 1, @@ -636,7 +609,6 @@ }, { "name": "Methodology Details", - "code": "MD7", "description": "Evaluation of comprehensive methodology explanation and implementation details", "function": "auto_grading", "maxPoints": 1, @@ -658,7 +630,7 @@ }, { "name": "Schedule", - "code": "SC", + "code": "schedule", "maxPoints": 5, "minPoints": 0, "calculation": "sum", @@ -666,7 +638,6 @@ "criteria": [ { "name": "Schedule Availability", - "code": "SC1", "description": "Evaluation of tabular schedule presence and chronological organization", "function": "auto_grading", "maxPoints": 1, @@ -686,7 +657,6 @@ }, { "name": "Schedule Completeness", - "code": "SC2", "description": "Assessment of schedule coverage and logical division into work blocks", "function": "auto_grading", "maxPoints": 1, @@ -706,7 +676,6 @@ }, { "name": "Schedule Block Description", - "code": "SC3", "description": "Evaluation of detailed descriptions for individual schedule blocks", "function": "auto_grading", "maxPoints": 1, @@ -726,7 +695,6 @@ }, { "name": "Schedule Realistic Relevance", - "code": "SC4", "description": "Assessment of timeline realism and alignment with Bachelor's thesis scope", "function": "auto_grading", "maxPoints": 2, @@ -752,14 +720,13 @@ }, { "name": "Bibliography", - "code": "BI", + "code": "bibliography", "maxPoints": 3, "minPoints": 0, "description": "Assessment of bibliography consistency and literature relevance", "criteria": [ { "name": "Bibliography Consistency", - "code": "BI1", "description": "Evaluation of citation style consistency and completeness of bibliographic information", "function": "auto_grading", "maxPoints": 1, @@ -779,7 +746,6 @@ }, { "name": "Key literature", - "code": "BI2", "description": "Assessment of literature relevance and quality for the research topic", "function": "auto_grading", "maxPoints": 2, @@ -805,7 +771,7 @@ }, { "name": "Additional points", - "code": "AD", + "code": "additional", "maxPoints": 2, "minPoints": -2, "calculation": "sum", @@ -814,7 +780,6 @@ "criteria": [ { "name": "Additional points", - "code": "AD1", "description": "Additional points for very good submissions not mentioned in other criteria", "function": "manual_grading", "maxPoints": 2, @@ -824,21 +789,20 @@ "scoring": [ { "points": 0, - "description": "No additional strengths beyond existing criteria." + "description": "" }, { "points": 1, - "description": "Some additional strengths beyond existing criteria." + "description": "" }, { "points": 2, - "description": "Clear exceptional strengths beyond existing criteria." + "description": "" } ] }, { "name": "Negative points", - "code": "AD2", "description": "Major mistakes in the submissions", "function": "manual_grading", "maxPoints": 0, @@ -848,15 +812,15 @@ "scoring": [ { "points": 0, - "description": "No major issues beyond existing criteria." + "description": "" }, { "points": -1, - "description": "Notable issues beyond existing criteria." + "description": "" }, { "points": -2, - "description": "Serious issues that strongly reduce overall quality." + "description": "" } ] } diff --git a/backend/db/migrations/20250919125851-basic-configuration-expose_assessment_german.json b/backend/db/migrations/20250919125851-basic-configuration-expose_assessment_german.json index 2e7a97be7..82fb1c911 100644 --- a/backend/db/migrations/20250919125851-basic-configuration-expose_assessment_german.json +++ b/backend/db/migrations/20250919125851-basic-configuration-expose_assessment_german.json @@ -775,7 +775,6 @@ "maxPoints": 2, "minPoints": -2, "calculation": "sum", - "isBonus": true, "description": "Zusätzliche Punkte für sehr gute Arbeiten oder Abzüge bei groben Fehlern", "criteria": [ { diff --git a/backend/db/migrations/20250919125851-basic-configuration-expose_feedback.json b/backend/db/migrations/20250919125851-basic-configuration-expose_feedback.json index 8fe5b70cf..bb2435582 100644 --- a/backend/db/migrations/20250919125851-basic-configuration-expose_feedback.json +++ b/backend/db/migrations/20250919125851-basic-configuration-expose_feedback.json @@ -1,12 +1,12 @@ { - "name": "Review feedback configuration", + "name": "Exposé feedback configuration", "description": "Review Criteria by rubrics including calculation", "version": "1.0.0", "type": "assessment", "rubrics": [ { "name": "Structure and Clarity", - "code": "SC", + "code": "structure", "description": "Assessment of overall structure and clarity", "calculation": "sum", "minPoints": 0, @@ -14,7 +14,6 @@ "criteria": [ { "name": "Summary available", - "code": "SC1", "description": "Concise summary that reflects understanding of the performance", "function": "auto_grading", "minPoints": 0, @@ -38,7 +37,6 @@ }, { "name": "Clarity", - "code": "SC2", "description": "Clarity with regard to the overall structure", "function": "auto_grading", "maxPoints": 2, @@ -64,7 +62,7 @@ }, { "name": "Language", - "code": "LG", + "code": "language", "description": "Assessment of language quality and tone", "calculation": "sum", "minPoints": 0, @@ -72,7 +70,6 @@ "criteria": [ { "name": "Language", - "code": "LG1", "description": "Accuracy and correctness of language used", "function": "auto_grading", "minPoints": 0, @@ -92,7 +89,6 @@ }, { "name": "Tone", - "code": "LG2", "description": "Evaluates the respectfulness, clarity, and constructiveness of feedback", "function": "auto_grading", "minPoints": 0, @@ -118,7 +114,7 @@ }, { "name": "Feed Up", - "code": "FU", + "code": "feed_up", "description": "Assessment of learning goals and success criteria", "calculation": "sum", "minPoints": 0, @@ -126,7 +122,6 @@ "criteria": [ { "name": "Learning Goals", - "code": "FU1", "description": "Measures the clarity, specificity, and guidance of learning objectives.", "function": "auto_grading", "minPoints": 0, @@ -150,7 +145,6 @@ }, { "name": "Success Criteria", - "code": "FU2", "description": "Measures the clarity and specificity of success criteria for achieving learning goals.", "function": "auto_grading", "minPoints": 0, @@ -176,7 +170,7 @@ }, { "name": "Feed Back", - "code": "FB", + "code": "feed_back", "description": "Assessment of feedback quality and knowledge transfer", "calculation": "min", "minPoints": 0, @@ -184,7 +178,6 @@ "criteria": [ { "name": "Knowledge of result", - "code": "FB1", "description": "Information on whether the task has been solved or not", "function": "auto_grading", "minPoints": 0, @@ -204,7 +197,6 @@ }, { "name": "Knowledge of correct response", - "code": "FB2", "description": "Information on the correct answer or solution", "function": "auto_grading", "minPoints": 0, @@ -224,7 +216,6 @@ }, { "name": "Knowledge about task constraints", - "code": "FB3", "description": "Information on rules, requirements or restrictions of the task", "function": "auto_grading", "minPoints": 0, @@ -244,7 +235,6 @@ }, { "name": "Knowledge about concepts", - "code": "FB4", "description": "Information on relevant concepts, principles or relationships", "function": "auto_grading", "minPoints": 0, @@ -264,7 +254,6 @@ }, { "name": "Knowledge about mistakes", - "code": "FB5", "description": "Information on the number, location, source or type of errors", "function": "auto_grading", "minPoints": 0, @@ -284,7 +273,6 @@ }, { "name": "Self-feedback", - "code": "FB6", "description": "Self Feedback (e.g. praise or recognition) not available or only in combination with learning strategies or self-regulation", "function": "auto_grading", "minPoints": 0, @@ -306,7 +294,7 @@ }, { "name": "Feed Forward", - "code": "FF", + "code": "feed_forward", "description": "Assessment of future learning guidance", "calculation": "sum", "minPoints": 0, @@ -314,7 +302,6 @@ "criteria": [ { "name": "Self-regulation", - "code": "FF1", "description": "Knowledge about how to process the task or about self regulation", "function": "auto_grading", "minPoints": 0, @@ -334,7 +321,6 @@ }, { "name": "Learning Skills", - "code": "FF2", "description": "Information on how to develop or improve learning skills", "function": "auto_grading", "minPoints": 0, @@ -356,7 +342,7 @@ }, { "name": "Content Quality", - "code": "CQ", + "code": "content_quality", "description": "Assessment of content accuracy and usefulness", "calculation": "sum", "minPoints": 0, @@ -364,7 +350,6 @@ "criteria": [ { "name": "Correctness", - "code": "CQ1", "description": "Evaluates the accuracy and correctness of the feedback content", "function": "auto_grading", "minPoints": 0, @@ -388,7 +373,6 @@ }, { "name": "Actionability", - "code": "CQ2", "description": "Assesses whether the feedback provides clear and specific instructions for improvement", "function": "auto_grading", "minPoints": 0, @@ -412,7 +396,6 @@ }, { "name": "Argumentation", - "code": "CQ3", "description": "Evaluates the quality of argumentation and evidence provided in the feedback", "function": "auto_grading", "minPoints": 0, @@ -434,7 +417,7 @@ }, { "name": "Errors", - "code": "ER", + "code": "errors", "description": "Assessment of potential errors and issues in feedback", "calculation": "max", "defaultPoints": 4, @@ -443,7 +426,6 @@ "criteria": [ { "name": "Neglect", - "code": "ER1", "description": "Assesses whether important details have been overlooked", "function": "auto_grading", "minPoints": -1, @@ -463,7 +445,6 @@ }, { "name": "Vague Critic", - "code": "ER2", "description": "Assesses the specificity and clarity of criticism provided", "function": "auto_grading", "minPoints": -1, @@ -483,7 +464,6 @@ }, { "name": "Out-of-Scope", - "code": "ER3", "description": "Assesses whether suggestions remain within the intended scope", "function": "auto_grading", "minPoints": -1, @@ -503,7 +483,6 @@ }, { "name": "Missing Reference", - "code": "ER4", "description": "Assesses whether suggestions are supported by justification or references", "function": "auto_grading", "minPoints": -1, @@ -523,7 +502,6 @@ }, { "name": "Contradiction", - "code": "ER5", "description": "Assesses whether the feedback is consistent and free of contradictions", "function": "auto_grading", "minPoints": -1, @@ -545,7 +523,7 @@ }, { "name": "Additional points", - "code": "AD", + "code": "additional", "maxPoints": 1, "minPoints": -1, "calculation": "sum", @@ -554,9 +532,8 @@ "criteria": [ { "name": "Additional points", - "code": "AD1", "description": "Additional points for very good reviews", - "function": "manual_grading", + "function": "auto_grading", "maxPoints": 1, "minPoints": 0, "expertLevel": 1, @@ -564,19 +541,18 @@ "scoring": [ { "points": 0, - "description": "No additional strengths identified." + "description": "" }, { "points": 1, - "description": "Notable additional strengths beyond other criteria." + "description": "" } ] }, { "name": "Negative points", - "code": "AD2", "description": "Major mistakes in the reviews", - "function": "manual_grading", + "function": "auto_grading", "maxPoints": 0, "minPoints": -1, "expertLevel": 1, @@ -584,11 +560,11 @@ "scoring": [ { "points": 0, - "description": "No additional major issues identified." + "description": "" }, { "points": -1, - "description": "Significant issues beyond other criteria." + "description": "" } ] } diff --git a/backend/db/migrations/20250919125851-basic-configuration-expose_feedback_german.json b/backend/db/migrations/20250919125851-basic-configuration-expose_feedback_german.json index 649a82dde..01edca87d 100644 --- a/backend/db/migrations/20250919125851-basic-configuration-expose_feedback_german.json +++ b/backend/db/migrations/20250919125851-basic-configuration-expose_feedback_german.json @@ -392,7 +392,6 @@ "maxPoints": 1, "minPoints": -1, "calculation": "sum", - "isBonus": true, "description": "Für besonders gute Bewertungen können zusätzliche Punkte vergeben werden, für gravierende Fehler hingegen werden Punkte abgezogen.", "criteria": [ { diff --git a/backend/db/migrations/20260316122741-create-assignment.js b/backend/db/migrations/20260316122741-create-assignment.js deleted file mode 100644 index 9bd42e547..000000000 --- a/backend/db/migrations/20260316122741-create-assignment.js +++ /dev/null @@ -1,122 +0,0 @@ -'use strict'; - -/** @type {import('sequelize-cli').Migration} */ -module.exports = { - async up (queryInterface, Sequelize) { - await queryInterface.createTable('assignment', { - id: { - allowNull: false, - autoIncrement: true, - primaryKey: true, - type: Sequelize.INTEGER, - }, - name: { - type: Sequelize.STRING, - allowNull: false, - }, - description: { - type: Sequelize.TEXT, - allowNull: true, - }, - disable: { - type: Sequelize.BOOLEAN, - allowNull: false, - defaultValue: false, - }, - projectId: { - type: Sequelize.INTEGER, - allowNull: true, - references: { - model: 'project', - key: 'id', - }, - onUpdate: 'CASCADE', - onDelete: 'CASCADE', - }, - userId: { - type: Sequelize.INTEGER, - allowNull: true, - references: { - model: 'user', - key: 'id', - }, - onUpdate: 'CASCADE', - onDelete: 'CASCADE', - }, - maxRevisions: { - type: Sequelize.INTEGER, - allowNull: false, - defaultValue: 1, - }, - start: { - type: Sequelize.DATE, - allowNull: true, - defaultValue: null, - }, - end: { - type: Sequelize.DATE, - allowNull: true, - defaultValue: null, - }, - validationConfigurationId: { - type: Sequelize.INTEGER, - allowNull: true, - defaultValue: null, - references: { - model: 'configuration', - key: 'id', - }, - onUpdate: 'CASCADE', - onDelete: 'SET NULL', - }, - parentAssignmentId: { - type: Sequelize.INTEGER, - allowNull: true, - defaultValue: null, - references: { - model: 'assignment', - key: 'id', - }, - onUpdate: 'CASCADE', - onDelete: 'SET NULL', - }, - closed: { - type: Sequelize.DATE, - allowNull: true, - defaultValue: null, - }, - allowReUpload: { - type: Sequelize.BOOLEAN, - allowNull: false, - defaultValue: false, - }, - notifyOnSubmissionUpload: { - type: Sequelize.BOOLEAN, - allowNull: false, - defaultValue: true, - }, - deleted: { - type: Sequelize.BOOLEAN, - allowNull: false, - defaultValue: false, - }, - createdAt: { - allowNull: false, - type: Sequelize.DATE, - }, - updatedAt: { - allowNull: false, - type: Sequelize.DATE, - }, - deletedAt: { - allowNull: true, - defaultValue: null, - type: Sequelize.DATE, - }, - }); - }, - - async down (queryInterface, Sequelize) { - await queryInterface.dropTable('assignment'); - } -}; diff --git a/backend/db/migrations/20260316124704-extend-submission-assignmentId.js b/backend/db/migrations/20260316124704-extend-submission-assignmentId.js deleted file mode 100644 index 21dac99f7..000000000 --- a/backend/db/migrations/20260316124704-extend-submission-assignmentId.js +++ /dev/null @@ -1,21 +0,0 @@ -'use strict'; - -/** @type {import('sequelize-cli').Migration} */ -module.exports = { - async up (queryInterface, Sequelize) { - await queryInterface.addColumn('submission', 'assignmentId', { - type: Sequelize.INTEGER, - allowNull: true, - references: { - model: 'assignment', - key: 'id', - }, - onUpdate: 'CASCADE', - onDelete: 'SET NULL', - }); - }, - - async down (queryInterface, Sequelize) { - await queryInterface.removeColumn('submission', 'assignmentId'); - } -}; diff --git a/backend/db/migrations/20260316130035-extend-nav_element-assignment.js b/backend/db/migrations/20260316130035-extend-nav_element-assignment.js deleted file mode 100644 index 34084214e..000000000 --- a/backend/db/migrations/20260316130035-extend-nav_element-assignment.js +++ /dev/null @@ -1,50 +0,0 @@ -'use strict'; - -const navElements = [ - { - name: "Assignments", - groupId: "Default", - icon: "list-check", - order: 14, - admin: false, - path: "assignments", - component: "Assignments", - }, -]; - -/** @type {import('sequelize-cli').Migration} */ -module.exports = { - async up (queryInterface, Sequelize) { - await queryInterface.bulkInsert( - "nav_element", - await Promise.all( - navElements.map(async (t) => { - const groupId = await queryInterface.rawSelect( - "nav_group", - { - where: { name: t.groupId }, - }, - ["id"] - ); - - t["createdAt"] = new Date(); - t["updatedAt"] = new Date(); - t["groupId"] = groupId; - - return t; - }) - ), - {} - ); - }, - - async down (queryInterface, Sequelize) { - await queryInterface.bulkDelete( - "nav_element", - { - name: navElements.map((t) => t.name), - }, - {} - ); - } -}; diff --git a/backend/db/migrations/20260325144936-extend-assignment-view_rights.js b/backend/db/migrations/20260325144936-extend-assignment-view_rights.js deleted file mode 100644 index f2703c11a..000000000 --- a/backend/db/migrations/20260325144936-extend-assignment-view_rights.js +++ /dev/null @@ -1,71 +0,0 @@ -'use strict'; - -const assignmentViewRights = [ - { - name: "frontend.dashboard.assignments.view", - description: "access to view assignments in the dashboard", - }, -]; - -const roleRights = [ - { role: "teacher", userRightName: "frontend.dashboard.assignments.view" }, - { role: "mentor", userRightName: "frontend.dashboard.assignments.view" }, - { role: "admin", userRightName: "frontend.dashboard.assignments.view" }, - { role: "user", userRightName: "frontend.dashboard.assignments.view" }, - { role: "guest", userRightName: "frontend.dashboard.assignments.view" }, -]; - -/** @type {import('sequelize-cli').Migration} */ -module.exports = { - async up (queryInterface, Sequelize) { - await queryInterface.bulkInsert( - "user_right", - assignmentViewRights.map((right) => ({ - ...right, - createdAt: new Date(), - updatedAt: new Date(), - })), - {} - ); - - const userRoles = await queryInterface.sequelize.query('SELECT id, name FROM "user_role"', { - type: queryInterface.sequelize.QueryTypes.SELECT, - }); - - const roleNameIdMapping = userRoles.reduce((acc, role) => { - acc[role.name] = role.id; - return acc; - }, {}); - - await queryInterface.bulkInsert( - "role_right_matching", - roleRights - .filter((right) => roleNameIdMapping[right.role]) - .map((right) => ({ - userRoleId: roleNameIdMapping[right.role], - userRightName: right.userRightName, - createdAt: new Date(), - updatedAt: new Date(), - })), - {} - ); - }, - - async down (queryInterface, Sequelize) { - await queryInterface.bulkDelete( - "role_right_matching", - { - userRightName: roleRights.map((r) => r.userRightName), - }, - {} - ); - - await queryInterface.bulkDelete( - "user_right", - { - name: assignmentViewRights.map((r) => r.name), - }, - {} - ); - } -}; diff --git a/backend/db/migrations/20260329142000-extend-document-study_usage_count.js b/backend/db/migrations/20260329142000-extend-document-study_usage_count.js deleted file mode 100644 index 121338c37..000000000 --- a/backend/db/migrations/20260329142000-extend-document-study_usage_count.js +++ /dev/null @@ -1,16 +0,0 @@ -'use strict'; - -/** @type {import('sequelize-cli').Migration} */ -module.exports = { - async up(queryInterface, Sequelize) { - await queryInterface.addColumn('document', 'studyUsageCount', { - type: Sequelize.INTEGER, - allowNull: false, - defaultValue: 0, - }); - }, - - async down(queryInterface, Sequelize) { - await queryInterface.removeColumn('document', 'studyUsageCount'); - }, -}; diff --git a/backend/db/migrations/20260330103000-extend-assignment-admin-view-rights.js b/backend/db/migrations/20260330103000-extend-assignment-admin-view-rights.js deleted file mode 100644 index 1e62a0d14..000000000 --- a/backend/db/migrations/20260330103000-extend-assignment-admin-view-rights.js +++ /dev/null @@ -1,87 +0,0 @@ -'use strict'; - -const assignmentAdminRights = [ - { - name: "frontend.dashboard.assignments.viewAll", - description: "access to view all assignments and submissions in the Assignments dashboard view", - }, - { - name: "frontend.dashboard.assignments.uploadForOthers", - description: "access to upload submissions for other users", - }, - { - name: "frontend.dashboard.assignments.edit", - description: "access to edit assignments", - }, - { - name: "frontend.dashboard.assignments.replaceDeleteSubmissions", - description: "access to replace or delete submissions", - }, - { - name: "frontend.dashboard.submissions.view", - description: "access to view submissions dashboard", - }, -]; - -const roleRights = [ - { role: "admin", userRightName: "frontend.dashboard.assignments.viewAll" }, - { role: "admin", userRightName: "frontend.dashboard.assignments.uploadForOthers" }, - { role: "admin", userRightName: "frontend.dashboard.assignments.edit" }, - { role: "admin", userRightName: "frontend.dashboard.assignments.replaceDeleteSubmissions" }, - { role: "user", userRightName: "frontend.dashboard.submissions.view" }, -]; - -/** @type {import('sequelize-cli').Migration} */ -module.exports = { - async up(queryInterface, Sequelize) { - await queryInterface.bulkInsert( - "user_right", - assignmentAdminRights.map((right) => ({ - ...right, - createdAt: new Date(), - updatedAt: new Date(), - })), - {} - ); - - const userRoles = await queryInterface.sequelize.query('SELECT id, name FROM "user_role"', { - type: queryInterface.sequelize.QueryTypes.SELECT, - }); - - const roleNameIdMapping = userRoles.reduce((acc, role) => { - acc[role.name] = role.id; - return acc; - }, {}); - - await queryInterface.bulkInsert( - "role_right_matching", - roleRights - .filter((right) => roleNameIdMapping[right.role]) - .map((right) => ({ - userRoleId: roleNameIdMapping[right.role], - userRightName: right.userRightName, - createdAt: new Date(), - updatedAt: new Date(), - })), - {} - ); - }, - - async down(queryInterface, Sequelize) { - await queryInterface.bulkDelete( - "role_right_matching", - { - userRightName: roleRights.map((r) => r.userRightName), - }, - {} - ); - - await queryInterface.bulkDelete( - "user_right", - { - name: assignmentAdminRights.map((r) => r.name), - }, - {} - ); - }, -}; diff --git a/backend/db/migrations/20260330112500-extend-submission-name-description.js b/backend/db/migrations/20260330112500-extend-submission-name-description.js deleted file mode 100644 index f02e57dfc..000000000 --- a/backend/db/migrations/20260330112500-extend-submission-name-description.js +++ /dev/null @@ -1,23 +0,0 @@ -'use strict'; - -/** @type {import('sequelize-cli').Migration} */ -module.exports = { - async up(queryInterface, Sequelize) { - await queryInterface.addColumn('submission', 'name', { - type: Sequelize.STRING, - allowNull: true, - defaultValue: null, - }); - - await queryInterface.addColumn('submission', 'description', { - type: Sequelize.TEXT, - allowNull: true, - defaultValue: null, - }); - }, - - async down(queryInterface, Sequelize) { - await queryInterface.removeColumn('submission', 'description'); - await queryInterface.removeColumn('submission', 'name'); - }, -}; diff --git a/backend/db/migrations/20260331130000-transform-submissions-nav-default.js b/backend/db/migrations/20260331130000-transform-submissions-nav-default.js deleted file mode 100644 index 938ac378e..000000000 --- a/backend/db/migrations/20260331130000-transform-submissions-nav-default.js +++ /dev/null @@ -1,43 +0,0 @@ -"use strict"; - -module.exports = { - async up(queryInterface, Sequelize) { - const defaultGroupId = await queryInterface.rawSelect( - "nav_group", - { where: { name: "Default" } }, - ["id"] - ); - - await queryInterface.bulkUpdate( - "nav_element", - { - groupId: defaultGroupId, - admin: false, - updatedAt: new Date(), - }, - { - path: "submissions", - } - ); - }, - - async down(queryInterface, Sequelize) { - const adminGroupId = await queryInterface.rawSelect( - "nav_group", - { where: { name: "Admin" } }, - ["id"] - ); - - await queryInterface.bulkUpdate( - "nav_element", - { - groupId: adminGroupId, - admin: true, - updatedAt: new Date(), - }, - { - path: "submissions", - } - ); - }, -}; diff --git a/backend/db/migrations/20260406152554-basic-setting-email_template_submission_upload.js b/backend/db/migrations/20260406152554-basic-setting-email_template_submission_upload.js deleted file mode 100644 index e9938d543..000000000 --- a/backend/db/migrations/20260406152554-basic-setting-email_template_submission_upload.js +++ /dev/null @@ -1,38 +0,0 @@ -'use strict'; - -/** @type {import('sequelize-cli').Migration} */ - -const settings = [ - { - key: 'email.template.submissionUpload', - value: '', - type: 'number', - description: - 'Template type for assignment submission upload/reupload emails to the assignment owner (Email - Submission upload). Leave empty to use default email.', - }, - { - key: 'email.template.submissionUploadConfirmation', - value: '', - type: 'number', - description: - 'Template for submission upload confirmation to the submitter (Email - Submission upload). Leave empty to use default email.', - }, -]; - -module.exports = { - async up(queryInterface, Sequelize) { - await queryInterface.bulkInsert( - 'setting', - settings.map((t) => ({ - ...t, - createdAt: new Date(), - updatedAt: new Date(), - })), - {} - ); - }, - - async down(queryInterface, Sequelize) { - await queryInterface.bulkDelete('setting', { key: settings.map((t) => t.key) }, {}); - }, -}; diff --git a/backend/db/migrations/20260406152625-basic-placeholder-email_template_type_7_submission_upload.js b/backend/db/migrations/20260406152625-basic-placeholder-email_template_type_7_submission_upload.js deleted file mode 100644 index 7cd22109f..000000000 --- a/backend/db/migrations/20260406152625-basic-placeholder-email_template_type_7_submission_upload.js +++ /dev/null @@ -1,70 +0,0 @@ -'use strict'; - -/** @type {import('sequelize-cli').Migration} */ - -const placeholders = [ - { - type: 7, - placeholderKey: 'username', - placeholderLabel: 'Recipient username', - placeholderType: 'text', - placeholderDescription: 'Recipient username (assignment owner or submitter).', - }, - { - type: 7, - placeholderKey: 'assignmentName', - placeholderLabel: 'Assignment name', - placeholderType: 'text', - placeholderDescription: 'Name of the assignment.', - required: true, - }, - { - type: 7, - placeholderKey: 'eventType', - placeholderLabel: 'Upload event', - placeholderType: 'text', - placeholderDescription: 'Lowercase: "uploaded" or "reuploaded".', - }, - { - type: 7, - placeholderKey: 'assignmentId', - placeholderLabel: 'Assignment ID', - placeholderType: 'text', - placeholderDescription: 'Internal assignment identifier.', - }, - { - type: 7, - placeholderKey: 'submissionId', - placeholderLabel: 'Submission ID', - placeholderType: 'text', - placeholderDescription: 'Internal submission identifier.', - }, - { - type: 7, - placeholderKey: 'timestamp', - placeholderLabel: 'Upload timestamp', - placeholderType: 'text', - placeholderDescription: 'When the submission was uploaded.', - }, -]; - -module.exports = { - async up(queryInterface, Sequelize) { - await queryInterface.bulkInsert( - 'placeholder', - placeholders.map((p) => ({ - ...p, - required: p.required === true, - deleted: false, - deletedAt: null, - createdAt: new Date(), - updatedAt: new Date(), - })), - {} - ); - }, - - async down(queryInterface, Sequelize) { - await queryInterface.bulkDelete('placeholder', { type: 7 }, {}); - }, -}; diff --git a/backend/db/migrations/20260418000000-create-assignment_share.js b/backend/db/migrations/20260418000000-create-assignment_share.js deleted file mode 100644 index e92fb85d0..000000000 --- a/backend/db/migrations/20260418000000-create-assignment_share.js +++ /dev/null @@ -1,77 +0,0 @@ -'use strict'; - -/** @type {import('sequelize-cli').Migration} */ -module.exports = { - async up(queryInterface, Sequelize) { - await queryInterface.createTable('assignment_share', { - id: { - allowNull: false, - autoIncrement: true, - primaryKey: true, - type: Sequelize.INTEGER, - }, - assignmentId: { - type: Sequelize.INTEGER, - allowNull: false, - references: { - model: 'assignment', - key: 'id', - }, - onUpdate: 'CASCADE', - onDelete: 'CASCADE', - }, - roleId: { - type: Sequelize.INTEGER, - allowNull: true, - defaultValue: null, - references: { - model: 'user_role', - key: 'id', - }, - onUpdate: 'CASCADE', - onDelete: 'CASCADE', - }, - userId: { - type: Sequelize.INTEGER, - allowNull: true, - defaultValue: null, - references: { - model: 'user', - key: 'id', - }, - onUpdate: 'CASCADE', - onDelete: 'CASCADE', - }, - createdAt: { - allowNull: false, - type: Sequelize.DATE, - }, - updatedAt: { - allowNull: false, - type: Sequelize.DATE, - }, - deleted: { - type: Sequelize.BOOLEAN, - allowNull: false, - defaultValue: false, - }, - }); - - - await queryInterface.addIndex('assignment_share', ['assignmentId'], { - name: 'assignment_share_assignmentId_index', - }); - - await queryInterface.sequelize.query( - `ALTER TABLE "assignment_share" ADD CONSTRAINT "chk_assignment_share_exclusive" - CHECK ( - ("roleId" IS NOT NULL AND "userId" IS NULL) OR - ("userId" IS NOT NULL AND "roleId" IS NULL) - )` - ); - }, - - async down(queryInterface, Sequelize) { - await queryInterface.dropTable('assignment_share'); - }, -}; diff --git a/backend/db/migrations/20260425001238-create-assignment-nav_group.js b/backend/db/migrations/20260425001238-create-assignment-nav_group.js deleted file mode 100644 index 4feacbf83..000000000 --- a/backend/db/migrations/20260425001238-create-assignment-nav_group.js +++ /dev/null @@ -1,95 +0,0 @@ -'use strict'; - -const NEW_GROUP = { name: 'Assignment', icon: 'journal-check', order: 4 }; - -// Groups that need to be shifted up to make room for the new Assignment group -const SHIFTED_GROUPS = [ - { name: 'Settings', newOrder: 6, oldOrder: 4 }, - { name: 'AI', newOrder: 5, oldOrder: 5 }, -]; - -const ELEMENT_TO_GROUP = { - Assignments: { group: 'Assignment', order: 1, previousGroup: 'Default' }, - Submissions: { group: 'Assignment', order: 2, previousGroup: 'Study', icon: 'file-earmark-arrow-up', previousIcon: 'file-earmark-richtext' }, -}; - -module.exports = { - async up(queryInterface, Sequelize) { - const now = new Date(); - - // Shift existing groups to make room - for (const group of SHIFTED_GROUPS) { - await queryInterface.bulkUpdate( - 'nav_group', - { order: group.newOrder, updatedAt: now }, - { name: group.name } - ); - } - - await queryInterface.bulkInsert('nav_group', [{ - name: NEW_GROUP.name, - icon: NEW_GROUP.icon, - order: NEW_GROUP.order, - admin: false, - deleted: false, - createdAt: now, - updatedAt: now, - deletedAt: null, - }]); - - for (const [elementName, config] of Object.entries(ELEMENT_TO_GROUP)) { - const groupId = await queryInterface.rawSelect( - 'nav_group', - { where: { name: config.group } }, - ['id'] - ); - - if (groupId) { - const updateFields = { groupId, order: config.order, updatedAt: now }; - if (config.icon) { - updateFields.icon = config.icon; - } - await queryInterface.bulkUpdate( - 'nav_element', - updateFields, - { name: elementName } - ); - } - } - }, - - async down(queryInterface, Sequelize) { - const now = new Date(); - - for (const [elementName, config] of Object.entries(ELEMENT_TO_GROUP)) { - const groupId = await queryInterface.rawSelect( - 'nav_group', - { where: { name: config.previousGroup } }, - ['id'] - ); - - if (groupId) { - const revertFields = { groupId, updatedAt: now }; - if (config.previousIcon) { - revertFields.icon = config.previousIcon; - } - await queryInterface.bulkUpdate( - 'nav_element', - revertFields, - { name: elementName } - ); - } - } - - await queryInterface.bulkDelete('nav_group', { name: NEW_GROUP.name }); - - // Restore shifted groups to their original order - for (const group of SHIFTED_GROUPS) { - await queryInterface.bulkUpdate( - 'nav_group', - { order: group.oldOrder, updatedAt: now }, - { name: group.name } - ); - } - }, -}; diff --git a/backend/db/migrations/20260511000000-add-guest-submissions-view-right.js b/backend/db/migrations/20260511000000-add-guest-submissions-view-right.js deleted file mode 100644 index 91d309e32..000000000 --- a/backend/db/migrations/20260511000000-add-guest-submissions-view-right.js +++ /dev/null @@ -1,51 +0,0 @@ -'use strict'; - -const RIGHT_NAME = "frontend.dashboard.submissions.view"; -const ROLE_NAME = "guest"; - -/** @type {import('sequelize-cli').Migration} */ -module.exports = { - async up(queryInterface, Sequelize) { - const userRoles = await queryInterface.sequelize.query('SELECT id, name FROM "user_role"', { - type: queryInterface.sequelize.QueryTypes.SELECT, - }); - - const guestRole = userRoles.find((role) => role.name === ROLE_NAME); - if (!guestRole) { - return; - } - - await queryInterface.bulkInsert( - "role_right_matching", - [ - { - userRoleId: guestRole.id, - userRightName: RIGHT_NAME, - createdAt: new Date(), - updatedAt: new Date(), - }, - ], - {} - ); - }, - - async down(queryInterface, Sequelize) { - const userRoles = await queryInterface.sequelize.query('SELECT id, name FROM "user_role"', { - type: queryInterface.sequelize.QueryTypes.SELECT, - }); - - const guestRole = userRoles.find((role) => role.name === ROLE_NAME); - if (!guestRole) { - return; - } - - await queryInterface.bulkDelete( - "role_right_matching", - { - userRoleId: guestRole.id, - userRightName: RIGHT_NAME, - }, - {} - ); - }, -}; diff --git a/backend/db/migrations/20260530135251-extend-setting-displayName-email_templates-type_7.js b/backend/db/migrations/20260530135251-extend-setting-displayName-email_templates-type_7.js deleted file mode 100644 index 6528e1754..000000000 --- a/backend/db/migrations/20260530135251-extend-setting-displayName-email_templates-type_7.js +++ /dev/null @@ -1,35 +0,0 @@ -'use strict'; - -/** - * Set displayName, displayGroup, and displaySubsection for submission upload email - * template settings (type 7), added after extend-setting-displayName-email_templates. - * - * @type {import('sequelize-cli').Migration} - */ - -const UPDATES = [ - { key: 'email.template.submissionUpload', displayName: 'Submission upload (assignment owner)', displayGroup: 'Mail', displaySubsection: 'Email templates' }, - { key: 'email.template.submissionUploadConfirmation', displayName: 'Submission upload (submitter)', displayGroup: 'Mail', displaySubsection: 'Email templates' }, -]; - -module.exports = { - async up(queryInterface, Sequelize) { - const now = new Date(); - for (const u of UPDATES) { - await queryInterface.sequelize.query( - `UPDATE setting SET "displayName" = :dn, "displayGroup" = :dg, "displaySubsection" = :ds, "updatedAt" = :now WHERE key = :k`, - { replacements: { dn: u.displayName, dg: u.displayGroup, ds: u.displaySubsection, k: u.key, now } } - ); - } - }, - - async down(queryInterface, Sequelize) { - const now = new Date(); - for (const u of UPDATES) { - await queryInterface.sequelize.query( - `UPDATE setting SET "displayName" = NULL, "displayGroup" = NULL, "displaySubsection" = NULL, "updatedAt" = :now WHERE key = :k`, - { replacements: { k: u.key, now } } - ); - } - }, -}; diff --git a/backend/db/models/assignment.js b/backend/db/models/assignment.js deleted file mode 100644 index c11a01e7d..000000000 --- a/backend/db/models/assignment.js +++ /dev/null @@ -1,171 +0,0 @@ -'use strict'; -const MetaModel = require("../MetaModel.js"); -const { Op } = require("sequelize"); - -module.exports = (sequelize, DataTypes) => { - class Assignment extends MetaModel { - static autoTable = true; - static accessMap = [ - { - right: "frontend.dashboard.assignments.viewAll", - columns: this.getAttributes(), - }, - ]; - static fields = [ - { - key: "name", - label: "Assignment Name:", - placeholder: "Assignment 1", - type: "text", - required: true, - default: "", - }, - { - key: "description", - label: "Description:", - help: "Optional description shown for this assignment.", - type: "textarea", - required: false, - }, - { - key: "maxRevisions", - label: "Maximum Revisions:", - type: "slider", - class: "custom-slider-class", - min: 1, - max: 20, - step: 1, - unit: "revision(s)", - unlimitedAtMax: true, - unlimitedLabel: "unlimited", - unlimitedStoredValue: 0, - required: true, - default: 1, - help: "Maximum number of allowed revision copies for this assignment. Move to the end for unlimited.", - }, - { - key: "start", - label: "Start Time:", - type: "datetime", - size: 6, - default: null, - required: false, - }, - { - key: "end", - label: "End Time:", - type: "datetime", - size: 6, - default: null, - required: false, - }, - { - key: "validationConfigurationId", - label: "Validation Configuration:", - type: "select", - options: { - table: "configuration", - name: "name", - value: "id", - filter: [ - { key: "type", value: 1 }, - ], - }, - required: true, - help: "Validation is applied before submission upload.", - }, - { - key: "allowReUpload", - label: "Allow Re-Upload:", - type: "switch", - default: false, - required: false, - help: "If enabled, users can replace or delete uploaded submissions.", - }, - { - key: "notifyOnSubmissionUpload", - label: "Notify on Submission Upload:", - type: "switch", - default: false, - required: false, - help: "If enabled, sends an email when a student uploads or re-uploads a submission.", - }, - ]; - - /** - * Apply visibility filter for assignments based on assignment_share. - * A user can see an assignment if they are the owner (userId), or if the - * assignment's assignment_share entry has their userId or one of their roleIds. - * - * @param {number} userId - The ID of the user to build the filter for. - * @returns {object} Sequelize where-clause filter object. - */ - static async getUserFilter(userId) { - const roleIds = await sequelize.models.user_role_matching.getUserRolesById(userId); - - // Step 1: build the assignment_share query — rows belonging to this user directly or via role - const roleOrConditions = [{ userId }]; - if (Array.isArray(roleIds) && roleIds.length > 0) { - roleOrConditions.push({ roleId: { [Op.in]: roleIds } }); - } - // Step 2: find all assignmentIds this user is linked to - const matchingEntries = await sequelize.models.assignment_share.findAll({ - attributes: ['assignmentId'], - where: { - deleted: { [Op.not]: true }, - [Op.or]: roleOrConditions, - }, - raw: true, - }); - const assignedIds = [...new Set(matchingEntries.map(e => e.assignmentId))]; - - // Step 3: filter assignments by ownership or assignment_share membership - const filter = { [Op.or]: [{ userId }] }; - if (assignedIds.length > 0) { - filter[Op.or].push({ id: { [Op.in]: assignedIds } }); - } - return filter; - } - static associate(models) { - - Assignment.belongsTo(models["configuration"], { - foreignKey: "validationConfigurationId", - as: "validationConfiguration", - }); - - Assignment.belongsTo(models["assignment"], { - foreignKey: "parentAssignmentId", - as: "parentAssignment", - }); - } - } - - Assignment.init( - { - name: DataTypes.STRING, - description: DataTypes.TEXT, - projectId: DataTypes.INTEGER, - userId: DataTypes.INTEGER, - disable: DataTypes.BOOLEAN, - maxRevisions: DataTypes.INTEGER, - start: DataTypes.DATE, - end: DataTypes.DATE, - validationConfigurationId: DataTypes.INTEGER, - parentAssignmentId: DataTypes.INTEGER, - allowReUpload: DataTypes.BOOLEAN, - notifyOnSubmissionUpload: DataTypes.BOOLEAN, - closed: DataTypes.DATE, - deleted: DataTypes.BOOLEAN, - deletedAt: DataTypes.DATE, - createdAt: DataTypes.DATE, - updatedAt: DataTypes.DATE, - }, - { - sequelize, - modelName: 'assignment', - tableName: 'assignment', - } - ); - - return Assignment; -}; diff --git a/backend/db/models/assignment_share.js b/backend/db/models/assignment_share.js deleted file mode 100644 index 0d8789e08..000000000 --- a/backend/db/models/assignment_share.js +++ /dev/null @@ -1,56 +0,0 @@ -'use strict'; -const MetaModel = require("../MetaModel.js"); - -module.exports = (sequelize, DataTypes) => { - class AssignmentShare extends MetaModel { - static autoTable = true; - static accessMap = [ - { - right: "frontend.dashboard.assignments.viewAll", - columns: this.getAttributes(), - }, - ]; - - static associate(models) { - AssignmentShare.belongsTo(models["assignment"], { - foreignKey: "assignmentId", - as: "assignment", - }); - - AssignmentShare.belongsTo(models["user_role"], { - foreignKey: "roleId", - as: "role", - }); - - AssignmentShare.belongsTo(models["user"], { - foreignKey: "userId", - as: "user", - }); - } - } - - AssignmentShare.init( - { - assignmentId: DataTypes.INTEGER, - roleId: DataTypes.INTEGER, - userId: DataTypes.INTEGER, - deleted: DataTypes.BOOLEAN, - createdAt: DataTypes.DATE, - updatedAt: DataTypes.DATE, - }, - { - sequelize, - modelName: 'assignment_share', - tableName: 'assignment_share', - hooks: { - afterUpdate: async (assignmentShare, options) => { - if (assignmentShare.deleted && !assignmentShare._previousDataValues.deleted) { - await AssignmentShare.destroy({ where: { id: assignmentShare.id }, transaction: options.transaction }); - } - }, - }, - } - ); - - return AssignmentShare; -}; diff --git a/backend/db/models/document.js b/backend/db/models/document.js index 90a7adeda..b99f34537 100644 --- a/backend/db/models/document.js +++ b/backend/db/models/document.js @@ -20,13 +20,6 @@ module.exports = (sequelize, DataTypes) => { static autoTable = true; - static accessMap = [ - { - right: "frontend.dashboard.assignments.viewAll", - columns: this.getAttributes(), - }, - ]; - static fields = [ { key: "name", @@ -210,28 +203,6 @@ module.exports = (sequelize, DataTypes) => { } } - /** - * Delete the physical file associated with a document from disk. - * @param {Object} document - The document record (must have hash and type). - * @returns {Promise} - */ - static async deleteDocumentFile(document) { - const extensions = { - [Document.docTypes.DOC_TYPE_PDF]: '.pdf', - [Document.docTypes.DOC_TYPE_HTML]: '.delta', - [Document.docTypes.DOC_TYPE_MODAL]: '.delta', - [Document.docTypes.DOC_TYPE_ZIP]: '.zip', - }; - const ext = extensions[document.type]; - if (!ext) return; - const filePath = path.join(UPLOAD_PATH, `${document.hash}${ext}`); - try { - await fs.promises.unlink(filePath); - } catch (err) { - if (err.code !== 'ENOENT') throw err; - } - } - /** * Cascade delete study steps and sessions for a document. * Deletes all study steps with the given documentId and related study sessions. @@ -364,15 +335,11 @@ module.exports = (sequelize, DataTypes) => { projectId: DataTypes.INTEGER, submissionId: DataTypes.INTEGER, originalFilename: DataTypes.STRING, - studyUsageCount: DataTypes.INTEGER }, { sequelize: sequelize, modelName: 'document', tableName: 'document', hooks: { - afterDestroy: async (document, options) => { - await Document.deleteDocumentFile(document); - }, afterUpdate: async (document, options) => { // If the document is deleted, we should also delete the associated db columns if (document.deleted && !document._previousDataValues.deleted) { diff --git a/backend/db/models/study.js b/backend/db/models/study.js index ca7e32101..4b0ba74ad 100644 --- a/backend/db/models/study.js +++ b/backend/db/models/study.js @@ -222,7 +222,6 @@ module.exports = (sequelize, DataTypes) => { */ static async deleteStudySteps(study, options) { const studySteps = await sequelize.models.study_step.getAllByKey("studyId", study.id); - const documentIds = [...new Set(studySteps.map((step) => step.documentId).filter(Boolean))]; for (const studyStep of studySteps) { await sequelize.models.study_step.deleteById(studyStep.id, {transaction: options.transaction}); @@ -298,12 +297,6 @@ module.exports = (sequelize, DataTypes) => { } } - const usedDocumentIds = [...new Set( - Object.values(studyStepsMap) - .map((step) => step.documentId) - .filter(Boolean) - )]; - } /** diff --git a/backend/db/models/study_step.js b/backend/db/models/study_step.js index 5c3d7f50e..d98815a17 100644 --- a/backend/db/models/study_step.js +++ b/backend/db/models/study_step.js @@ -247,41 +247,6 @@ module.exports = (sequelize, DataTypes) => { return await super.add(data, options); } - /** - * Recalculate and persist how many studies use a given document. - * - * @param {number} documentId - The document ID to recalculate usage for. - * @param {object} options - Optional sequelize options with transaction. - * @returns {Promise} The recalculated study usage count. - */ - static async updateDocumentStudyUsageCount(documentId, options = {}) { - if (!documentId) { - return 0; - } - - const transaction = options.transaction; - const count = await sequelize.models.study_step.count({ - distinct: true, - col: "studyId", - where: { - documentId, - deleted: false, - }, - include: [{ - model: sequelize.models.study, - as: "study", - attributes: [], - where: { deleted: false }, - required: true, - }], - transaction, - }); - - await sequelize.models.document.updateById(documentId, { studyUsageCount: count }, { transaction }); - return count; - } - - /** * Helper method for defining associations. * This method is not a part of Sequelize lifecycle. @@ -346,16 +311,9 @@ module.exports = (sequelize, DataTypes) => { } } }*/ - if(studyStep.deleted) { - await StudyStep.updateDocumentStudyUsageCount(studyStep.documentId, {transaction: options.transaction}); - } - - }, - afterCreate: async (studyStep, options) => { - // if a new step is created with a document, we need to update the study usage count for this document - await StudyStep.updateDocumentStudyUsageCount(studyStep.documentId, {transaction: options.transaction}); } } + } ); diff --git a/backend/db/models/submission.js b/backend/db/models/submission.js index 9f6760d41..b28a89997 100644 --- a/backend/db/models/submission.js +++ b/backend/db/models/submission.js @@ -9,12 +9,6 @@ const UPLOAD_PATH = `${__dirname}/../../../files`; module.exports = (sequelize, DataTypes) => { class Submission extends MetaModel { static autoTable = true; - static accessMap = [ - { - right: "frontend.dashboard.assignments.viewAll", - columns: this.getAttributes(), - }, - ]; static fields = []; @@ -24,11 +18,6 @@ module.exports = (sequelize, DataTypes) => { as: "documents", }); - Submission.belongsTo(models["assignment"], { - foreignKey: "assignmentId", - as: "assignment", - }); - Submission.belongsTo(models["submission"], { foreignKey: "parentSubmissionId", as: "parentSubmission", @@ -157,11 +146,8 @@ module.exports = (sequelize, DataTypes) => { userId: originalSubmission.userId, createdByUserId: createdByUserId, projectId: originalSubmission.projectId || null, - assignmentId: originalSubmission.assignmentId || null, parentSubmissionId: originalSubmissionId, // Link to parent extId: originalSubmission.extId || null, - name: originalSubmission.name || null, - description: originalSubmission.description || null, group: originalSubmission.group, additionalSettings: originalSubmission.additionalSettings || null, validationConfigurationId: originalSubmission.validationConfigurationId || null, @@ -253,12 +239,10 @@ module.exports = (sequelize, DataTypes) => { userId: DataTypes.INTEGER, createdByUserId: DataTypes.INTEGER, projectId: DataTypes.INTEGER, - assignmentId: DataTypes.INTEGER, parentSubmissionId: DataTypes.INTEGER, previousSubmissionId: DataTypes.INTEGER, extId: DataTypes.INTEGER, - name: DataTypes.STRING, - description: DataTypes.TEXT, + group: DataTypes.INTEGER, additionalSettings: DataTypes.JSONB, validationConfigurationId: DataTypes.INTEGER, deleted: DataTypes.BOOLEAN, @@ -281,12 +265,6 @@ module.exports = (sequelize, DataTypes) => { await sequelize.models["document"].deleteById(document.id); } } - }, - beforeDestroy: async (submission, options) => { - const documents = await sequelize.models.document.getAllByKey("submissionId", submission.id); - for (const document of documents) { - await sequelize.models.document.deleteById(document.id, { force: true, transaction: options.transaction }); - } } }, } diff --git a/backend/db/models/template.js b/backend/db/models/template.js index 0ec528cfa..da1c46a79 100644 --- a/backend/db/models/template.js +++ b/backend/db/models/template.js @@ -24,7 +24,7 @@ module.exports = (sequelize, DataTypes) => { return {[Op.or]: [{userId: userId}, {public: true}]}; } else { // Non-admins: own templates (types 4, 5 only) OR public templates from others (types 4, 5 only) - // Email templates (types 1, 2, 3, 6, 7) are admin-only + // Email templates (types 1, 2, 3, 6) are admin-only return { [Op.or]: [ {[Op.and]: [{userId: userId}, {type: {[Op.in]: [4, 5]}}]}, @@ -66,7 +66,7 @@ module.exports = (sequelize, DataTypes) => { /** * Override getAutoTable to apply custom filtering for templates: * - All users (including admins): own templates OR public templates from others - * - Non-admins: exclude email templates (types 1, 2, 3, 6, 7) - admin-only + * - Non-admins: exclude email templates (types 1, 2, 3, 6) - admin-only */ static async getAutoTable(filterList = [], userId = null, attributes = null) { const {Op} = require("sequelize"); @@ -149,10 +149,6 @@ module.exports = (sequelize, DataTypes) => { name: "Email - Study Close", value: 6 }, - { - name: "Email - Submission upload", - value: 7 - }, { name: "Document - General", value: 4 @@ -472,12 +468,12 @@ module.exports = (sequelize, DataTypes) => { ); } - // appDataUpdate / updateData passes callerUserId so hooks can enforce ownership - if (options.callerUserId === undefined) { + // updateData sets context.currentUserId (same pattern as study model) + if (options.context?.currentUserId === undefined) { return; } - if (template.userId !== options.callerUserId) { + if (template.userId !== options.context.currentUserId) { throw new Error( "You can only update templates that you own" ); diff --git a/backend/db/models/user_role.js b/backend/db/models/user_role.js index 51e9eb88e..57ebf3481 100644 --- a/backend/db/models/user_role.js +++ b/backend/db/models/user_role.js @@ -4,7 +4,6 @@ const MetaModel = require("../MetaModel.js"); module.exports = (sequelize, DataTypes) => { class UserRole extends MetaModel { static autoTable = true; - static publicTable = true; /** * Helper method for defining associations. * This method is not a part of Sequelize lifecycle. diff --git a/backend/db/plugins.js b/backend/db/plugins.js index 5f153b769..16a170fe6 100644 --- a/backend/db/plugins.js +++ b/backend/db/plugins.js @@ -31,7 +31,6 @@ function GlobalChangeTrackingPlugin(sequelize) { afterDestroy: (instance, options) => { if (options.transaction) { options.transaction.changes = options.transaction.changes || []; - instance.dataValues.deleted = true; options.transaction.changes.push(instance); } } diff --git a/backend/package-lock.json b/backend/package-lock.json index c8ac72d58..8fc9f4d45 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -56,7 +56,8 @@ }, "devDependencies": { "cross-env": "^10.1.0", - "jest": "^30.2.0" + "jest": "^30.2.0", + "nodemon": "^3.1.10" }, "engines": { "node": ">=18.0.0" @@ -105,6 +106,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2134,6 +2136,7 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "license": "MIT", + "peer": true, "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", @@ -2294,6 +2297,7 @@ "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", "license": "Apache-2.0", + "peer": true, "peerDependencies": { "bare-abort-controller": "*" }, @@ -2451,6 +2455,19 @@ "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", "license": "MIT" }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -2496,6 +2513,19 @@ "balanced-match": "^1.0.0" } }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -2516,6 +2546,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2706,6 +2737,31 @@ "node": "*" } }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -3677,6 +3733,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -3848,6 +3905,19 @@ "moment": "^2.29.1" } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -4143,6 +4213,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -4289,6 +4372,13 @@ ], "license": "BSD-3-Clause" }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, "node_modules/immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", @@ -4382,6 +4472,19 @@ "dev": true, "license": "MIT" }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-buffer": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", @@ -4403,6 +4506,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -4422,6 +4535,29 @@ "node": ">=6" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -5792,6 +5928,110 @@ "node": ">=6.0.0" } }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/nodemon/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/nodemon/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/nopt": { "version": "7.2.1", "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", @@ -6204,6 +6444,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -6244,7 +6485,6 @@ "resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.19.0.tgz", "integrity": "sha512-J5cF1MUz7LRJ9emOqF/06QjabMHMZy587rSPF0UuA8rCwKeeYl2co8Pp+6k5UU9YrAYHMzWkLxilfZB0hqsWWw==", "license": "MIT", - "peer": true, "peerDependencies": { "pg": "^8" } @@ -6498,6 +6738,13 @@ "node": ">=10" } }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, "node_modules/pure-rand": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", @@ -6619,6 +6866,32 @@ "node": ">=10" } }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -6794,6 +7067,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@types/debug": "^4.1.8", "@types/validator": "^13.7.17", @@ -7213,6 +7487,32 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -7806,6 +8106,19 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -7821,6 +8134,16 @@ "integrity": "sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg==", "license": "MIT" }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, "node_modules/triple-beam": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", @@ -7914,6 +8237,13 @@ "node": ">=6.0.0" } }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, "node_modules/undici-types": { "version": "7.19.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", @@ -8135,6 +8465,7 @@ "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", "license": "MIT", + "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", diff --git a/backend/utils/emailHelper.js b/backend/utils/emailHelper.js index 616059b04..d76497a64 100644 --- a/backend/utils/emailHelper.js +++ b/backend/utils/emailHelper.js @@ -53,12 +53,6 @@ async function getEmailFallbackContent(key, variables = {}) { * @param {string} [context.otp] - One-time password code (2FA email) * @param {number} [context.tokenExpiry] - Token expiry hours * @param {Object} [context.options] - Extra resolver options (e.g. transaction) - * @param {string} [context.eventType] - Upload event for ~eventType~ (submission upload; uploaded/reuploaded) - * @param {string} [context.eventLabel] - Title-style label for submission upload fallback - * @param {string} [context.eventLabelLower] - Lowercase label for submission upload fallback - * @param {number} [context.assignmentId] - Assignment ID (submission upload) - * @param {number} [context.submissionId] - Submission ID (submission upload) - * @param {string} [context.timestamp] - Upload timestamp from submission createdAt (submission upload) * @param {Object} models - Database models * @param {Object} logger - Logger instance * @returns {Promise<{subject: string, body: string, isHtml: boolean}>} Email subject, body, and whether body is HTML diff --git a/backend/utils/templateResolver.js b/backend/utils/templateResolver.js index 229d65f65..07e9c923d 100644 --- a/backend/utils/templateResolver.js +++ b/backend/utils/templateResolver.js @@ -100,7 +100,7 @@ async function buildReplacementMap(context, models, options = {}) { if (allow("assignmentType") && context.assignmentType) { replacements["~assignmentType~"] = context.assignmentType; } - if (allow("assignmentName") && context.assignmentName != null) { + if (allow("assignmentName") && context.assignmentName) { replacements["~assignmentName~"] = context.assignmentName; } @@ -117,20 +117,6 @@ async function buildReplacementMap(context, models, options = {}) { replacements["~tokenExpiry~"] = String(context.tokenExpiry); } - // Submission upload notification (template type 7) - if (allow("eventType") && context.eventType) { - replacements["~eventType~"] = context.eventType; - } - if (allow("assignmentId") && context.assignmentId != null) { - replacements["~assignmentId~"] = String(context.assignmentId); - } - if (allow("submissionId") && context.submissionId != null) { - replacements["~submissionId~"] = String(context.submissionId); - } - if (allow("timestamp") && context.timestamp) { - replacements["~timestamp~"] = context.timestamp; - } - return replacements; } @@ -337,7 +323,7 @@ async function resolveTemplateToDelta(templateId, context, models, options = {}) * Return placeholder keys that are required for the given template type but missing in content. * * @param {Object} content - Quill Delta object with ops array - * @param {number} templateType - Template type (e.g. 1, 2, 3, 6, 7) + * @param {number} templateType - Template type (e.g. 1, 2, 3, 6) * @param {Object} models - Database models * @param {Object} [options] * @returns {Promise} Array of missing required placeholder keys (e.g. ['link']) diff --git a/backend/webserver/Socket.js b/backend/webserver/Socket.js index 96ccec9a4..5910ddf85 100644 --- a/backend/webserver/Socket.js +++ b/backend/webserver/Socket.js @@ -441,87 +441,54 @@ module.exports = class Socket { * @returns {Object} modified filters and attributes + whether access is allowed */ async getFiltersAndAttributes(userId, allFilter, allAttributes, tableName, rolesUpdatedAt) { - const accessMap = this.server.db.models[tableName]['accessMap'] || []; + const accessMap = this.server.db.models[tableName]['accessMap']; const filteredAccessMap = await this.filterAccessMap(accessMap, userId, rolesUpdatedAt); const relevantAccessMap = filteredAccessMap.filter(item => item.hasAccess); const accessRights = relevantAccessMap.map(item => item.access); const model = this.models[tableName]; const hasModelUserFilter = typeof model.getUserFilter === "function"; const isAdmin = await this.isAdmin(userId, rolesUpdatedAt); - const isPublicOrAdmin = isAdmin || model.publicTable; - const hasAccessRules = accessMap.length > 0; - const hasUserIdAttribute = model.autoTable && 'userId' in model.getAttributes(); - - // Early denial: not public/admin, has access rules, no matching rights, no user-filter, and no ownership fallback - if (!isPublicOrAdmin && hasAccessRules && accessRights.length === 0 && !hasModelUserFilter && !hasUserIdAttribute) { - this.logger.warn("User with id " + userId + " requested table " + tableName + " without access rights"); - return {filter: allFilter, attributes: allAttributes, accessAllowed: false}; - } - - // Collect row-visibility conditions from user filter and access-map limitations. - // All conditions are combined with OR so the user sees the union of what each grants. - // fullRowAccess=true means no row restriction is applied (admin, public table, or unlimited right). - const rowVisibilityConditions = []; - let fullRowAccess = isPublicOrAdmin; - - if (!fullRowAccess) { - // --- Ownership: user always sees their own rows when table has userId --- - if (hasUserIdAttribute) { - rowVisibilityConditions.push({userId}); + + if ((isAdmin || model.publicTable) && !hasModelUserFilter) { // is allowed to see everything + // no adaption of the filter or attributes needed + } else if (hasModelUserFilter) { + const userFilter = model.getUserFilter(userId, isAdmin); + allFilter = {[Op.and]: [allFilter, userFilter]}; + } else if (model.autoTable && 'userId' in model.getAttributes() && accessRights.length === 0) { + // is allowed to see only his data and possible if there is a public attribute + const userFilter = {}; + if ("public" in model.getAttributes()) { + userFilter[Op.or] = [{userId: userId}, {public: true}]; + } else { + userFilter['userId'] = userId; } + allFilter = {[Op.and]: [allFilter, userFilter]}; + } else { + + if (accessRights.length > 0) { + if (relevantAccessMap.every(item => item.limitation)) { + const {filter: limitedFilter, columns} = this.handleLimitations( + tableName, + allFilter, + accessRights, + relevantAccessMap, + userId + ); - // --- User-level row filter --- - if (hasModelUserFilter) { - const userFilter = await model.getUserFilter(userId); - if (Reflect.ownKeys(userFilter).length > 0) { - rowVisibilityConditions.push(userFilter); - } else { - // getUserFilter returns {} → grants full row access (e.g. for admins) - fullRowAccess = true; + allFilter = limitedFilter; + allAttributes['include'] = columns; + } else { // do without limitations + allAttributes['include'] = [...new Set( + accessRights + .filter(a => a.columns) + .flatMap(a => a.columns) + )]; } - } else if (!hasUserIdAttribute && accessRights.length === 0) { + + } else { this.logger.warn("User with id " + userId + " requested table " + tableName + " without access rights"); return {filter: allFilter, attributes: allAttributes, accessAllowed: false}; } - - // --- Access-map limitations (ORed with user filter conditions) --- - if (!fullRowAccess && accessRights.length > 0) { - const limitedAccessMap = relevantAccessMap.filter(item => item.limitation); - const hasUnlimitedRights = accessRights.length > limitedAccessMap.length; - - if (hasUnlimitedRights) { - // At least one right has no limitation → unlimited row access for that right - fullRowAccess = true; - } else if (limitedAccessMap.length > 0) { - // All rights carry limitations → add each as an additional OR condition - limitedAccessMap.forEach(a => { - const idField = a.access.target || 'id'; - rowVisibilityConditions.push({[idField]: {[Op.in]: [...new Set(a.limitation)]}}); - }); - } - } - } - - // --- Column restrictions from access rights (applied regardless of row logic) --- - if (accessRights.length > 0) { - allAttributes['include'] = [...new Set( - accessRights - .filter(a => a.columns) - .flatMap(a => a.columns) - )]; - } - - // Apply row-visibility: baseFilter AND (condition1 OR condition2 OR ...) - // Skipped entirely when fullRowAccess is true (no row restriction needed). - if (!fullRowAccess && rowVisibilityConditions.length > 0) { - allFilter = { - [Op.and]: [ - allFilter, - rowVisibilityConditions.length === 1 - ? rowVisibilityConditions[0] - : {[Op.or]: rowVisibilityConditions}, - ], - }; } return {filter: allFilter, attributes: allAttributes, accessAllowed: true}; } @@ -676,6 +643,7 @@ module.exports = class Socket { allFilter = filtersAndAttributes.filter; allAttributes = filtersAndAttributes.attributes; + let data = await this.models[tableName].getAll({ where: allFilter, attributes: allAttributes, diff --git a/backend/webserver/routes/export.js b/backend/webserver/routes/export.js index 39721b964..96f890ddc 100644 --- a/backend/webserver/routes/export.js +++ b/backend/webserver/routes/export.js @@ -5,6 +5,8 @@ const { faker } = require('@faker-js/faker'); const JSZip = require('jszip'); const { deriveUserSeed } = require('../auth/utils'); const Papa = require('papaparse'); +const { dbToDelta, deltaToPlainText, deltaToHtml } = require('editor-delta-conversion'); +const storageDir = path.join(__dirname, "..", "..", "..", "files"); module.exports = function (server) { @@ -35,12 +37,11 @@ module.exports = function (server) { } // Input parsing - const { projectId, exportType, generateAliases, fakerSeed, gradeFormat } = req.body; - let { userIds = [] } = req.body; + const { projectId, exportType, generateAliases, fakerSeed, includeNonConsentingEdits, includeNonConsentingAnnotations } = req.body; + let { userIds = [], documentTypes = [0, 1, 2, 4] } = req.body; const shouldGenerateAliases = String(generateAliases) === 'true'; - const normalizedGradeFormat = String(gradeFormat || "json").toLowerCase(); - const supportedExportTypes = new Set(["submissions", "grades"]); - const { Op } = server.db.Sequelize; + const shouldIncludeNonConsentingEdits = String(includeNonConsentingEdits) === 'true'; + const shouldIncludeNonConsentingAnnotations = String(includeNonConsentingAnnotations) === 'true'; try { userIds = typeof userIds === 'string' ? JSON.parse(userIds) : userIds; @@ -51,18 +52,6 @@ module.exports = function (server) { } try { - if (!projectId) return res.status(400).send("Missing projectId."); - if (!supportedExportTypes.has(exportType)) { - return res.status(400).send("Unsupported export type."); - } - if (exportType === "grades" && !["json", "csv"].includes(normalizedGradeFormat)) { - return res.status(400).send("Unsupported grade format. Use json or csv."); - } - if (userIds.length === 0) { - console.warn("Export aborted: No valid users selected."); - return res.status(400).send("No valid users selected."); - } - // check if the project is valid const projectCheck = await server.db.models.project.findOne({ where: { id: projectId } }); if (!projectCheck) { @@ -70,9 +59,10 @@ module.exports = function (server) { return res.status(403).send("The selected project does not exist."); } - const users = await server.db.models.user.findAll({ where: { id: { [Op.in]: userIds } } }); - if (users.length === 0) { - console.warn("Export aborted: No existing users to export."); + const users = await server.db.models.user.findAll({ where: { id: userIds } }); + + if (userIds.length === 0) { + console.warn(`Export aborted: No authorized users to export.`); return res.status(400).send("No authorized users to export."); } @@ -109,21 +99,20 @@ module.exports = function (server) { archive ); break; - case 'grades': - await processGradesExport( + case 'documents': + await processDocumentBasedExport( server, projectId, userIds, - users, - shouldGenerateAliases, - hasPrivateInfoRight, - userMapping, - normalizedGradeFormat, + documentTypes, + shouldIncludeNonConsentingEdits, + shouldIncludeNonConsentingAnnotations, + exportFolderName.split('.')[0], archive ); break; default: - return res.status(400).send("Unsupported export type."); + console.warn(`Export type ${exportType} not implemented.`); } await archive.finalize(); @@ -148,9 +137,6 @@ module.exports = function (server) { async function replaceAuthorInZip(filePath, realName, fakeName) { const fileData = fs.readFileSync(filePath); const zip = await JSZip.loadAsync(fileData); - // TODO: What if the realName contains middle name? - const [realFirstName = "", realLastName = ""] = String(realName || "").split(/\s+/, 2); - const [fakeFirstName = "", fakeLastName = ""] = String(fakeName || "").split(/\s+/, 2); const authorRegex = /\\author\s*\{[^}]*\}/g; @@ -158,8 +144,8 @@ module.exports = function (server) { if (!zipEntry.dir && relativePath.toLowerCase().endsWith('.tex')) { let text = await zipEntry.async("string"); text = text.replace(authorRegex, `\\author{${fakeName}}`); - if (realFirstName && fakeFirstName) text = text.replace(realFirstName, fakeFirstName); - if (realLastName && fakeLastName) text = text.replace(realLastName, fakeLastName); + text = text.replace(realName.split(" ")[0], fakeName.split(" ")[0]); + text = text.replace(realName.split(" ")[1], fakeName.split(" ")[1]); zip.file(relativePath, text); } @@ -193,8 +179,7 @@ module.exports = function (server) { faker.seed(derivedFakerSeed); } - const sortedUsers = [...users].sort((a, b) => Number(a.id) - Number(b.id)); - sortedUsers.forEach(u => { + users.forEach(u => { const realUsername = u.userName; const realName = `${u.firstName} ${u.lastName}`; const fakeName = `${faker.person.firstName()} ${faker.person.lastName()}`; @@ -262,7 +247,6 @@ module.exports = function (server) { 1: ".html", 4: ".zip" }; - const storageDir = path.join(__dirname, "..", "..", "..", "files"); for (const submission of submissions) { const student = users.find(u => u.id === submission.userId); @@ -270,7 +254,6 @@ module.exports = function (server) { const validationRules = configMap.get(submission.validationConfigurationId); let folderName = shouldGenerateAliases ? userMapping[student.id] : (hasPrivateInfoRight ? `${student.firstName} ${student.lastName}` : `${student.userName}`); - folderName = sanitizeFolderName(folderName); for (const doc of submission.documents) { const version = calculateSubmissionVersion(submission, submissionMap); @@ -293,7 +276,7 @@ module.exports = function (server) { if (fs.existsSync(filePath)) { if (shouldGenerateAliases && doc.type == 4) { - const realName = `${student.firstName || ""} ${student.lastName || ""}`.trim(); + const realName = `${student.firstName} ${student.lastName}`; const fakeName = userMapping[student.id]; try { const newZipBuffer = await replaceAuthorInZip(filePath, realName, fakeName); @@ -312,247 +295,246 @@ module.exports = function (server) { } } - function sanitizeFolderName(value) { - return String(value || "unknown") - .replace(/[<>:"/\\|?*\x00-\x1F]/g, "_") - .replace(/\s+/g, " ") - .trim(); - } - - function flattenObject(value, prefix = "", out = {}) { - if (value === null || value === undefined) return out; - if (typeof value !== "object" || Array.isArray(value)) { - out[prefix] = value; - return out; - } - - for (const [key, child] of Object.entries(value)) { - const nextPrefix = prefix ? `${prefix}.${key}` : key; - flattenObject(child, nextPrefix, out); + /** + * Calculates the version number of a submission by traversing backwards + * through the chain of previous submissions. + * @param {Object} submission - The current submission object to start from. + * @param {Map} submissionMap - A Map containing all related + * submissions for quick lookup by ID. + * @returns {number} - The calculated version number (starting at 1 for the original). + */ + function calculateSubmissionVersion(submission, submissionMap) { + let version = 1; + let currentSub = submission; + while (currentSub && currentSub.previousSubmissionId) { + const prevSub = submissionMap.get(currentSub.previousSubmissionId); + if (!prevSub) break; + version++; + currentSub = prevSub; } - return out; - } - - function getDisplayName(user, shouldGenerateAliases, hasPrivateInfoRight, userMapping) { - if (shouldGenerateAliases) return userMapping[user.id]; - if (hasPrivateInfoRight) return `${user.firstName} ${user.lastName}`.trim(); - return user.userName; + return version; } - async function processGradesExport( - server, - projectId, - userIds, - users, - shouldGenerateAliases, - hasPrivateInfoRight, - userMapping, - gradeFormat, - archive - ) { - const { Op } = server.db.Sequelize; - const gradeRows = await server.db.models.document_data.findAll({ - where: { - key: "assessment_result", - deleted: false, - studySessionId: { [Op.ne]: null } - }, - include: [{ - model: server.db.models.document, - as: "document", - required: true, - where: { - projectId, - userId: { [Op.in]: userIds }, - deleted: false - }, - include: [{ - model: server.db.models.submission, - as: "submission", - required: false - }] - }], - order: [["studySessionId", "ASC"], ["studyStepId", "ASC"], ["createdAt", "ASC"]] + /** + * Exports a single document to the archive based on its type. + * - Type 0 (PDF): exports annotations, comments (with votes), document_data, and the PDF file. + * - Type 1 (HTML) / Type 2 (Modal): exports edits, plain text, HTML, and document_data. + * - Type 4 (ZIP): exports the zip file and document_data. + * @param {Object} server - The server instance providing database models. + * @param {Object} doc - The document record from the database. + * @param {string} docFolder - The target folder path inside the archive. + * @param {Object} archive - The archiver instance to append files to. + * @returns {Promise} + */ + async function processDocumentForExport(server, doc, docFolder, includeNonConsentingEdits, includeNonConsentingAnnotations, docUserRoles, archive) { + // document_data for all types, at the doc level. + const documentData = await server.db.models.document_data.findAll({ + where: { documentId: doc.id, deleted: false }, + raw: true, }); - - const sessionIds = [...new Set(gradeRows.map((row) => row.studySessionId).filter(Boolean))]; - const studySessions = sessionIds.length > 0 - ? await server.db.models.study_session.findAll({ - where: { id: { [Op.in]: sessionIds }, deleted: false }, - raw: true - }) - : []; - const sessionsById = new Map(studySessions.map((session) => [session.id, session])); - - const studyIds = [...new Set(studySessions.map((session) => session.studyId).filter(Boolean))]; - const studies = studyIds.length > 0 - ? await server.db.models.study.findAll({ - where: { id: { [Op.in]: studyIds }, deleted: false }, - raw: true - }) - : []; - const studiesById = new Map(studies.map((study) => [study.id, study])); - - const relatedUserIds = [...new Set([ - ...users.map((user) => user.id), - ...studySessions.map((session) => session.userId), - ...studies.map((study) => study.userId) - ].filter(Boolean))]; - const relatedUsers = relatedUserIds.length > 0 - ? await server.db.models.user.findAll({ where: { id: { [Op.in]: relatedUserIds } }, raw: true }) - : []; - const usersById = new Map(relatedUsers.map((user) => [user.id, user])); - - const roleMatches = relatedUserIds.length > 0 - ? await server.db.models.user_role_matching.findAll({ - where: { userId: { [Op.in]: relatedUserIds } }, - raw: true - }) - : []; - const roleIds = [...new Set(roleMatches.map((match) => match.userRoleId))]; - const roles = roleIds.length > 0 - ? await server.db.models.user_role.findAll({ - where: { id: { [Op.in]: roleIds } }, - attributes: ["id", "name"], - raw: true - }) - : []; - const roleNameById = new Map(roles.map((role) => [role.id, role.name])); - const rolesByUserId = new Map(); - for (const match of roleMatches) { - const roleName = roleNameById.get(match.userRoleId); - if (!roleName) continue; - if (!rolesByUserId.has(match.userId)) rolesByUserId.set(match.userId, []); - rolesByUserId.get(match.userId).push(roleName); + if (documentData.length > 0) { + archive.append(JSON.stringify(documentData, null, 2), { name: `${docFolder}/document_data.json` }); } - const recordsByUser = new Map(); - for (const row of gradeRows) { - const document = row.document; - const ownerUser = usersById.get(document.userId); - if (!ownerUser) continue; - const session = sessionsById.get(row.studySessionId); - const reviewerUser = session ? usersById.get(session.userId) : null; - const study = session ? studiesById.get(session.studyId) : null; - const graderUser = study ? usersById.get(study.userId) : null; - const submission = document.submission; - - const scoreObject = row.value || {}; - const totalPoints = - typeof scoreObject.total === "number" - ? scoreObject.total - : (typeof scoreObject.achieved_points === "number" ? scoreObject.achieved_points : null); - - const record = { - projectId, - userId: ownerUser.id, - userExtId: ownerUser.extId ?? null, - userName: ownerUser.userName ?? "", - displayName: getDisplayName(ownerUser, shouldGenerateAliases, hasPrivateInfoRight, userMapping), - submissionId: submission?.id ?? document.submissionId ?? null, - submissionExtId: submission?.extId ?? null, - studySessionId: row.studySessionId ?? null, - studyStepId: row.studyStepId ?? null, - sessionHash: session?.hash ?? null, - roles: rolesByUserId.get(study?.userId) || [], - grader: graderUser ? `${graderUser.firstName} ${graderUser.lastName}`.trim() : null, - reviewer: reviewerUser ? `${reviewerUser.firstName} ${reviewerUser.lastName}`.trim() : null, - author: ownerUser ? `${ownerUser.firstName} ${ownerUser.lastName}`.trim() : null, - scores: scoreObject, - totalPoints, - createdAt: row.createdAt ? new Date(row.createdAt).toISOString() : null, - sourceKey: "assessment_result" - }; - - if (!recordsByUser.has(ownerUser.id)) recordsByUser.set(ownerUser.id, []); - recordsByUser.get(ownerUser.id).push(record); - } - - const usedFolderNames = new Set(); - const getUniqueHashFolderName = (baseHash, userId, sessionId) => { - const raw = baseHash || `session_${sessionId || "unknown"}_user_${userId}`; - const safeBase = sanitizeFolderName(raw); - let candidate = safeBase; - let suffix = 1; - while (usedFolderNames.has(candidate)) { - candidate = `${safeBase}_${suffix}`; - suffix += 1; - } - usedFolderNames.add(candidate); - return candidate; + const docMeta = { + hash: doc.hash, + type: doc.type, + userId: doc.userId, + userRoles: docUserRoles, }; + archive.append(JSON.stringify(docMeta, null, 2), { name: `${docFolder}/meta.json` }); + + switch (doc.type) { + case 0: { // PDF + // Annotations live on study-session copies (parentDocumentId = doc.id), + // not on the root document. Collect all copy IDs and query across them. + const copies = await server.db.models.document.findAll({ + where: { parentDocumentId: doc.id }, + attributes: ['id'], + raw: true, + }); + const allDocIds = [doc.id, ...copies.map(c => c.id)]; + + let [annotations, comments] = await Promise.all([ + server.db.models.annotation.findAll({ where: { documentId: allDocIds }, raw: true }), + server.db.models.comment.findAll({ where: { documentId: allDocIds }, raw: true }), + ]); + + if (!includeNonConsentingAnnotations) { + const allUserIds = [...new Set([ + ...annotations.map(a => a.userId), + ...comments.map(c => c.userId), + ].filter(Boolean))]; + const annotationUsers = await server.db.models.user.findAll({ + where: { id: allUserIds }, + attributes: ['id', 'acceptDataSharing'], + raw: true, + }); + const consentedUserIds = new Set( + annotationsUsers.filter(u => u.acceptDataSharing).map(u => u.id) + ); + annotations = annotations.filter(a => !a.userId || consentedUserIds.has(a.userId)); + comments = comments.filter(c => !c.userId || consentedUserIds.has(c.userId)); + } - for (const user of users) { - const records = (recordsByUser.get(user.id) || []).sort((a, b) => { - const createdA = a.createdAt ? new Date(a.createdAt).getTime() : 0; - const createdB = b.createdAt ? new Date(b.createdAt).getTime() : 0; - return ( - (a.studySessionId || 0) - (b.studySessionId || 0) || - (a.studyStepId || 0) - (b.studyStepId || 0) || - createdA - createdB - ); - }); + const commentVotes = await server.db.models.comment_vote.findAll({ + where: { commentId: comments.map(c => c.id), deleted: false }, + raw: true, + }); + const commentsWithVotes = comments.map(c => ({ + ...c, + votes: commentVotes.filter(v => v.commentId === c.id), + })); + + // All annotations and comments go into one file each. + if (annotations.length > 0) { + archive.append(JSON.stringify(annotations, null, 2), { name: `${docFolder}/annotations.json` }); + } + if (commentsWithVotes.length > 0) { + archive.append(JSON.stringify(commentsWithVotes, null, 2), { name: `${docFolder}/comments.json` }); + } - const recordsByHash = new Map(); - for (const record of records) { - const hashKey = record.sessionHash || null; - if (!recordsByHash.has(hashKey)) recordsByHash.set(hashKey, []); - recordsByHash.get(hashKey).push(record); + const pdfPath = path.join(storageDir, `${doc.hash}.pdf`); + if (fs.existsSync(pdfPath)) { + archive.file(pdfPath, { name: `${docFolder}/document.pdf` }); + } else { + console.warn(`[DocumentExport] PDF not found for document ${doc.hash}`); + } + break; } - for (const [hashKey, hashRecords] of recordsByHash.entries()) { - const folderName = getUniqueHashFolderName(hashKey, user.id, hashRecords[0]?.studySessionId); - const hashFolder = `grades/${folderName}`; - const exportedRecords = hashRecords.map(({ sessionHash, ...rest }) => rest); - - if (gradeFormat === "csv") { - const csvRows = exportedRecords.map((record) => { - const flatScores = flattenObject(record.scores, "scores"); - return { - projectId: record.projectId, - userId: record.userId, - userExtId: record.userExtId, - userName: record.userName, - displayName: record.displayName, - submissionId: record.submissionId, - submissionExtId: record.submissionExtId, - studySessionId: record.studySessionId, - studyStepId: record.studyStepId, - roles: record.roles.join("|"), - grader: record.grader, - reviewer: record.reviewer, - author: record.author, - totalPoints: record.totalPoints, - createdAt: record.createdAt, - sourceKey: record.sourceKey, - ...flatScores - }; + case 1: // HTML + case 2: { // MODAL + // fetch all edits for this document, ordered chronologically + let allEdits = await server.db.models.document_edit.findAll({ + where: { documentId: doc.id, deleted: false }, + order: [['createdAt', 'ASC']], + raw: true, + }); + + // filter by consent unless the option is enabled + if (!includeNonConsentingEdits) { + const editorUserIds = [...new Set(allEdits.map(e => e.userId).filter(Boolean))]; + const editorUsers = await server.db.models.user.findAll({ + where: { id: editorUserIds }, + attributes: ['id', 'acceptDataSharing'], + raw: true, }); - archive.append(Papa.unparse(csvRows), { name: `${hashFolder}/scores.csv` }); + const consentedUserIds = new Set( + editorUsers.filter(u => u.acceptDataSharing).map(u => u.id) + ); + allEdits = allEdits.filter(e => !e.userId || consentedUserIds.has(e.userId)); + } + + // group edits by studySessionId (null = template) + const sessionGroups = new Map(); + for (const edit of allEdits) { + const key = edit.studySessionId ?? '__template__'; + if (!sessionGroups.has(key)) sessionGroups.set(key, []); + sessionGroups.get(key).push(edit); + } + + // fetch study sessions to resolve hashes + const sessionIds = [...sessionGroups.keys()].filter(k => k !== '__template__'); + const sessions = sessionIds.length > 0 + ? await server.db.models.study_session.findAll({ + where: { id: sessionIds }, + attributes: ['id', 'hash'], + raw: true, + }) + : []; + const sessionHashMap = new Map(sessions.map(s => [s.id, s.hash])); + + for (const [key, edits] of sessionGroups.entries()) { + const isTemplate = key === '__template__'; + const delta = dbToDelta(edits); + + // skip empty content + const text = deltaToPlainText(delta); + if (!text.trim()) continue; + + const subFolder = isTemplate + ? `${docFolder}/template` + : `${docFolder}/${sessionHashMap.get(key) ?? key}`; + + archive.append(text, { name: `${subFolder}/text.txt` }); + archive.append(deltaToHtml(delta), { name: `${subFolder}/html.html` }); + archive.append(JSON.stringify(edits, null, 2), { name: `${subFolder}/edits.json` }); + } + break; + } + + case 4: { // ZIP + const zipPath = path.join(storageDir, `${doc.hash}.zip`); + if (fs.existsSync(zipPath)) { + archive.file(zipPath, { name: `${docFolder}/document.zip` }); } else { - archive.append(JSON.stringify(exportedRecords, null, 2), { name: `${hashFolder}/scores.json` }); + console.warn(`[DocumentExport] ZIP not found for document ${doc.hash}`); } + break; } + + default: + console.warn(`[DocumentExport] Unhandled document type ${doc.type} for document ${doc.hash}, skipping.`); } } /** - * Calculates the version number of a submission by traversing backwards - * through the chain of previous submissions. - * @param {Object} submission - The current submission object to start from. - * @param {Map} submissionMap - A Map containing all related - * submissions for quick lookup by ID. - * @returns {number} - The calculated version number (starting at 1 for the original). + * Main export function for the "documents" export type. + * Fetches all studies and steps for a project, collects unique documents, + * filters by owner data sharing consent, and exports each document to the archive. + * @param {Object} server - The server instance providing database models. + * @param {number|string} projectId - The ID of the project to export. + * @param {string} baseFolderName - The root folder name inside the ZIP archive. + * @param {Object} archive - The archiver instance to append files to. + * @param {Array} userIds - List of user IDs to filter documents by. + * @param {Array} documentTypes - List of document types to include (0=PDF, 1=HTML, 2=Modal, 4=ZIP). + * @returns {Promise} */ - function calculateSubmissionVersion(submission, submissionMap) { - let version = 1; - let currentSub = submission; - while (currentSub && currentSub.previousSubmissionId) { - const prevSub = submissionMap.get(currentSub.previousSubmissionId); - if (!prevSub) break; - version++; - currentSub = prevSub; + async function processDocumentBasedExport(server, projectId, userIds, documentTypes, includeNonConsentingEdits, includeNonConsentingAnnotations, baseFolderName, archive) { + try { + documentTypes = typeof documentTypes === 'string' ? JSON.parse(documentTypes) : documentTypes; + if (!Array.isArray(documentTypes)) documentTypes = [0, 1, 2, 4]; + } catch (e) { + console.warn("Could not parse documentTypes:", documentTypes); + documentTypes = [0, 1, 2, 4]; + } + + const docs = await server.db.models.document.findAll({ + where: { projectId, userId: userIds, deleted: false, parentDocumentId: null }, + }); + + if (docs.length === 0) { + console.warn(`[DocumentExport] No documents found for project ${projectId}`); + return; + } + + const filteredDocs = docs.filter(doc => + documentTypes.includes(doc.type) || documentTypes.includes(String(doc.type)) + ); + + if (filteredDocs.length === 0) { + console.warn(`[DocumentExport] No documents matching selected types found for project ${projectId}`); + return; + } + + const uniqueUserIds = [...new Set(filteredDocs.map(doc => doc.userId).filter(Boolean))]; + + const userRoleRows = await server.db.models.user_role_matching.findAll({ + where: { userId: uniqueUserIds }, + raw: true, + }); + + const rolesMap = {}; + for (const row of userRoleRows) { + if (!rolesMap[row.userId]) rolesMap[row.userId] = []; + rolesMap[row.userId].push(row.userRoleId); + } + + for (const doc of filteredDocs) { + const docFolder = `${baseFolderName}/${doc.hash}`; + const docUserRoles = rolesMap[doc.userId] || []; + await processDocumentForExport(server, doc, docFolder, includeNonConsentingEdits, includeNonConsentingAnnotations, docUserRoles, archive); } - return version; } }; \ No newline at end of file diff --git a/backend/webserver/sockets/app.js b/backend/webserver/sockets/app.js index 2873b8945..a1daac733 100644 --- a/backend/webserver/sockets/app.js +++ b/backend/webserver/sockets/app.js @@ -66,10 +66,8 @@ class AppSocket extends Socket { const transaction = options.transaction; let newEntry = null; - - if (("id" in data.data && data.data.id !== 0) && - ('deleted' in data.data || 'closed' in data.data || 'public' in data.data || 'end' in data.data || 'disable' in data.data)) { + ('deleted' in data.data || 'closed' in data.data || 'public' in data.data || 'end' in data.data)) { newEntry = await this.models[data.table].updateById( data.data.id, data.data, @@ -78,8 +76,8 @@ class AppSocket extends Socket { transaction: transaction } ); - // if the entry is destroyed then it wont return an id - return newEntry?.id; + + return newEntry.id; } // check or set user information diff --git a/backend/webserver/sockets/document.js b/backend/webserver/sockets/document.js index aad2947ad..8e57863cf 100644 --- a/backend/webserver/sockets/document.js +++ b/backend/webserver/sockets/document.js @@ -11,7 +11,6 @@ const Validator = require("../../utils/validator.js"); const {Op} = require('sequelize'); const {applyTemplateToDocument} = require("../../utils/documentTemplateHelper.js"); const {generateError} = require("../../utils/generic.js"); -const {getEmailContent} = require("../../utils/emailHelper.js"); const UPLOAD_PATH = `${__dirname}/../../../files`; @@ -193,7 +192,6 @@ class DocumentSocket extends Socket { userId: data.userId ?? this.userId, uploadedByUserId: this.userId, readyForReview: data.isUploaded ?? false, - projectId: data.projectId, submissionId: data.submissionId }, {transaction: options.transaction} @@ -861,15 +859,6 @@ class DocumentSocket extends Socket { const downloadedSubmissions = []; const downloadedErrors = []; const submissions = data.submissions || []; - const assignmentId = data.assignmentId || null; - // Validate assignment once before the loop (if provided) - let assignment = null; - if (assignmentId) { - assignment = await this.models["assignment"].getById(assignmentId, {}); - if (!assignment) { - throw new Error(`Assignment with id ${assignmentId} not found`); - } - } for (const submission of submissions) { // Create a new transaction for each submission @@ -881,56 +870,23 @@ class DocumentSocket extends Socket { tempFiles = await this.validator.downloadFilesToTemp(submission.files, data.options); // 2. Validate files - const validationResult = await this.validator.validateSubmissionFiles(tempFiles, data.validationConfigurationId? data.validationConfigurationId : (assignment ? assignment.validationConfigurationId : null)); + const validationResult = await this.validator.validateSubmissionFiles(tempFiles, data.validationConfigurationId); if (!validationResult.success) { throw new Error(validationResult.message || "Validation failed"); } - - // 3. Determine previousSubmissionId - let previousSubmissionId = null; - if (assignmentId) { - const assignmentSubmissions = await this.models["submission"].findAll({ - where: { assignmentId, userId: submission.userId, deleted: false }, - raw: true, - transaction, - }); - - // Check revision limit (0 = unlimited) - if (assignment && assignment.maxRevisions > 0 && assignmentSubmissions.length >= assignment.maxRevisions) { - throw new Error(`Revision limit reached: user ${submission.userId} already has ${assignmentSubmissions.length} submission(s) for this assignment (max: ${assignment.maxRevisions}).`); - } - - const childByParentId = new Map(); - for (const s of assignmentSubmissions) { - if (s.previousSubmissionId) { - childByParentId.set(s.previousSubmissionId, s.id); - } - } - - const parentIds = new Set(assignmentSubmissions.filter((s) => s.previousSubmissionId).map((s) => s.previousSubmissionId)); - const chainTails = assignmentSubmissions.filter((s) => !parentIds.has(s.id)).map((s) => s.id); - - if (chainTails.length > 0) { - previousSubmissionId = chainTails.sort((a, b) => b - a)[0]; - } - } else { - const previousSubmission = await this.models["submission"].getParentSubmission(submission.userId, submission.projectId, true, {transaction}); - previousSubmissionId = previousSubmission ? previousSubmission.id : null; - } - + // 3. Get previous submission for the user and project to link the new submission (if exists) + const previousSubmission = await this.models["submission"].getParentSubmission(submission.userId, submission.projectId, true, {transaction}); // 4. Only if validation passes, create submission and save documents const submissionEntry = await this.models["submission"].add( { userId: submission.userId, createdByUserId: this.userId, extId: submission.submissionId, - previousSubmissionId, + previousSubmissionId: previousSubmission ? previousSubmission.id : null, projectId: submission.projectId, - assignmentId: assignmentId || null, - name: submission.name ?? null, - description: submission.description ?? null, - validationConfigurationId: assignment ? assignment.validationConfigurationId : (data.validationConfigurationId || null), + group: data.group, + validationConfigurationId: data.validationConfigurationId, }, {transaction} ); @@ -944,7 +900,6 @@ class DocumentSocket extends Socket { userId: submission.userId, isUploaded: true, submissionId: submissionEntry.id, - projectId: submissionEntry.projectId }, {transaction} ); @@ -982,92 +937,6 @@ class DocumentSocket extends Socket { return {downloadedSubmissions, downloadedErrors}; } - /** - * Send submission upload/reupload notification emails to assignment owner and submitter. - * - * @author Mohammad Elwan - * @param {Object} data - The input data for sending the notification - * @param {number} data.assignmentId - Assignment ID linked to the submission - * @param {number} data.submissionId - Submission ID that was created/replaced - * @param {number} data.submitterUserId - User ID of the person who uploaded - * @param {string} data.eventType - Upload event type ('first_upload' or 'reupload') - * @returns {Promise} - */ - async sendSubmissionUploadEmail(data) { - const {assignmentId, submissionId, submitterUserId, eventType} = data; - const assignment = await this.models["assignment"].getById(assignmentId); - if (!assignment) { - this.server.logger.warn(`Cannot send submission upload email: assignment ${assignmentId} not found`); - return; - } - - if (assignment.notifyOnSubmissionUpload === false) { - return; - } - - const submission = await this.models["submission"].getById(submissionId); - const eventLabel = eventType === "reupload" ? "Reuploaded" : "Uploaded"; - const eventLabelLower = eventType === "reupload" ? "reuploaded" : "uploaded"; - const emailContext = { - assignmentName: assignment.name, - assignmentId: assignment.id, - submissionId: submission?.id ?? submissionId, - eventType: eventLabelLower, - eventLabel, - eventLabelLower, - timestamp: submission?.createdAt - ? new Date(submission.createdAt).toLocaleString("en-GB", { - day: "numeric", - month: "long", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - }) - : "", - }; - - const owner = await this.models["user"].getById(assignment.userId); - if (!owner || !owner.email) { - this.server.logger.warn(`Cannot send submission upload email: assignment owner ${assignment.userId} has no email`); - } else { - const ownerEmailContent = await getEmailContent( - "email.template.submissionUpload", - "submissionUpload", - { - userId: assignment.userId, - ...emailContext, - }, - this.models, - this.logger - ); - - await this.server.sendMail(owner.email, ownerEmailContent.subject, ownerEmailContent.body, {isHtml: ownerEmailContent.isHtml}); - } - - if (!submitterUserId || submitterUserId === assignment.userId) { - return; - } - - const submitter = await this.models["user"].getById(submitterUserId); - if (!submitter || !submitter.email) { - this.server.logger.warn(`Cannot send submission upload confirmation email: submitter ${submitterUserId} has no email`); - return; - } - - const submitterEmailContent = await getEmailContent( - "email.template.submissionUploadConfirmation", - "submissionUploadConfirmation", - { - userId: submitterUserId, - ...emailContext, - }, - this.models, - this.logger - ); - - await this.server.sendMail(submitter.email, submitterEmailContent.subject, submitterEmailContent.body, {isHtml: submitterEmailContent.isHtml}); - } - /** * Upload a single submission to the DB. * @@ -1077,15 +946,13 @@ class DocumentSocket extends Socket { * @param {Array} data.files - The submissions files * @param {number} data.group - The group number to be assigned to the submissions * @param {number} data.validationConfigurationId - Configuration ID referring to the validation schema - * @param {string|null} [data.name] - Optional submission name. - * @param {string|null} [data.description] - Optional submission description. * @param {Object} options - Additional configuration parameters * @param {Object} options.transaction - Sequelize DB transaction options * @returns {Promise>} - The result of the processed submission * @throws {Error} - If the upload fails, or if saving to server fails */ async uploadSingleSubmission(data, options) { - const {files, userId, group, validationConfigurationId, projectId, assignmentId, submissionId, name, description} = data; + const {files, userId, group, validationConfigurationId, projectId} = data; const transaction = options.transaction; try { const result = await this.validator.validateSubmissionFiles(files, validationConfigurationId); @@ -1093,101 +960,14 @@ class DocumentSocket extends Socket { if (!result.success) { throw new Error(result.message || "Validation failed"); } - - if (assignmentId && submissionId) { - return await this.replaceAssignmentSubmission( - { - files, - userId, - group, - validationConfigurationId, - projectId, - assignmentId, - submissionId, - name, - description, - }, - {transaction} - ); - } - - let previousSubmissionId = null; - let resolvedProjectId = projectId; - - if (assignmentId) { - const assignment = await this.models["assignment"].getById(assignmentId, {transaction}); - if (!assignment) { - throw new Error(`Assignment with id ${assignmentId} not found`); - } - resolvedProjectId = resolvedProjectId ?? assignment.projectId ?? null; - - const assignmentSubmissions = await this.models["submission"].findAll({ - where: { - assignmentId, - userId, - deleted: false, - }, - raw: true, - transaction, - }); - - const submissionById = new Map(assignmentSubmissions.map((submission) => [submission.id, submission])); - const childByParentId = new Map(); - for (const submission of assignmentSubmissions) { - if (submission.previousSubmissionId) { - childByParentId.set(submission.previousSubmissionId, submission.id); - } - } - - const parentIds = new Set(); - for (const submission of assignmentSubmissions) { - if (submission.previousSubmissionId) { - parentIds.add(submission.previousSubmissionId); - } - } - - const chainTails = assignmentSubmissions - .filter((submission) => !parentIds.has(submission.id)) - .map((submission) => submission.id); - - if (chainTails.length > 0) { - previousSubmissionId = chainTails.sort((a, b) => b - a)[0]; - } - - if (assignment.maxRevisions !== null && assignment.maxRevisions !== undefined && previousSubmissionId) { - let chainDepth = 0; - let currentId = previousSubmissionId; - const visited = new Set(); - - while (currentId && submissionById.has(currentId) && !visited.has(currentId)) { - visited.add(currentId); - chainDepth += 1; - currentId = submissionById.get(currentId).previousSubmissionId; - } - - if (chainDepth >= assignment.maxRevisions) { - throw new Error( - `Maximum revisions reached for this assignment (${chainDepth}/${assignment.maxRevisions})` - ); - } - } - } else { - const previousSubmission = await this.models["submission"].getParentSubmission(userId, resolvedProjectId, true, {transaction}); - previousSubmissionId = previousSubmission ? previousSubmission.id : null; - } - - + const previousSubmission = await this.models["submission"].getParentSubmission(userId, projectId, true, {transaction}); const submission = await this.models["submission"].add({ userId, group, validationConfigurationId, createdByUserId: this.userId, - previousSubmissionId, - projectId: resolvedProjectId, - assignmentId: assignmentId || null, - name: name ?? null, - description: description ?? null, + previousSubmissionId: previousSubmission ? previousSubmission.id : null, }, {transaction}); for (const file of files) { await this.addDocument( @@ -1197,167 +977,16 @@ class DocumentSocket extends Socket { userId: userId, isUploaded: true, submissionId: submission.id, - projectId: resolvedProjectId, }, {transaction} ); } - - if (assignmentId) { - transaction.afterCommit(async () => { - try { - await this.sendSubmissionUploadEmail({ - assignmentId, - submissionId: submission.id, - submitterUserId: userId, - eventType: "first_upload", - }); - } catch (emailError) { - this.server.logger.error("Failed to send submission upload email:", emailError); - } - }); - } } catch (error) { this.logger.error(error); throw new Error(error); } } - /** - * Replace an existing assignment submission by creating a new one, - * deleting the old one, and reconnecting submission chain pointers. - * - * @param {Object} data - The input data for the replacement - * @param {Array} data.files - The new submission files to upload - * @param {number} data.userId - The ID of the user who owns the submission - * @param {number} data.group - The group number to be assigned to the submission - * @param {number} data.validationConfigurationId - Configuration ID referring to the validation schema - * @param {number} data.assignmentId - The ID of the assignment the submission belongs to - * @param {number} data.submissionId - The ID of the existing submission to replace - * @param {string|null} [data.name] - Optional submission name; falls back to the old submission's name - * @param {string|null} [data.description] - Optional submission description; falls back to the old submission's description - * @param {Object} options - Additional configuration parameters - * @param {Object} options.transaction - Sequelize DB transaction options - * @returns {Promise} An object containing replacedSubmissionId and newSubmissionId - * @throws {Error} If the assignment or submission is not found, the user lacks permission, or a linked document is used in a study - */ - async replaceAssignmentSubmission(data, options) { - const {files, userId, group, validationConfigurationId, projectId, assignmentId, submissionId, name, description} = data; - const transaction = options.transaction; - - const assignment = await this.models["assignment"].getById(assignmentId, {transaction}); - if (!assignment) { - throw new Error(`Assignment with id ${assignmentId} not found`); - } - - if (assignment.closed) { - throw new Error("Cannot replace submission because the assignment is closed."); - } - - const oldSubmission = await this.models["submission"].findOne({ - where: { - id: submissionId, - assignmentId, - userId, - deleted: false, - }, - raw: true, - transaction, - }); - - if (!oldSubmission) { - throw new Error(`Submission with id ${submissionId} not found for this assignment`); - } - const resolvedProjectId = projectId ?? oldSubmission.projectId ?? assignment.projectId ?? null; - - const isOwner = this.userId === oldSubmission.userId; - const hasRight = await this.hasAccess('frontend.dashboard.assignments.replaceDeleteSubmissions'); - if (!isOwner && !hasRight) { - throw new Error("You are not allowed to replace this submission."); - } - - const oldSubmissionDocuments = await this.models["document"].findAll({ - where: { - submissionId: oldSubmission.id, - deleted: false, - }, - raw: true, - transaction, - }); - const hasStudyLinkedDocument = oldSubmissionDocuments.some( - (document) => Number(document.studyUsageCount || 0) > 0 - ); - if (hasStudyLinkedDocument) { - throw new Error("Cannot replace submission because one or more linked documents are used in studies."); - } - - const newSubmission = await this.models["submission"].add({ - userId, - group: group ?? oldSubmission.group, - validationConfigurationId, - createdByUserId: this.userId, - previousSubmissionId: oldSubmission.previousSubmissionId || null, - projectId: resolvedProjectId, - assignmentId, - name: name ?? oldSubmission.name ?? null, - description: description ?? oldSubmission.description ?? null, - }, {transaction}); - - // Reconnect revisions that pointed to the replaced submission. - const childRevision = await this.models["submission"].findOne({ - where: { - previousSubmissionId: oldSubmission.id, - assignmentId, - userId, - deleted: false, - }, - raw: true, - transaction, - }); - - if (childRevision) { - await this.models["submission"].updateById( - childRevision.id, - { previousSubmissionId: newSubmission.id }, - { transaction } - ); - } - - await this.models["submission"].deleteById(oldSubmission.id, { force: true, transaction, individualHooks: true }); - - for (const file of files) { - await this.addDocument( - { - file: file.content, - name: file.fileName, - userId, - isUploaded: true, - submissionId: newSubmission.id, - projectId: resolvedProjectId, - }, - {transaction} - ); - } - - transaction.afterCommit(async () => { - try { - await this.sendSubmissionUploadEmail({ - assignmentId: assignment.id, - submissionId: newSubmission.id, - submitterUserId: userId, - eventType: "reupload", - }); - } catch (emailError) { - this.server.logger.error("Failed to send submission reupload email:", emailError); - } - }); - - return { - replacedSubmissionId: oldSubmission.id, - newSubmissionId: newSubmission.id, - }; - } - /** * Send a document to the client * @@ -1645,4 +1274,4 @@ class DocumentSocket extends Socket { } }; -module.exports = DocumentSocket; +module.exports = DocumentSocket; \ No newline at end of file diff --git a/backend/webserver/sockets/submission.js b/backend/webserver/sockets/submission.js index 57ae2ba06..9f22888fd 100644 --- a/backend/webserver/sockets/submission.js +++ b/backend/webserver/sockets/submission.js @@ -58,34 +58,16 @@ class SubmissionSocket extends Socket { * @returns {Promise} A promise that resolves when the update is complete * @throws {Error} If the user is not allowed to update the document */ - async deleteSubmission(data, options) { - const { id, force = false } = data; - const transaction = options.transaction; - - const submission = await this.models['submission'].getById(id, { transaction }); - if (!submission) { - throw new Error("Submission not found."); - } + async updateSubmission(data, options) { + const submission = await this.models['submission'].getById(data['id']); if (!(await this.checkUserAccess(submission.userId))) { - throw new Error("You are not allowed to delete this submission."); + throw new Error("You are not allowed to update this submission."); } - const assignment = await this.models['assignment'].getById(submission.assignmentId, { transaction }); - if (assignment && assignment.closed) { - throw new Error("Cannot delete submission because the assignment is closed."); - } - - const documents = await this.models['document'].findAll({ - where: { submissionId: id, deleted: false }, - raw: true, - transaction, + const newSubmission = await this.models['submission'].updateById(submission.id, data); + options.transaction.afterCommit(async () => { + this.emit("submissionRefresh", await this.updateCreatorName(newSubmission)); }); - const isStudyLocked = documents.some(doc => Number(doc.studyUsageCount || 0) > 0); - if (isStudyLocked) { - throw new Error("Cannot delete submission because one or more linked documents are used in studies."); - } - - return await this.models['submission'].deleteById(id, { force, transaction }); } /** @@ -110,7 +92,7 @@ class SubmissionSocket extends Socket { init() { this.createSocket("submissionAssignGroup", this.assignGroupToSubmissions, {}, true); - this.createSocket("submissionDelete", this.deleteSubmission, {}, true); + this.createSocket("submissionUpdate", this.updateSubmission, {}, true); this.createSocket("submissionPublishGrades", this.publishGrades, {}, false); } } diff --git a/backend/webserver/sockets/template.js b/backend/webserver/sockets/template.js index e1dec7493..95cd35ae5 100644 --- a/backend/webserver/sockets/template.js +++ b/backend/webserver/sockets/template.js @@ -33,7 +33,7 @@ class TemplateSocket extends Socket { if (!data.name || !data.description || data.type === undefined || data.content === undefined) { throw new Error("Missing required fields: name, description, type, content"); } - if (!(await this.isAdmin()) && [1, 2, 3, 6, 7].includes(data.type)) { + if (!(await this.isAdmin()) && [1, 2, 3, 6].includes(data.type)) { throw new Error("Access denied: Only administrators can create email templates"); } @@ -195,8 +195,8 @@ class TemplateSocket extends Socket { */ async addPlaceholder(data, options) { if (!(await this.isAdmin())) throw new Error("Access denied"); - if (!data.templateType || ![1, 2, 3, 4, 5, 6, 7].includes(data.templateType)) { - throw new Error("Template type is required and must be 1-7"); + if (!data.templateType || ![1, 2, 3, 4, 5, 6].includes(data.templateType)) { + throw new Error("Template type is required and must be 1-6"); } if (!data.placeholderKey || !data.placeholderLabel || !data.placeholderType) { throw new Error("Missing required fields: placeholderKey, placeholderLabel, placeholderType"); @@ -232,7 +232,7 @@ class TemplateSocket extends Socket { async updatePlaceholder(data, options) { if (!(await this.isAdmin())) throw new Error("Access denied"); if (!data.id) throw new Error("Placeholder ID is required"); - + const updateData = {}; if (data.placeholderLabel !== undefined) updateData.placeholderLabel = data.placeholderLabel; if (data.placeholderType !== undefined) updateData.placeholderType = data.placeholderType; @@ -243,9 +243,9 @@ class TemplateSocket extends Socket { } return await this.models["placeholder"].updateById( - data.id, - updateData, - { transaction: options.transaction } + data.id, + updateData, + { transaction: options.transaction } ); } @@ -455,7 +455,7 @@ class TemplateSocket extends Socket { }); if (edits.length === 0) { - if ([1, 2, 3, 6, 7].includes(template.type)) { + if ([1, 2, 3, 6].includes(template.type)) { const templateContentModel = this.models["template_content"]; const langRow = await templateContentModel.findOne({ where: { templateId, language, deleted: false }, @@ -496,8 +496,8 @@ class TemplateSocket extends Socket { const editsDelta = new Delta(dbToDelta(edits)); const mergedDelta = baseContent.compose(editsDelta); - // Email templates (types 1, 2, 3, 6, 7) must include all required placeholders - if ([1, 2, 3, 6, 7].includes(template.type)) { + // Email templates (types 1, 2, 3, 6) must include all required placeholders + if ([1, 2, 3, 6].includes(template.type)) { const missing = await getMissingRequiredPlaceholders( { ops: mergedDelta.ops }, template.type, @@ -609,7 +609,7 @@ class TemplateSocket extends Socket { if (!data.sourceTemplateId) throw new Error("Source template ID is required"); const source = await this.models["template"].getById(data.sourceTemplateId); - if (!(await this.isAdmin()) && [1, 2, 3, 6, 7].includes(source?.type)) { + if (!(await this.isAdmin()) && [1, 2, 3, 6].includes(source?.type)) { throw new Error("Access denied: Only administrators can copy email templates"); } @@ -678,11 +678,11 @@ class TemplateSocket extends Socket { throw new Error("You can only delete templates that you own"); } - if (template.public && [1, 2, 3, 6, 7].includes(template.type)) { + if (template.public && [1, 2, 3, 6].includes(template.type)) { throw new Error("Public email templates cannot be deleted"); } - if ([1, 2, 3, 6, 7].includes(template.type)) { + if ([1, 2, 3, 6].includes(template.type)) { const usedBySettings = await this.models["setting"].findAll({ where: { key: {[Op.like]: "email.template.%"}, diff --git a/docs/source/for_developers/before_you_start.rst b/docs/source/for_developers/before_you_start.rst index 200abec73..710b8d9f5 100644 --- a/docs/source/for_developers/before_you_start.rst +++ b/docs/source/for_developers/before_you_start.rst @@ -137,8 +137,6 @@ a database in a docker container and populates it with the necessary schemas. ``make dev`` runs with ``DEV_SKIP_WIZARD=true`` for faster iterative development. If you need to test the first-time setup flow, use ``make dev-wizard``. - For backend-only runs, ``make dev-backend`` and ``make dev-backend-watch`` also skip the wizard, - while ``make dev-backend-wizard`` keeps wizard flow enabled. .. note:: @@ -148,9 +146,7 @@ a database in a docker container and populates it with the necessary schemas. .. warning:: The ``make dev`` command only works on Linux systems. - On Windows, you need to start the frontend and backend separately. - Use ``make dev-frontend`` + ``make dev-backend`` for wizard-skipped backend runs, or - ``make dev-frontend`` + ``make dev-backend-wizard`` to test the first-time setup wizard flow. + On Windows, you need to start the frontend and backend separately with ``make dev-frontend`` and ``make dev-backend``. Frontend ~~~~~~~~~~~~ @@ -200,13 +196,6 @@ After that, the backend can be started with: make dev-backend To enable auto-restart of the backend when server files change, use ``make dev-backend-watch`` (runs ``nodemon``). -Both commands run with ``DEV_SKIP_WIZARD=true``. - -If you want backend-only development with wizard enabled, use: - -.. code-block:: bash - - make dev-backend-wizard To shorten things, both commands can also be executed with ``make dev-build`` at once. @@ -250,11 +239,9 @@ More Commands * - ``make dev-wizard`` - Run frontend (dev) and backend (dev) together, with setup wizard enabled. (Unix only) * - ``make dev-backend`` - - Run backend in development mode with setup wizard skipped. - * - ``make dev-backend-wizard`` - - Run backend in development mode with setup wizard enabled. + - Run backend in development mode. * - ``make dev-backend-watch`` - - Run backend in development mode with nodemon (auto-restart on file changes), setup wizard skipped. + - Run backend in development mode with nodemon (auto-restart on file changes). * - ``make dev-frontend`` - Run frontend in development mode. * - ``make dev-build`` diff --git a/files/email-fallbacks/submissionUpload.txt b/files/email-fallbacks/submissionUpload.txt deleted file mode 100644 index 12e239e64..000000000 --- a/files/email-fallbacks/submissionUpload.txt +++ /dev/null @@ -1,13 +0,0 @@ -CARE - Submission {{eventLabel}} - -Hello, - -An assignment submission has been {{eventLabelLower}}. - -Assignment: {{assignmentName}} -Assignment ID: {{assignmentId}} -Submission ID: {{submissionId}} -Uploaded at: {{timestamp}} - -Best regards, -The CARE Team diff --git a/files/email-fallbacks/submissionUploadConfirmation.txt b/files/email-fallbacks/submissionUploadConfirmation.txt deleted file mode 100644 index f5cf1e46f..000000000 --- a/files/email-fallbacks/submissionUploadConfirmation.txt +++ /dev/null @@ -1,13 +0,0 @@ -CARE - Submission {{eventLabel}} confirmation - -Hello, - -Your assignment submission has been {{eventLabelLower}} successfully. - -Assignment: {{assignmentName}} -Assignment ID: {{assignmentId}} -Submission ID: {{submissionId}} -Uploaded at: {{timestamp}} - -Best regards, -The CARE Team diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f4f651f5e..2742d60c9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -114,6 +114,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1461,6 +1462,7 @@ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" @@ -1908,6 +1910,7 @@ "integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -2277,6 +2280,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2532,6 +2536,7 @@ } ], "license": "MIT", + "peer": true, "peerDependencies": { "@popperjs/core": "^2.11.8" } @@ -2614,6 +2619,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3635,6 +3641,7 @@ "integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -5784,6 +5791,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -6417,6 +6425,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -6955,6 +6964,7 @@ "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -7289,6 +7299,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -7428,6 +7439,7 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/compiler-sfc": "3.5.30", @@ -7466,6 +7478,7 @@ "integrity": "sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0 || ^9.0.0", @@ -7936,6 +7949,7 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/frontend/src/auth/SetupWizard.vue b/frontend/src/auth/SetupWizard.vue index ff853987f..952d98742 100644 --- a/frontend/src/auth/SetupWizard.vue +++ b/frontend/src/auth/SetupWizard.vue @@ -251,7 +251,7 @@ export default { return typeof p === "string" && p.length >= 8 && !/^\s*$/.test(p) - && ![...p].some((c) => (c.codePointAt(0) || 0) < 32 || (c.codePointAt(0) || 0) === 127) + && !/[\x00-\x1F\x7F]/.test(p) && ![...p].some((c) => (c.codePointAt(0) || 0) > 0xFFFF); }, adminFormValid() { diff --git a/frontend/src/basic/dashboard/card/Card.vue b/frontend/src/basic/dashboard/card/Card.vue index 97c554c34..7729dc61e 100644 --- a/frontend/src/basic/dashboard/card/Card.vue +++ b/frontend/src/basic/dashboard/card/Card.vue @@ -8,19 +8,13 @@
{{ title }}
-
-
- - -
+
+
diff --git a/frontend/src/basic/editor/Modal.vue b/frontend/src/basic/editor/Modal.vue index 13df66260..242bd8252 100644 --- a/frontend/src/basic/editor/Modal.vue +++ b/frontend/src/basic/editor/Modal.vue @@ -7,10 +7,8 @@ diff --git a/frontend/src/basic/form/Slider.vue b/frontend/src/basic/form/Slider.vue index 4fe4b1665..a8105845d 100644 --- a/frontend/src/basic/form/Slider.vue +++ b/frontend/src/basic/form/Slider.vue @@ -45,55 +45,28 @@ export default { } }, computed: { - unlimitedStoredValue() { - return Number(this.options.unlimitedStoredValue ?? 0); - }, - hasUnlimitedAtMax() { - return Boolean(this.options.unlimitedAtMax); - }, - isAtUnlimitedPosition() { - return this.hasUnlimitedAtMax && Number(this.currentData) === Number(this.options.max); - }, - emittedValue() { - if (this.isAtUnlimitedPosition) { - return this.unlimitedStoredValue; - } - return Number(this.currentData); - }, - displayValue() { - if (this.isAtUnlimitedPosition) { - return this.options.unlimitedLabel || "unlimited"; - } - return Number(this.currentData); - }, - normalizedModelValue() { - if (this.hasUnlimitedAtMax && Number(this.modelValue) === this.unlimitedStoredValue) { - return Number(this.options.max); - } - return Number(this.modelValue); - }, displayText() { if (this.options.textMapping && Array.isArray(this.options.textMapping)) { const mapping = this.options.textMapping.find( - (item) => item.from === this.displayValue + (item) => item.from === Number(this.currentData) ); if (mapping) { return mapping.to; } } - return this.displayValue; + return this.currentData; }, }, watch: { currentData() { - this.$emit("update:modelValue", this.emittedValue); + this.$emit("update:modelValue", Number(this.currentData)); }, modelValue() { - this.currentData = this.normalizedModelValue; + this.currentData = this.modelValue; }, }, beforeMount() { - this.currentData = this.normalizedModelValue; + this.currentData = this.modelValue; }, } diff --git a/frontend/src/components/dashboard/Assignments.vue b/frontend/src/components/dashboard/Assignments.vue deleted file mode 100644 index 854e26222..000000000 --- a/frontend/src/components/dashboard/Assignments.vue +++ /dev/null @@ -1,485 +0,0 @@ - - - diff --git a/frontend/src/components/dashboard/Study.vue b/frontend/src/components/dashboard/Study.vue index d2beddde3..7925801db 100644 --- a/frontend/src/components/dashboard/Study.vue +++ b/frontend/src/components/dashboard/Study.vue @@ -17,13 +17,6 @@ text="Close All Studies" icon="x-octagon" @click="closeStudies" - /> - - diff --git a/frontend/src/components/dashboard/Submissions.vue b/frontend/src/components/dashboard/Submissions.vue index cb78618bb..b9e6b7231 100644 --- a/frontend/src/components/dashboard/Submissions.vue +++ b/frontend/src/components/dashboard/Submissions.vue @@ -1,224 +1,431 @@ + + diff --git a/frontend/src/components/dashboard/Templates.vue b/frontend/src/components/dashboard/Templates.vue index b4ace81e7..36216452d 100644 --- a/frontend/src/components/dashboard/Templates.vue +++ b/frontend/src/components/dashboard/Templates.vue @@ -98,8 +98,8 @@ return { ...t, typeName: this.typeName(t.type), - // Public email templates (types 1, 2, 3, 6, 7) cannot be deleted - canDelete: !(t.public && [1, 2, 3, 6, 7].includes(t.type)), + // Public email templates (types 1, 2, 3, 6) cannot be deleted + canDelete: !(t.public && [1, 2, 3, 6].includes(t.type)), isCopy, hasUpdate, sourceStatus, @@ -251,7 +251,6 @@ case 4: return "Document - General"; case 5: return "Document - Study"; case 6: return "Email - Study Close"; - case 7: return "Email - Submission upload"; default: return "Choose Type" } }, diff --git a/frontend/src/components/dashboard/Users.vue b/frontend/src/components/dashboard/Users.vue index 3aa906253..eaba82197 100644 --- a/frontend/src/components/dashboard/Users.vue +++ b/frontend/src/components/dashboard/Users.vue @@ -24,20 +24,6 @@ icon="shield-lock" @click="openRightsManagementModal" /> - - - - + - - - - - - - - - - diff --git a/frontend/src/components/dashboard/assignments/AssignmentSubmissionsModal.vue b/frontend/src/components/dashboard/assignments/AssignmentSubmissionsModal.vue deleted file mode 100644 index 1936c6211..000000000 --- a/frontend/src/components/dashboard/assignments/AssignmentSubmissionsModal.vue +++ /dev/null @@ -1,197 +0,0 @@ - - - diff --git a/frontend/src/components/dashboard/assignments/AssignmentSubmissionsTable.vue b/frontend/src/components/dashboard/assignments/AssignmentSubmissionsTable.vue deleted file mode 100644 index 114f22c2d..000000000 --- a/frontend/src/components/dashboard/assignments/AssignmentSubmissionsTable.vue +++ /dev/null @@ -1,352 +0,0 @@ - - - diff --git a/frontend/src/components/dashboard/assignments/AssignmentUploadModal.vue b/frontend/src/components/dashboard/assignments/AssignmentUploadModal.vue deleted file mode 100644 index bb458c774..000000000 --- a/frontend/src/components/dashboard/assignments/AssignmentUploadModal.vue +++ /dev/null @@ -1,333 +0,0 @@ - - - - - diff --git a/frontend/src/components/dashboard/projects/ExportModal.vue b/frontend/src/components/dashboard/projects/ExportModal.vue index 1834feff2..1a71e57b7 100644 --- a/frontend/src/components/dashboard/projects/ExportModal.vue +++ b/frontend/src/components/dashboard/projects/ExportModal.vue @@ -18,24 +18,59 @@ :fields="dataSelectionFields" /> + + + - @@ -86,8 +127,10 @@ import JSZip from 'jszip'; import FileSaver from 'file-saver'; import Quill from "quill"; import {dbToDelta} from "editor-delta-conversion"; -import StepSelectStudents from "@/components/dashboard/projects/export/StepSelectStudents.vue"; -import StepOptions from "@/components/dashboard/projects/export/StepOptions.vue"; +import BasicLoading from "@/basic/Loading.vue"; +import StepSelectUsers from "@/components/dashboard/projects/export/StepSelectUsers.vue"; +import StepOptionsSubmissions from "@/components/dashboard/projects/export/StepOptionsSubmissions.vue"; +import StepOptionsDocuments from "@/components/dashboard/projects/export/StepOptionsDocuments.vue"; import StepConfirmDownload from "@/components/dashboard/projects/export/StepConfirmDownload.vue"; import getServerURL from "@/assets/serverUrl.js"; @@ -95,11 +138,11 @@ import getServerURL from "@/assets/serverUrl.js"; /** * ProjectModal - modal component for adding and editing projects * - * @author Dennis Zyska, Mélissa Loew, Linyin Huang + * @author Dennis Zyska, Mélissa Loew */ export default { name: "ExportProjectModal", - components: { StepperModal, BasicForm, StepSelectStudents, StepOptions, StepConfirmDownload }, + components: { BasicLoading, StepperModal, BasicForm, StepSelectUsers, StepOptionsSubmissions, StepOptionsDocuments, StepConfirmDownload }, subscribeTable: [{ table: "document", }, { @@ -134,21 +177,30 @@ export default { filter: [], wait: false, // Data for Export Submissions - submissionSelection: [], + userSelection: [], generateAliases:false, fakerSeed: 846569412, - gradeFormat: "json" + selectedDocumentTypes: [0, 1, 2, 4], + excludeNonConsentingEdits: false, + excludeNonConsentingAnnotations: false }; }, computed: { stepValid() { - if (["submissions", "grades"].includes(this.dataSelection.exportType)) { + if (this.dataSelection.exportType === "submissions") { return [ !!this.dataSelection.projectId && !!this.dataSelection.exportType, // must select a valid project and export type - this.submissionSelection.length > 0, // must select at least one student + this.userSelection.length > 0, // must select at least one student true, true ]; + } else if (this.dataSelection.exportType === "documents") { + return [ + !!this.dataSelection.projectId && !!this.dataSelection.exportType, + this.userSelection.length > 0, + this.selectedDocumentTypes.length > 0, + true, + ]; } return [ !!this.dataSelection.projectId && !!this.dataSelection.exportType, @@ -156,10 +208,17 @@ export default { ]; }, steps() { - if (["submissions", "grades"].includes(this.dataSelection.exportType)) { + if (this.dataSelection.exportType === 'submissions') { + return [ + { title: "Settings" }, + { title: "Select Users" }, + { title: "Options" }, + { title: "Confirm Download" } + ]; + } else if (this.dataSelection.exportType === 'documents') { return [ { title: "Settings" }, - { title: "Select Students" }, + { title: "Select Users" }, { title: "Options" }, { title: "Confirm Download" } ]; @@ -188,7 +247,7 @@ export default { options: [ {name: "Export a list of all reviewers", value: "reviewerList"}, {name: "Export submissions", value: "submissions"}, - {name: "Export grades", value: "grades"}, + {name: "Export documents", value:"documents"}, {name: "All", value: "all"}, ], required: true, @@ -254,14 +313,21 @@ export default { }, hide() { this.filter = []; + this.userSelection = []; + this.generateAliases = false; + this.fakerSeed = 846569412; + this.selectedDocumentTypes = [0, 1, 2, 4]; + this.excludeNonConsentingEdits = false; + this.excludeNonConsentingAnnotations = false; + this.wait = false; }, downloadData() { if (this.dataSelection.exportType === "reviewerList") { this.downloadReviewerList(); } else if (this.dataSelection.exportType === "submissions") { this.downloadSubmissions(); - } else if (this.dataSelection.exportType === "grades") { - this.downloadGrades(); + } else if (this.dataSelection.exportType === "documents") { + this.downloadDocuments(); } else { this.downloadAllData(); } @@ -325,7 +391,7 @@ export default { async downloadSubmissions() { try { // get the selected student's user ids - const selectedUserIds = this.submissionSelection.map(row => row.userId); + const selectedUserIds = this.userSelection.map(row => row.userId); // call helper function to trigger the stream download this.triggerStreamDownload({ @@ -342,21 +408,22 @@ export default { this.$toast.error("An error occurred starting the stream. Please try again."); } }, - async downloadGrades() { + async downloadDocuments() { try { - const selectedUserIds = this.submissionSelection.map(row => row.userId); + const selectedUserIds = this.userSelection.map(row => row.userId); this.triggerStreamDownload({ projectId: this.dataSelection.projectId, - exportType: 'grades', + exportType: 'documents', userIds: selectedUserIds, - generateAliases: this.generateAliases, - fakerSeed: this.generateAliases ? this.fakerSeed : null, - gradeFormat: this.gradeFormat + documentTypes: this.selectedDocumentTypes, + includeNonConsentingEdits: !this.excludeNonConsentingEdits, + includeNonConsentingAnnotations: !this.excludeNonConsentingAnnotations }); + this.$refs.exportStepper.close(); } catch (error) { - console.error("Streaming error:", error); - this.$toast.error("An error occurred starting the stream. Please try again."); + console.error("Streaming error:", error); + this.$toast.error("An error occurred starting the stream. Please try again."); } }, async downloadAllData() { diff --git a/frontend/src/components/dashboard/projects/export/StepConfirmDownload.vue b/frontend/src/components/dashboard/projects/export/StepConfirmDownload.vue index 8afd4df92..66ec915f5 100644 --- a/frontend/src/components/dashboard/projects/export/StepConfirmDownload.vue +++ b/frontend/src/components/dashboard/projects/export/StepConfirmDownload.vue @@ -6,7 +6,7 @@
Confirm Selection:
- +
You have selected one or more students who didn't accept data sharing.
@@ -21,14 +21,16 @@
Summary:
- You are about to download submissions for - {{ submissionSelection.length }} student(s). + You are about to download + submissions + documents + for {{ userSelection.length }} users(s).
    -
  • - {{ row.studentName || row.userName }} ({{ row.fileCount }} files) +
  • + {{ row.studentName || row.userName }} ({{ row.count }} {{ exportType === 'submissions' ? 'submission(s)' : 'document(s)' }})
@@ -44,13 +46,12 @@ import BasicLoading from "@/basic/Loading.vue"; * * The final confirmation step within the ExportModal. * This component provides a summary of the selected - * submissions intended for download, as well as some + * data intended for download, as well as some * warnings for the user, if they selected generate aliases * or students who didn't accept data sharing. * * @author Mélissa Loew */ - export default { name: "StepConfirmDownload", components: { BasicLoading }, @@ -63,14 +64,18 @@ export default { type: Boolean, default: false }, - submissionSelection: { + userSelection: { type: Array, required: true + }, + exportType: { + type: String, + default: 'submissions' } }, computed: { hasDeclinedSharingSelected() { - return this.submissionSelection.some(row => row.acceptDataSharing === 'No'); + return this.userSelection.some(row => row.acceptDataSharing === 'No'); } } } diff --git a/frontend/src/components/dashboard/projects/export/StepOptionsDocuments.vue b/frontend/src/components/dashboard/projects/export/StepOptionsDocuments.vue new file mode 100644 index 000000000..fd60bfb1e --- /dev/null +++ b/frontend/src/components/dashboard/projects/export/StepOptionsDocuments.vue @@ -0,0 +1,109 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/dashboard/projects/export/StepOptions.vue b/frontend/src/components/dashboard/projects/export/StepOptionsSubmissions.vue similarity index 66% rename from frontend/src/components/dashboard/projects/export/StepOptions.vue rename to frontend/src/components/dashboard/projects/export/StepOptionsSubmissions.vue index f86688a87..c1bfaa213 100644 --- a/frontend/src/components/dashboard/projects/export/StepOptions.vue +++ b/frontend/src/components/dashboard/projects/export/StepOptionsSubmissions.vue @@ -7,9 +7,9 @@
- -
- - -
\ No newline at end of file diff --git a/frontend/src/components/dashboard/projects/export/StepSelectUsers.vue b/frontend/src/components/dashboard/projects/export/StepSelectUsers.vue new file mode 100644 index 000000000..beab219d3 --- /dev/null +++ b/frontend/src/components/dashboard/projects/export/StepSelectUsers.vue @@ -0,0 +1,169 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/dashboard/settings/SettingItem.vue b/frontend/src/components/dashboard/settings/SettingItem.vue index adff2bbbe..e29ce7737 100644 --- a/frontend/src/components/dashboard/settings/SettingItem.vue +++ b/frontend/src/components/dashboard/settings/SettingItem.vue @@ -132,7 +132,7 @@ export default { emailTemplates() { // Show only the user's own templates (copies count, since copies have userId === currentUser). return this.$store.getters["table/template/getAll"] - .filter(t => !t.deleted && [1, 2, 3, 6, 7].includes(t.type) && t.userId === this.user?.id) + .filter(t => !t.deleted && [1, 2, 3, 6].includes(t.type) && t.userId === this.user?.id) .map(t => ({ id: t.id, name: t.name, type: t.type })); }, isEmailTemplateSetting() { @@ -149,7 +149,6 @@ export default { if (["email.template.sessionStart", "email.template.sessionFinish"].includes(key)) return 2; if (key === "email.template.assignment") return 3; if (key === "email.template.studyClosed") return 6; - if (["email.template.submissionUpload", "email.template.submissionUploadConfirmation"].includes(key)) return 7; return null; }, filteredEmailTemplates() { @@ -175,6 +174,6 @@ export default { this.$emit("update:value", normalized); } }, - } + }, }; diff --git a/frontend/src/components/dashboard/settings/SettingsSection.vue b/frontend/src/components/dashboard/settings/SettingsSection.vue index ac51800e1..6d88f687f 100644 --- a/frontend/src/components/dashboard/settings/SettingsSection.vue +++ b/frontend/src/components/dashboard/settings/SettingsSection.vue @@ -67,11 +67,6 @@ export default { isCollapsed: true, }; }, - computed: { - visibleSubsections() { - return this.subsections; - }, - }, watch: { collapsed: { immediate: true, @@ -80,6 +75,11 @@ export default { }, }, }, + computed: { + visibleSubsections() { + return this.subsections; + }, + }, methods: { toggleCollapse() { this.isCollapsed = !this.isCollapsed; diff --git a/frontend/src/components/dashboard/submission/ImportModal.vue b/frontend/src/components/dashboard/submission/ImportModal.vue index d48260a9a..83c3a4aff 100644 --- a/frontend/src/components/dashboard/submission/ImportModal.vue +++ b/frontend/src/components/dashboard/submission/ImportModal.vue @@ -28,8 +28,29 @@ :max-table-height="400" /> - + + + -