diff --git a/Makefile b/Makefile index c61a41e53..1f21c0840 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" @@ -100,16 +99,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 fc27d7984..b0a80f7f0 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 2fd368d1b..5e465b10f 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 fd9e65863..8a1bd332c 100644 --- a/backend/db/models/template.js +++ b/backend/db/models/template.js @@ -25,7 +25,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]}}]}, @@ -67,7 +67,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"); @@ -150,10 +150,6 @@ module.exports = (sequelize, DataTypes) => { name: "Email - Study Close", value: 6 }, - { - name: "Email - Submission upload", - value: 7 - }, { name: "Document - General", value: 4 @@ -490,7 +486,7 @@ module.exports = (sequelize, DataTypes) => { 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..8fb387f76 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,6 +12,7 @@ "@node-saml/passport-saml": "^5.1.0", "archiver": "^7.0.1", "argparse": "^2.0.1", + "assessment-score": "file:../utils/modules/assessment-score", "axios": "^1.13.6", "axios-oauth-client": "^2.2.0", "bcrypt": "^6.0.0", @@ -56,18 +57,28 @@ }, "devDependencies": { "cross-env": "^10.1.0", - "jest": "^30.2.0" + "jest": "^30.2.0", + "nodemon": "^3.1.10" }, "engines": { "node": ">=18.0.0" } }, + "../utils/modules/assessment-score": { + "version": "1.0.0", + "license": "Apache-2.0", + "devDependencies": { + "cross-env": "^7.0.3", + "jest": "^30.3.0" + } + }, "../utils/modules/editor-delta-conversion": { "version": "1.0.0", "license": "Apache-2.0", "dependencies": { "quill": "2.0.2", - "quill-delta": "^5.1.0" + "quill-delta": "^5.1.0", + "quill-delta-to-html": "^0.12.1" }, "devDependencies": { "cross-env": "^7.0.3", @@ -105,6 +116,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", @@ -2099,6 +2111,10 @@ "node": ">=0.8" } }, + "node_modules/assessment-score": { + "resolved": "../utils/modules/assessment-score", + "link": true + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -2134,6 +2150,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 +2311,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 +2469,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 +2527,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 +2560,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2706,6 +2751,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 +3747,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 +3919,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 +4227,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 +4386,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 +4486,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 +4520,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 +4549,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 +5942,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 +6458,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 +6499,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 +6752,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 +6880,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 +7081,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@types/debug": "^4.1.8", "@types/validator": "^13.7.17", @@ -7213,6 +7501,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 +8120,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 +8148,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 +8251,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 +8479,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/helper/email.js b/backend/utils/helper/email.js index f98eaa435..bf2d54749 100644 --- a/backend/utils/helper/email.js +++ b/backend/utils/helper/email.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/helper/export.js b/backend/utils/helper/export.js new file mode 100644 index 000000000..c03bfd883 --- /dev/null +++ b/backend/utils/helper/export.js @@ -0,0 +1,479 @@ +const fs = require('fs'); +const { faker } = require('@faker-js/faker'); +const JSZip = require('jszip'); +const { deriveUserSeed } = require('../../webserver/auth/utils'); +const path = require('path'); +const storageDir = path.join(__dirname, "..", "..", "..", "files"); +const Papa = require('papaparse'); + +const SUPPORTED_EXPORT_TYPES = new Set(["submissions", "grades", "documents"]); + + +/** + * Validates an export request and loads the project/users it targets. + * @param {Object} server - The server instance providing database models and Sequelize operators. + * @param {Object} params - Validation inputs. + * @param {number} params.parsedProjectId - The numeric project id. + * @param {string} params.exportType - The requested export type. + * @param {string} params.normalizedGradeFormat - The requested grade format, lowercased. + * @param {Array} params.userIds - Parsed user ids to export. + * @returns {Promise<{success: boolean, status?: number, message?: string, users?: Array}>} + */ +async function loadExportRequestContext(server, { parsedProjectId, exportType, normalizedGradeFormat, userIds }) { + if (!Number.isInteger(parsedProjectId)) { + return { success: false, status: 400, message: "Missing projectId." }; + } + if (!SUPPORTED_EXPORT_TYPES.has(exportType)) { + return { success: false, status: 400, message: "Unsupported export type." }; + } + if (exportType === "grades" && !["json", "csv"].includes(normalizedGradeFormat)) { + return { success: false, status: 400, message: "Unsupported grade format. Use json or csv." }; + } + if (userIds.length === 0) { + console.warn("Export aborted: No valid users selected."); + return { success: false, status: 400, message: "No valid users selected." }; + } + + const project = await server.db.models.project.findOne({ where: { id: parsedProjectId } }); + if (!project) { + console.warn(`${parsedProjectId} does not exist.`); + return { success: false, status: 403, message: "The selected project does not exist." }; + } + + const { Op } = server.db.Sequelize; + 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."); + return { success: false, status: 400, message: "No authorized users to export." }; + } + + return { success: true, users }; +} + +/** + * Opens a zip file, replaces the student's real name with a fake name in all .tex files, + * and returns the modified zip as a Buffer. + * @param {string} filePath - Path to the original zip file on disk + * @param {string} realName - The student's real name to search for + * @param {string} fakeName - The generated fake name to insert + * @returns {Promise} - The newly generated zip file buffer + */ +async function replaceAuthorInZip(filePath, realName, fakeName) { + const fileData = fs.readFileSync(filePath); + const zip = await JSZip.loadAsync(fileData); + const getFirstAndLastNameTokens = (name) => { + const parts = String(name || "").trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return ["", ""]; + if (parts.length === 1) return [parts[0], ""]; + return [parts[0], parts[parts.length - 1]]; + }; + const [realFirstName, realLastName] = getFirstAndLastNameTokens(realName); + const [fakeFirstName, fakeLastName] = getFirstAndLastNameTokens(fakeName); + + const authorRegex = /\\author\s*\{[^}]*\}/g; + + for (const [relativePath, zipEntry] of Object.entries(zip.files)) { + 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); + + zip.file(relativePath, text); + } + } + + return await zip.generateAsync({ + type: "nodebuffer", + compression: "DEFLATE" + }); +} + +/** + * Constructs a mapping of user IDs to aliases and generates a + * corresponding CSV string. + * @param {Array} users - Array of user objects from the database. + * @param {boolean} shouldGenerateAliases - Whether the export should use fake names. + * @param {boolean} hasPrivateInfoRight - Whether the current user is allowed to see/export full names. + * @param {number|string} fakerSeed - The base integer seed (from the form input). + * @param {string} salt - The hex-encoded salt string from the user's database record. + * @returns {Object} An object containing: + * - userMapping: An object mapping user IDs to their generated fake names. + * - mappingCsv: A CSV-formatted string containing the mapping (conditionally includes real names). + */ +function buildUserMapping(users, shouldGenerateAliases, hasPrivateInfoRight, fakerSeed, salt) { + let userMapping = {}; + let csvRows = []; + + if (shouldGenerateAliases) { + if (fakerSeed && !isNaN(parseInt(fakerSeed, 10))) { + const derivedFakerSeed = deriveUserSeed(parseInt(fakerSeed, 10), salt); + faker.seed(derivedFakerSeed); + } + + const sortedUsers = [...users].sort((a, b) => Number(a.id) - Number(b.id)); + sortedUsers.forEach(u => { + const realUsername = u.userName; + const realName = `${u.firstName} ${u.lastName}`; + const fakeName = `${faker.person.firstName()} ${faker.person.lastName()}`; + + userMapping[u.id] = fakeName; + + let rowData = { + "Username": realUsername + }; + if (hasPrivateInfoRight) { + rowData["Real Name"] = realName; + } + + rowData["Generated Alias"] = fakeName; + + csvRows.push(rowData); + }); + } + const mappingCsv = csvRows.length > 0 ? Papa.unparse(csvRows) : ""; + return { userMapping, mappingCsv }; +} + +/** + * Normalizes a folder name so it can be used as a ZIP path segment without + * accidentally introducing invalid filename characters or nested paths. + * + * @param {string|number|null|undefined} value - The raw folder name. + * @returns {string} A sanitized folder name with reserved characters replaced. + */ +function sanitizeFolderName(value) { + return String(value || "unknown") + .replace(/[<>:"/\\|?*\x00-\x1F]/g, "_") + .replace(/\s+/g, " ") + .trim(); +} + +/** + * Returns a user's display name based on private info permissions. + * + * @param {Object|null} user - The user record. + * @param {boolean} hasPrivateInfoRight - Whether real names are allowed. + * @returns {string|null} Full name or username depending on permissions. + */ +function getPrivateAwareName(user, hasPrivateInfoRight) { + if (!user) return null; + if (hasPrivateInfoRight) return `${user.firstName} ${user.lastName}`.trim(); + // Usernames are considered anonymous-enough for exports when real names are restricted. + return user.userName ?? null; +} + +/** + * Resolves the display name for a user based on the current export settings. + * This wraps getPrivateAwareName with alias support for anonymized exports. + * + * @param {Object} user - The user record to display. + * @param {boolean} shouldGenerateAliases - Whether aliases should replace real names. + * @param {boolean} hasPrivateInfoRight - Whether the current user may export real names. + * @param {Object} userMapping - Map of user IDs to generated aliases. + * @returns {string} The display name to write into the export. + */ +function getDisplayName(user, shouldGenerateAliases, hasPrivateInfoRight, userMapping) { + if (shouldGenerateAliases) return userMapping[user.id]; + return getPrivateAwareName(user, hasPrivateInfoRight); +} + +/** + * 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 version; +} + +/** + * Parses an assessment state payload when it is stored as JSON text. + * + * @param {string} rawAssessmentState - The raw JSON string from document_data. + * @returns {Object} The parsed assessment state or an empty object on failure. + */ +function parseAssessmentState(rawAssessmentState) { + try { + const parsed = JSON.parse(rawAssessmentState); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch (error) { + console.warn("Failed to parse assessment state:", error.message); + return {}; + } +} + +/** + * Reads the rubric configuration id from a study step configuration payload. + * + * @param {Object|null|undefined} studyStepConfiguration - The study step's configuration object. + * @returns {number|null} The referenced configuration id or null when unavailable. + */ +function getAssessmentConfigurationId(studyStepConfiguration) { + if (!studyStepConfiguration || typeof studyStepConfiguration !== "object") return null; + const rawId = + studyStepConfiguration.settings?.configurationId ?? + studyStepConfiguration.configurationId ?? + null; + const parsedId = Number(rawId); + return Number.isInteger(parsedId) ? parsedId : null; +} + +/** + * Resolves the assessment rubric configuration referenced by a study step. + * Study steps are expected to store only a configurationId; rubric content + * is loaded from the configuration table. + * + * @param {Object|null|undefined} studyStepConfiguration - The study step's configuration JSON. + * @param {Map} configurationsById - Loaded configuration records by id. + * @returns {Object|null} Assessment config content (with rubrics) or null. + */ +function resolveAssessmentConfigurationContent(studyStepConfiguration, configurationsById) { + const configurationId = getAssessmentConfigurationId(studyStepConfiguration); + if (configurationId === null) return null; + + const configuration = configurationsById.get(configurationId); + return configuration?.content ?? null; +} + +/** + * Records the assessment configuration content for a given configuration id, + * so each distinct configuration used across a grade export gets its own + * criteria reference file (a grade export may now span multiple configurations). + * + * @param {Map} referencesByConfigId - Mutable map of configurationId -> reference content. + * @param {number|null} configurationId - Resolved persisted configuration id. + * @param {Object|null} assessmentConfig - Resolved assessment configuration content. + * @returns {void} + */ +function addCriteriaReferenceEntry(referencesByConfigId, configurationId, assessmentConfig) { + if (!assessmentConfig || typeof assessmentConfig !== "object") return; + if (!Number.isInteger(configurationId)) return; + if (referencesByConfigId.has(configurationId)) return; + + referencesByConfigId.set(configurationId, { + configurationId, + ...assessmentConfig + }); +} + +/** + * Builds a flat CSV row for a grade export record. + * The row contains backend export metadata columns followed by + * one column per assessment criterion score. + * + * @param {Object} record - Prepared grade export record. + * @returns {Object} A flat object suitable for Papa.unparse. + */ +function buildGradeCsvRow(record) { + const criterionScores = record.scores && typeof record.scores === "object" ? record.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, + studyName: record.studyName, + studyStepId: record.studyStepId, + studyStepType: record.studyStepType, + configurationId: record.configurationId, + studyOwner: record.studyOwner, + sessionOwner: record.sessionOwner, + author: record.author, + totalPoints: record.totalPoints, + createdAt: record.createdAt, + ...criterionScores + }; +} + +/** + * Loads the related entities needed to turn raw assessment_result rows into + * export-ready grade records. + * + * @param {Object} server - The server instance with Sequelize models. + * @param {Array} gradeRows - Assessment result rows with attached documents. + * @param {Array} users - The selected document owners for the export. + * @returns {Promise} Lookup maps for related grade-export entities. + */ +async function loadGradeExportContext(server, gradeRows, users) { + const { Op } = server.db.Sequelize; + + 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 studyStepIds = [...new Set(gradeRows.map((row) => row.studyStepId).filter(Boolean))]; + const studySteps = studyStepIds.length > 0 + ? await server.db.models.study_step.findAll({ + where: { id: { [Op.in]: studyStepIds }, deleted: false }, + raw: true + }) + : []; + const studyStepsById = new Map(studySteps.map((studyStep) => [studyStep.id, studyStep])); + + const configurationIds = [...new Set( + studySteps + .map((studyStep) => getAssessmentConfigurationId(studyStep.configuration)) + .filter((id) => id !== null) + )]; + const configurations = configurationIds.length > 0 + ? await server.db.models.configuration.findAll({ + where: { id: { [Op.in]: configurationIds }, deleted: false }, + raw: true + }) + : []; + const configurationsById = new Map(configurations.map((configuration) => [configuration.id, configuration])); + + // The export references study/session owners in addition to the selected document owners. + 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])); + + return { + sessionsById, + studiesById, + studyStepsById, + configurationsById, + usersById + }; +} + +/** + * Resolves which of the given user ids have opted into data sharing. + * + * @param {Object} server - The server instance providing database models. + * @param {Array} candidateUserIds - User ids to check consent for. + * @returns {Promise>} Set of user ids that accepted data sharing. + */ +async function getConsentedUserIds(server, candidateUserIds) { + if (candidateUserIds.length === 0) return new Set(); + const consentedUsers = await server.db.models.user.findAll({ + where: { id: candidateUserIds }, + attributes: ['id', 'acceptDataSharing'], + raw: true, + }); + return new Set(consentedUsers.filter(u => u.acceptDataSharing).map(u => u.id)); +} + +/** + * Orders grade records by session, then step within the session, then creation time. + * @param {Object} a - First grade record to compare. + * @param {Object} b - Second grade record to compare. + * @returns {number} Standard comparator result for Array#sort. + */ +function compareGradeRecords(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 + ); +} + +/** + * Appends a stored file (by hash + extension) to the archive if it exists on disk, otherwise warns. + * @param {Object} archive - The archiver instance to append the file to. + * @param {string} hash - The document's storage hash. + * @param {string} extension - File extension including the dot, e.g. ".pdf". + * @param {string} archivePath - Destination path inside the ZIP archive. + * @param {string} typeLabel - Human-readable type label used in the warning log. + * @returns {void} + */ +function appendStoredFileIfExists(archive, hash, extension, archivePath, typeLabel) { + const filePath = path.join(storageDir, `${hash}${extension}`); + if (fs.existsSync(filePath)) { + archive.file(filePath, { name: archivePath }); + } else { + console.warn(`[DocumentExport] ${typeLabel} not found for document ${hash}`); + } +} + +/** + * Resolves whether a user may see other users' full names in exports (admins always can). + * @param {Object} server - The server instance providing database models. + * @param {number} userId - The requesting user's id. + * @returns {Promise} Whether the user has the private-info export right. + */ +async function resolveHasPrivateInfoRight(server, userId) { + const roleIds = await server.db.models["user_role_matching"].getUserRolesById(userId); + const isAdmin = await server.db.models["user_role_matching"].isAdminInUserRoles(roleIds); + if (isAdmin) return true; + + const userRightsObj = await server.db.models.user.getUserRights(userId); + if (!userRightsObj) return false; + + const allRights = Object.values(userRightsObj).flat(); + return allRights.includes('frontend.dashboard.studies.view.userPrivateInfo'); +} + +/** + * Parses the raw userIds field from a request body into an array, tolerating a JSON-encoded string. + * @param {*} rawUserIds - The raw value from req.body.userIds. + * @returns {Array} Parsed array of user ids, or an empty array if parsing fails. + */ +function parseUserIds(rawUserIds) { + try { + const parsed = typeof rawUserIds === 'string' ? JSON.parse(rawUserIds) : rawUserIds; + return Array.isArray(parsed) ? parsed : []; + } catch (e) { + console.warn("Could not parse userIds:", rawUserIds); + return []; + } +} + +module.exports = { + replaceAuthorInZip, + buildUserMapping, + sanitizeFolderName, + getPrivateAwareName, + getDisplayName, + calculateSubmissionVersion, + parseAssessmentState, + getAssessmentConfigurationId, + resolveAssessmentConfigurationContent, + addCriteriaReferenceEntry, + buildGradeCsvRow, + loadGradeExportContext, + getConsentedUserIds, + compareGradeRecords, + appendStoredFileIfExists, + resolveHasPrivateInfoRight, + parseUserIds, + loadExportRequestContext, + SUPPORTED_EXPORT_TYPES, +}; \ No newline at end of file diff --git a/backend/utils/helper/templateResolver.js b/backend/utils/helper/templateResolver.js index f01afeedf..577d095de 100644 --- a/backend/utils/helper/templateResolver.js +++ b/backend/utils/helper/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 2b1eab34e..6bc23cf8c 100644 --- a/backend/webserver/Socket.js +++ b/backend/webserver/Socket.js @@ -486,34 +486,29 @@ 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 { // --- Public rows: always visible regardless of ownership or access rights --- if ('public' in model.getAttributes()) { @@ -529,7 +524,8 @@ module.exports = class Socket { // getUserFilter returns {} → grants full row access (e.g. for admins) fullRowAccess = true; } - } 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}; } @@ -727,6 +723,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 35dd32376..1788b1549 100644 --- a/backend/webserver/routes/export.js +++ b/backend/webserver/routes/export.js @@ -1,12 +1,31 @@ const archiver = require('archiver'); const path = require('path'); const fs = require('fs'); -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 { calculateAssessmentScore, buildScoresFromState } = require('assessment-score'); - +const { + replaceAuthorInZip, + buildUserMapping, + sanitizeFolderName, + getPrivateAwareName, + getDisplayName, + calculateSubmissionVersion, + parseAssessmentState, + getAssessmentConfigurationId, + resolveAssessmentConfigurationContent, + addCriteriaReferenceEntry, + buildGradeCsvRow, + loadGradeExportContext, + getConsentedUserIds, + compareGradeRecords, + appendStoredFileIfExists, + resolveHasPrivateInfoRight, + parseUserIds, + loadExportRequestContext, + SUPPORTED_EXPORT_TYPES, +} = require('../../utils/helper/export.js'); +const storageDir = path.join(__dirname, "..", "..", "..", "files"); const ASSESSMENT_RESULT_KEY = "assessment_result"; module.exports = function (server) { @@ -16,70 +35,28 @@ module.exports = function (server) { // Auth checking const currentUserId = req.user?.id; if (!currentUserId) return res.status(401).send("Log in required"); - const currentUser = await server.db.models.user.findByPk(currentUserId); if (!currentUser) return res.status(401).send("User not found"); + const hasPrivateInfoRight = await resolveHasPrivateInfoRight(server, currentUserId); - // check if user has right to see full names - let hasPrivateInfoRight = false; - - const roleIds = await server.db.models["user_role_matching"].getUserRolesById(currentUserId); - const isAdmin = await server.db.models["user_role_matching"].isAdminInUserRoles(roleIds); - if (isAdmin) { - // override, admin has all rights - hasPrivateInfoRight = true; - } else { - const userRightsObj = await server.db.models.user.getUserRights(currentUserId); - - if (userRightsObj) { - const allRights = Object.values(userRightsObj).flat(); - hasPrivateInfoRight = allRights.includes('frontend.dashboard.studies.view.userPrivateInfo'); - } - } // Input parsing - const { projectId, exportType, generateAliases, fakerSeed, gradeFormat, mergeCsvFiles } = req.body; - let { userIds = [] } = req.body; + const { projectId, exportType, generateAliases, fakerSeed, gradeFormat, mergeCsvFiles, includeNonConsentingEdits, includeNonConsentingAnnotations } = req.body; + let { userIds: rawUserIds = [], documentTypes = [0, 1, 2, 4] } = req.body; const shouldGenerateAliases = String(generateAliases) === 'true'; const shouldMergeCsvFiles = String(mergeCsvFiles) === "true"; + const shouldIncludeNonConsentingEdits = String(includeNonConsentingEdits) === 'true'; + const shouldIncludeNonConsentingAnnotations = String(includeNonConsentingAnnotations) === 'true'; const normalizedGradeFormat = String(gradeFormat || "json").toLowerCase(); - const supportedExportTypes = new Set(["submissions", "grades"]); - const { Op } = server.db.Sequelize; const parsedProjectId = Number(projectId); + const userIds = parseUserIds(rawUserIds); try { - userIds = typeof userIds === 'string' ? JSON.parse(userIds) : userIds; - if (!Array.isArray(userIds)) userIds = []; - } catch (e) { - console.warn("Could not parse userIds:", userIds); - userIds = []; - } - - try { - if (!Number.isInteger(parsedProjectId)) 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: parsedProjectId } }); - if (!projectCheck) { - console.warn(`${parsedProjectId} does not exist.`); - 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."); - return res.status(400).send("No authorized users to export."); + const context = await loadExportRequestContext(server, { parsedProjectId, exportType, normalizedGradeFormat, userIds}); + if (!context.success) { + return res.status(context.status).send(context.message); } + const { users } = context; // build user mapping for aliases const { userMapping, mappingCsv } = buildUserMapping(users, shouldGenerateAliases, hasPrivateInfoRight, fakerSeed, currentUser.salt); @@ -128,6 +105,18 @@ module.exports = function (server) { archive ); break; + case 'documents': + await processDocumentBasedExport( + server, + parsedProjectId, + userIds, + documentTypes, + shouldIncludeNonConsentingEdits, + shouldIncludeNonConsentingAnnotations, + exportFolderName.split('.')[0], + archive + ); + break; default: return res.status(400).send("Unsupported export type."); } @@ -141,93 +130,6 @@ module.exports = function (server) { } }); - // HELPER FUNCTIONS - - /** - * Opens a zip file, replaces the student's real name with a fake name in all .tex files, - * and returns the modified zip as a Buffer. - * @param {string} filePath - Path to the original zip file on disk - * @param {string} realName - The student's real name to search for - * @param {string} fakeName - The generated fake name to insert - * @returns {Promise} - The newly generated zip file buffer - */ - async function replaceAuthorInZip(filePath, realName, fakeName) { - const fileData = fs.readFileSync(filePath); - const zip = await JSZip.loadAsync(fileData); - const getFirstAndLastNameTokens = (name) => { - const parts = String(name || "").trim().split(/\s+/).filter(Boolean); - if (parts.length === 0) return ["", ""]; - if (parts.length === 1) return [parts[0], ""]; - return [parts[0], parts[parts.length - 1]]; - }; - const [realFirstName, realLastName] = getFirstAndLastNameTokens(realName); - const [fakeFirstName, fakeLastName] = getFirstAndLastNameTokens(fakeName); - - const authorRegex = /\\author\s*\{[^}]*\}/g; - - for (const [relativePath, zipEntry] of Object.entries(zip.files)) { - 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); - - zip.file(relativePath, text); - } - } - - return await zip.generateAsync({ - type: "nodebuffer", - compression: "DEFLATE" - }); - } - - /** - * Constructs a mapping of user IDs to aliases and generates a - * corresponding CSV string. - * @param {Array} users - Array of user objects from the database. - * @param {boolean} shouldGenerateAliases - Whether the export should use fake names. - * @param {boolean} hasPrivateInfoRight - Whether the current user is allowed to see/export full names. - * @param {number|string} fakerSeed - The base integer seed (from the form input). - * @param {string} salt - The hex-encoded salt string from the user's database record. - * @returns {Object} An object containing: - * - userMapping: An object mapping user IDs to their generated fake names. - * - mappingCsv: A CSV-formatted string containing the mapping (conditionally includes real names). - */ - function buildUserMapping(users, shouldGenerateAliases, hasPrivateInfoRight, fakerSeed, salt) { - let userMapping = {}; - let csvRows = []; - - if (shouldGenerateAliases) { - if (fakerSeed && !isNaN(parseInt(fakerSeed, 10))) { - const derivedFakerSeed = deriveUserSeed(parseInt(fakerSeed, 10), salt); - faker.seed(derivedFakerSeed); - } - - const sortedUsers = [...users].sort((a, b) => Number(a.id) - Number(b.id)); - sortedUsers.forEach(u => { - const realUsername = u.userName; - const realName = `${u.firstName} ${u.lastName}`; - const fakeName = `${faker.person.firstName()} ${faker.person.lastName()}`; - - userMapping[u.id] = fakeName; - - let rowData = { - "Username": realUsername - }; - if (hasPrivateInfoRight) { - rowData["Real Name"] = realName; - } - - rowData["Generated Alias"] = fakeName; - - csvRows.push(rowData); - }); - } - const mappingCsv = csvRows.length > 0 ? Papa.unparse(csvRows) : ""; - return { userMapping, mappingCsv }; - } - /** * Does the fetching, filtering, and archiving of student submissions for a specific project. * Handles file renaming based on validation rules and manages directory structures @@ -274,7 +176,6 @@ module.exports = function (server) { 1: ".html", 4: ".zip" }; - const storageDir = path.join(__dirname, "..", "..", "..", "files"); for (const submission of submissions) { const student = usersById.get(submission.userId); @@ -325,235 +226,6 @@ module.exports = function (server) { } } - /** - * Normalizes a folder name so it can be used as a ZIP path segment without - * accidentally introducing invalid filename characters or nested paths. - * - * @param {string|number|null|undefined} value - The raw folder name. - * @returns {string} A sanitized folder name with reserved characters replaced. - */ - function sanitizeFolderName(value) { - return String(value || "unknown") - .replace(/[<>:"/\\|?*\x00-\x1F]/g, "_") - .replace(/\s+/g, " ") - .trim(); - } - - /** - * Parses an assessment state payload when it is stored as JSON text. - * - * @param {string} rawAssessmentState - The raw JSON string from document_data. - * @returns {Object} The parsed assessment state or an empty object on failure. - */ - function parseAssessmentState(rawAssessmentState) { - try { - const parsed = JSON.parse(rawAssessmentState); - return parsed && typeof parsed === "object" ? parsed : {}; - } catch (error) { - console.warn("Failed to parse assessment state:", error.message); - return {}; - } - } - - /** - * Resolves the assessment rubric configuration referenced by a study step. - * Study steps are expected to store only a configurationId; rubric content - * is loaded from the configuration table. - * - * @param {Object|null|undefined} studyStepConfiguration - The study step's configuration JSON. - * @param {Map} configurationsById - Loaded configuration records by id. - * @returns {Object|null} Assessment config content (with rubrics) or null. - */ - function resolveAssessmentConfigurationContent(studyStepConfiguration, configurationsById) { - const configurationId = getAssessmentConfigurationId(studyStepConfiguration); - if (configurationId === null) return null; - - const configuration = configurationsById.get(configurationId); - return configuration?.content ?? null; - } - - /** - * Reads the rubric configuration id from a study step configuration payload. - * - * @param {Object|null|undefined} studyStepConfiguration - The study step's configuration object. - * @returns {number|null} The referenced configuration id or null when unavailable. - */ - function getAssessmentConfigurationId(studyStepConfiguration) { - if (!studyStepConfiguration || typeof studyStepConfiguration !== "object") return null; - const rawId = - studyStepConfiguration.settings?.configurationId ?? - studyStepConfiguration.configurationId ?? - null; - const parsedId = Number(rawId); - return Number.isInteger(parsedId) ? parsedId : null; - } - - /** - * Captures the single assessment configuration used by the current grade - * export for inclusion in the shared criteria_reference.json sidecar file. - * - * The first valid configuration becomes the export reference. If another - * different configuration is encountered later, the export aborts because - * grade exports are expected to use exactly one configuration. - * - * @param {{ key: string|null, reference: Object|null }} referenceState - Mutable single-reference state. - * @param {number|null} configurationId - Resolved persisted configuration id. - * @param {Object|null} assessmentConfig - Resolved assessment configuration content. - * @returns {void} - */ - function addCriteriaReferenceEntry(referenceState, configurationId, assessmentConfig) { - if (!assessmentConfig || typeof assessmentConfig !== "object") return; - - const referenceKey = Number.isInteger(configurationId) ? `configuration:${configurationId}` : null; - if (!referenceKey) return; - - if (!referenceState.reference) { - referenceState.key = referenceKey; - referenceState.reference = { - configurationId: Number.isInteger(configurationId) ? configurationId : null, - ...assessmentConfig - }; - return; - } - - if (referenceState.key !== referenceKey) { - throw new Error("Expected exactly one assessment configuration for grade export, found multiple."); - } - } - - /** - * Returns a user's display name based on private info permissions. - * - * @param {Object|null} user - The user record. - * @param {boolean} hasPrivateInfoRight - Whether real names are allowed. - * @returns {string|null} Full name or username depending on permissions. - */ - function getPrivateAwareName(user, hasPrivateInfoRight) { - if (!user) return null; - if (hasPrivateInfoRight) return `${user.firstName} ${user.lastName}`.trim(); - // Usernames are considered anonymous-enough for exports when real names are restricted. - return user.userName ?? null; - } - - /** - * Builds a flat CSV row for a grade export record. - * The row contains backend export metadata columns followed by - * one column per assessment criterion score. - * - * @param {Object} record - Prepared grade export record. - * @returns {Object} A flat object suitable for Papa.unparse. - */ - function buildGradeCsvRow(record) { - const criterionScores = record.scores && typeof record.scores === "object" ? record.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, - studyName: record.studyName, - studyStepId: record.studyStepId, - studyStepType: record.studyStepType, - configurationId: record.configurationId, - studyOwner: record.studyOwner, - sessionOwner: record.sessionOwner, - author: record.author, - totalPoints: record.totalPoints, - createdAt: record.createdAt, - ...criterionScores - }; - } - - /** - * Resolves the display name for a user based on the current export settings. - * This wraps getPrivateAwareName with alias support for anonymized exports. - * - * @param {Object} user - The user record to display. - * @param {boolean} shouldGenerateAliases - Whether aliases should replace real names. - * @param {boolean} hasPrivateInfoRight - Whether the current user may export real names. - * @param {Object} userMapping - Map of user IDs to generated aliases. - * @returns {string} The display name to write into the export. - */ - function getDisplayName(user, shouldGenerateAliases, hasPrivateInfoRight, userMapping) { - if (shouldGenerateAliases) return userMapping[user.id]; - return getPrivateAwareName(user, hasPrivateInfoRight); - } - - /** - * Loads the related entities needed to turn raw assessment_result rows into - * export-ready grade records. - * - * @param {Object} server - The server instance with Sequelize models. - * @param {Array} gradeRows - Assessment result rows with attached documents. - * @param {Array} users - The selected document owners for the export. - * @returns {Promise} Lookup maps for related grade-export entities. - */ - async function loadGradeExportContext(server, gradeRows, users) { - const { Op } = server.db.Sequelize; - - 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 studyStepIds = [...new Set(gradeRows.map((row) => row.studyStepId).filter(Boolean))]; - const studySteps = studyStepIds.length > 0 - ? await server.db.models.study_step.findAll({ - where: { id: { [Op.in]: studyStepIds }, deleted: false }, - raw: true - }) - : []; - const studyStepsById = new Map(studySteps.map((studyStep) => [studyStep.id, studyStep])); - - const configurationIds = [...new Set( - studySteps - .map((studyStep) => getAssessmentConfigurationId(studyStep.configuration)) - .filter((id) => id !== null) - )]; - const configurations = configurationIds.length > 0 - ? await server.db.models.configuration.findAll({ - where: { id: { [Op.in]: configurationIds }, deleted: false }, - raw: true - }) - : []; - const configurationsById = new Map(configurations.map((configuration) => [configuration.id, configuration])); - - // The export references study/session owners in addition to the selected document owners. - 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])); - - return { - sessionsById, - studiesById, - studyStepsById, - configurationsById, - usersById - }; - } - /** * Exports assessment results for the selected users as a ZIP archive. * Each selected user gets one or more hash-named folders containing @@ -618,11 +290,8 @@ module.exports = function (server) { } = await loadGradeExportContext(server, gradeRows, users); const recordsByUser = new Map(); - // Grade export currently assumes that all exported rows point to one assessment config. - const criteriaReferenceState = { - key: null, - reference: null - }; + // Each distinct assessment configuration used across the export gets its own criteria reference file. + const criteriaReferencesByConfigId = new Map(); for (const row of gradeRows) { const document = row.document; const ownerUser = usersById.get(document.userId); @@ -655,7 +324,7 @@ module.exports = function (server) { configurationsById ); addCriteriaReferenceEntry( - criteriaReferenceState, + criteriaReferencesByConfigId, configurationId, assessmentConfig ); @@ -688,10 +357,16 @@ module.exports = function (server) { recordsByUser.get(ownerUser.id).push(record); } - archive.append( - JSON.stringify(criteriaReferenceState.reference || {}, null, 2), - { name: "grades/criteria_reference.json" } - ); + if (criteriaReferencesByConfigId.size > 0) { + for (const [configurationId, reference] of criteriaReferencesByConfigId.entries()) { + archive.append( + JSON.stringify(reference, null, 2), + { name: `grades/criteria_reference_${configurationId}.json` } + ); + } + } else { + archive.append(JSON.stringify({}, null, 2), { name: "grades/criteria_reference.json" }); + } const usedFolderNames = new Set(); const getUniqueHashFolderName = (baseHash, userId, sessionId) => { @@ -722,15 +397,7 @@ module.exports = function (server) { } for (const [groupKey, groupRecords] of mergedGroups.entries()) { - const sortedRecords = [...groupRecords].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 sortedRecords = [...groupRecords].sort(compareGradeRecords); const csvRows = sortedRecords.map((record) => buildGradeCsvRow(record)); @@ -742,15 +409,7 @@ module.exports = function (server) { } 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 records = (recordsByUser.get(user.id) || []).sort(compareGradeRecords); const recordsByHash = new Map(); for (const record of records) { @@ -775,22 +434,199 @@ module.exports = function (server) { } /** - * 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). + * 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} */ - 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 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, + }); + if (documentData.length > 0) { + archive.append(JSON.stringify(documentData, null, 2), { name: `${docFolder}/document_data.json` }); + } + + const docMeta = { + ...doc.toJSON(), + 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 consentedUserIds = await getConsentedUserIds(server, allUserIds); + annotations = annotations.filter(a => !a.userId || consentedUserIds.has(a.userId)); + comments = comments.filter(c => !c.userId || consentedUserIds.has(c.userId)); + } + + 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` }); + } + + appendStoredFileIfExists(archive, doc.hash, '.pdf', `${docFolder}/document.pdf`, 'PDF'); + break; + } + + 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 consentedUserIds = await getConsentedUserIds(server, editorUserIds); + 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 + appendStoredFileIfExists(archive, doc.hash, '.zip', `${docFolder}/document.zip`, 'ZIP'); + break; + } + + default: + console.warn(`[DocumentExport] Unhandled document type ${doc.type} for document ${doc.hash}, skipping.`); + } + } + + /** + * 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} + */ + 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; } }; diff --git a/backend/webserver/sockets/app.js b/backend/webserver/sockets/app.js index d60aaf766..8b51e397e 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 42270d47b..f9a0a78af 100644 --- a/backend/webserver/sockets/document.js +++ b/backend/webserver/sockets/document.js @@ -193,7 +193,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 +860,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 +871,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 +901,6 @@ class DocumentSocket extends Socket { userId: submission.userId, isUploaded: true, submissionId: submissionEntry.id, - projectId: submissionEntry.projectId }, {transaction} ); @@ -982,92 +938,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 +947,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 +961,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 +978,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 +1275,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 5b8135a06..83172c93a 100644 --- a/backend/webserver/sockets/template.js +++ b/backend/webserver/sockets/template.js @@ -38,7 +38,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"); } @@ -227,8 +227,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"); @@ -264,7 +264,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; @@ -275,9 +275,9 @@ class TemplateSocket extends Socket { } return await this.models["placeholder"].updateById( - data.id, - updateData, - { transaction: options.transaction } + data.id, + updateData, + { transaction: options.transaction } ); } @@ -487,7 +487,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 }, @@ -525,8 +525,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, @@ -671,7 +671,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"); } @@ -740,11 +740,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 9a2856432..feef471ad 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -88,7 +88,8 @@ "license": "Apache-2.0", "dependencies": { "quill": "2.0.2", - "quill-delta": "^5.1.0" + "quill-delta": "^5.1.0", + "quill-delta-to-html": "^0.12.1" }, "devDependencies": { "cross-env": "^7.0.3", 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 99eaf8186..4d0ba5749 100644 --- a/frontend/src/components/dashboard/Study.vue +++ b/frontend/src/components/dashboard/Study.vue @@ -17,13 +17,6 @@ text="Manage Studies" icon="gear-fill" @click="manageStudies" - /> - -
-
-

Active Assignments

-
-
-

- You have no active assignments. -

-
-
-
-
- - - - - -
-
-
-
- -
-

Closed Assignments

-
-

- You have no closed assignments. -

-
-
-
-
- + + + + + + + + + + + + diff --git a/frontend/src/components/dashboard/Templates.vue b/frontend/src/components/dashboard/Templates.vue index c1103a5a1..8c47ba8a8 100644 --- a/frontend/src/components/dashboard/Templates.vue +++ b/frontend/src/components/dashboard/Templates.vue @@ -118,8 +118,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, @@ -283,7 +283,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 5615c02e0..835194f30 100644 --- a/frontend/src/components/dashboard/Users.vue +++ b/frontend/src/components/dashboard/Users.vue @@ -140,12 +140,12 @@ export default { PasswordModal, RightsModal, RightsManagementModal, + RoleManagementModal, BasicButton, ImportModal, UploadModal, UserAddModal, ConfirmModal, - RoleManagementModal, ActiveSessionsModal, }, props: { diff --git a/frontend/src/components/dashboard/assignments/AssignmentModal.vue b/frontend/src/components/dashboard/assignments/AssignmentModal.vue deleted file mode 100644 index abc352a52..000000000 --- a/frontend/src/components/dashboard/assignments/AssignmentModal.vue +++ /dev/null @@ -1,277 +0,0 @@ - - - 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 b46137148..2cf79c461 100644 --- a/frontend/src/components/dashboard/projects/ExportModal.vue +++ b/frontend/src/components/dashboard/projects/ExportModal.vue @@ -18,7 +18,9 @@ :fields="dataSelectionFields" /> + - @@ -87,8 +99,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 BasicLoading from "@/basic/Loading.vue"; +import StepSelectUsers from "@/components/dashboard/projects/export/StepSelectUsers.vue"; import StepOptions from "@/components/dashboard/projects/export/StepOptions.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"; @@ -96,11 +110,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, StepOptions, StepOptionsDocuments, StepConfirmDownload }, subscribeTable: [{ table: "document", }, { @@ -119,6 +133,12 @@ export default { table: "tag_set", }, { table: "tag" + }, { + table: "document_data", + }, { + table: "study_step", + }, { + table: "configuration", } ], provide() { @@ -135,11 +155,14 @@ export default { filter: [], wait: false, // Data for Export Submissions - submissionSelection: [], + userSelection: [], generateAliases:false, fakerSeed: 846569412, gradeFormat: "json", - mergeCsvFiles: false + mergeCsvFiles: false, + selectedDocumentTypes: [0, 1, 2, 4], + excludeNonConsentingEdits: false, + excludeNonConsentingAnnotations: false }; }, computed: { @@ -147,10 +170,17 @@ export default { if (["submissions", "grades"].includes(this.dataSelection.exportType)) { 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, @@ -161,7 +191,14 @@ export default { if (["submissions", "grades"].includes(this.dataSelection.exportType)) { return [ { title: "Settings" }, - { title: "Select Students" }, + { title: "Select Users" }, + { title: "Options" }, + { title: "Confirm Download" } + ]; + } else if (this.dataSelection.exportType === 'documents') { + return [ + { title: "Settings" }, + { title: "Select Users" }, { title: "Options" }, { title: "Confirm Download" } ]; @@ -191,6 +228,7 @@ export default { {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, @@ -256,6 +294,13 @@ 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") { @@ -264,6 +309,8 @@ export default { this.downloadSubmissions(); } else if (this.dataSelection.exportType === "grades") { this.downloadGrades(); + } else if (this.dataSelection.exportType === "documents") { + this.downloadDocuments(); } else { this.downloadAllData(); } @@ -327,7 +374,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({ @@ -346,7 +393,7 @@ export default { }, async downloadGrades() { 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', @@ -362,6 +409,24 @@ export default { this.$toast.error("An error occurred starting the stream. Please try again."); } }, + async downloadDocuments() { + try { + const selectedUserIds = this.userSelection.map(row => row.userId); + this.triggerStreamDownload({ + projectId: this.dataSelection.projectId, + exportType: 'documents', + userIds: selectedUserIds, + 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."); + } + }, async downloadAllData() { this.wait = true; diff --git a/frontend/src/components/dashboard/projects/export/StepConfirmDownload.vue b/frontend/src/components/dashboard/projects/export/StepConfirmDownload.vue index 8afd4df92..6aba6d277 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,15 @@
Summary:
- You are about to download submissions for - {{ submissionSelection.length }} student(s). + You are about to download + {{ exportTypeLabel }} + for {{ userSelection.length }} user(s).
    -
  • - {{ row.studentName || row.userName }} ({{ row.fileCount }} files) +
  • + {{ row.name }} ({{ row.suffix }})
@@ -44,13 +45,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,15 +63,35 @@ 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'); + }, + exportTypeLabel() { + const labels = { + submissions: 'submissions', + grades: 'grades', + documents: 'documents', + }; + return labels[this.exportType] || 'documents'; + }, + userSelectionDisplay() { + const unit = this.exportType === 'submissions' ? 'submission(s)' : 'document(s)'; + return this.userSelection.map(row => ({ + userId: row.userId, + name: row.studentName || row.userName, + suffix: this.exportType === 'grades' ? null : `${row.count} ${unit}`, + })); + }, } } \ No newline at end of file diff --git a/frontend/src/components/dashboard/projects/export/StepOptions.vue b/frontend/src/components/dashboard/projects/export/StepOptions.vue index 9294be51c..f0e1fda04 100644 --- a/frontend/src/components/dashboard/projects/export/StepOptions.vue +++ b/frontend/src/components/dashboard/projects/export/StepOptions.vue @@ -17,12 +17,12 @@ import BasicForm from "@/basic/Form.vue"; /** - * StepOptions + * StepOptionsSubmissions * * Provides configuration options for the export process. Currently, it allows * the user to toggle alias generation for student names and set a custom seed for it. * - * @author Mélissa Loew, Linyin Huang + * @author Mélissa Loew */ export default { 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/StepSelectStudents.vue b/frontend/src/components/dashboard/projects/export/StepSelectStudents.vue deleted file mode 100644 index 8ca0a4962..000000000 --- a/frontend/src/components/dashboard/projects/export/StepSelectStudents.vue +++ /dev/null @@ -1,147 +0,0 @@ - - - \ 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..ca7a5e0a3 --- /dev/null +++ b/frontend/src/components/dashboard/projects/export/StepSelectUsers.vue @@ -0,0 +1,242 @@ + + + \ 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 37c4798e5..fe31f4d02 100644 --- a/frontend/src/components/dashboard/settings/SettingItem.vue +++ b/frontend/src/components/dashboard/settings/SettingItem.vue @@ -131,7 +131,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() { @@ -148,7 +148,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() { @@ -174,6 +173,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" /> - + + + -