From f214ac5a2bcdcd6f7775db07b452455e5427a67e Mon Sep 17 00:00:00 2001 From: karimouf Date: Tue, 17 Mar 2026 14:53:56 +0200 Subject: [PATCH 01/63] feat: new assignment model --- .../20260316122741-create-assignment.js | 123 ++++++++++++++ backend/db/models/assignment.js | 157 ++++++++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 backend/db/migrations/20260316122741-create-assignment.js create mode 100644 backend/db/models/assignment.js diff --git a/backend/db/migrations/20260316122741-create-assignment.js b/backend/db/migrations/20260316122741-create-assignment.js new file mode 100644 index 000000000..e0285557a --- /dev/null +++ b/backend/db/migrations/20260316122741-create-assignment.js @@ -0,0 +1,123 @@ +'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, + }, + title: { + type: Sequelize.STRING, + allowNull: false, + }, + description: { + type: Sequelize.TEXT, + allowNull: true, + }, + studyId: { + type: Sequelize.INTEGER, + allowNull: true, + references: { + model: 'study', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + workflowId: { + type: Sequelize.INTEGER, + allowNull: true, + references: { + model: 'workflow', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + assignmentGroup: { + type: Sequelize.STRING, + allowNull: true, + defaultValue: null, + }, + groupIdx: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + }, + 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', + }, + previousSubmissionAssignmentId: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + references: { + model: 'assignment', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL', + }, + allowReUpload: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + 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/models/assignment.js b/backend/db/models/assignment.js new file mode 100644 index 000000000..667e7a1e5 --- /dev/null +++ b/backend/db/models/assignment.js @@ -0,0 +1,157 @@ +'use strict'; +const MetaModel = require("../MetaModel.js"); + +module.exports = (sequelize, DataTypes) => { + class Assignment extends MetaModel { + static autoTable = true; + static fields = [ + { + key: "title", + label: "Assignment Title:", + placeholder: "Assignment 1", + type: "text", + required: true, + default: "", + }, + { + key: "description", + label: "Description:", + help: "Optional description shown for this assignment.", + type: "textarea", + required: false, + }, + { + key: "studyId", + label: "Study:", + type: "select", + options: { + table: "study", + name: "name", + value: "id", + }, + required: false, + help: "Select a study assignment source.", + }, + { + key: "workflowId", + label: "Workflow:", + type: "select", + options: { + table: "workflow", + name: "name", + value: "id", + }, + required: false, + help: "Select a workflow assignment source.", + }, + { + key: "assignmentGroup", + label: "Assignment Group:", + placeholder: "group-a", + type: "text", + required: false, + }, + { + key: "groupIdx", + label: "Group Order Index:", + placeholder: "0", + type: "number", + min: 0, + required: false, + }, + { + 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.", + }, + ]; + static associate(models) { + Assignment.belongsTo(models["study"], { + foreignKey: "studyId", + as: "study", + }); + + Assignment.belongsTo(models["workflow"], { + foreignKey: "workflowId", + as: "workflow", + }); + + Assignment.belongsTo(models["configuration"], { + foreignKey: "validationConfigurationId", + as: "validationConfiguration", + }); + + Assignment.belongsTo(models["assignment"], { + foreignKey: "parentAssignmentId", + as: "parentAssignment", + }); + + Assignment.belongsTo(models["assignment"], { + foreignKey: "previousSubmissionAssignmentId", + as: "previousSubmissionAssignment", + }); + } + } + + Assignment.init( + { + title: DataTypes.STRING, + description: DataTypes.TEXT, + studyId: DataTypes.INTEGER, + workflowId: DataTypes.INTEGER, + assignmentGroup: DataTypes.STRING, + groupIdx: DataTypes.INTEGER, + start: DataTypes.DATE, + end: DataTypes.DATE, + validationConfigurationId: DataTypes.INTEGER, + parentAssignmentId: DataTypes.INTEGER, + previousSubmissionAssignmentId: DataTypes.INTEGER, + allowReUpload: DataTypes.BOOLEAN, + deleted: DataTypes.BOOLEAN, + deletedAt: DataTypes.DATE, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE, + }, + { + sequelize, + modelName: 'assignment', + tableName: 'assignment', + } + ); + + return Assignment; +}; From e27594ab5b76cf679216a5925ae240b80db3bf34 Mon Sep 17 00:00:00 2001 From: karimouf Date: Tue, 17 Mar 2026 14:54:46 +0200 Subject: [PATCH 02/63] feat: assignment ref in submissions --- ...16124704-extend-submission-assignmentId.js | 21 +++++++++++++++++++ backend/db/models/submission.js | 7 +++++++ 2 files changed, 28 insertions(+) create mode 100644 backend/db/migrations/20260316124704-extend-submission-assignmentId.js diff --git a/backend/db/migrations/20260316124704-extend-submission-assignmentId.js b/backend/db/migrations/20260316124704-extend-submission-assignmentId.js new file mode 100644 index 000000000..21dac99f7 --- /dev/null +++ b/backend/db/migrations/20260316124704-extend-submission-assignmentId.js @@ -0,0 +1,21 @@ +'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/models/submission.js b/backend/db/models/submission.js index a110accf7..d87a79716 100644 --- a/backend/db/models/submission.js +++ b/backend/db/models/submission.js @@ -18,6 +18,11 @@ module.exports = (sequelize, DataTypes) => { as: "documents", }); + Submission.belongsTo(models["assignment"], { + foreignKey: "assignmentId", + as: "assignment", + }); + Submission.belongsTo(models["submission"], { foreignKey: "parentSubmissionId", as: "parentSubmission", @@ -142,6 +147,7 @@ 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, group: originalSubmission.group, @@ -235,6 +241,7 @@ 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, From 2355809a9d3f68dca734bef9a3e0849fedaaa6ad Mon Sep 17 00:00:00 2001 From: karimouf Date: Tue, 17 Mar 2026 14:56:00 +0200 Subject: [PATCH 03/63] feat: assignment dashboard --- ...316130035-extend-nav_element-assignment.js | 50 +++ .../src/components/dashboard/Assignments.vue | 291 ++++++++++++++++++ .../dashboard/assignments/AssignmentModal.vue | 48 +++ .../AssignmentSubmissionsModal.vue | 109 +++++++ .../dashboard/submission/UploadModal.vue | 7 +- 5 files changed, 503 insertions(+), 2 deletions(-) create mode 100644 backend/db/migrations/20260316130035-extend-nav_element-assignment.js create mode 100644 frontend/src/components/dashboard/Assignments.vue create mode 100644 frontend/src/components/dashboard/assignments/AssignmentModal.vue create mode 100644 frontend/src/components/dashboard/assignments/AssignmentSubmissionsModal.vue diff --git a/backend/db/migrations/20260316130035-extend-nav_element-assignment.js b/backend/db/migrations/20260316130035-extend-nav_element-assignment.js new file mode 100644 index 000000000..34084214e --- /dev/null +++ b/backend/db/migrations/20260316130035-extend-nav_element-assignment.js @@ -0,0 +1,50 @@ +'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/frontend/src/components/dashboard/Assignments.vue b/frontend/src/components/dashboard/Assignments.vue new file mode 100644 index 000000000..18d51616a --- /dev/null +++ b/frontend/src/components/dashboard/Assignments.vue @@ -0,0 +1,291 @@ + + + diff --git a/frontend/src/components/dashboard/assignments/AssignmentModal.vue b/frontend/src/components/dashboard/assignments/AssignmentModal.vue new file mode 100644 index 000000000..b8da83c9b --- /dev/null +++ b/frontend/src/components/dashboard/assignments/AssignmentModal.vue @@ -0,0 +1,48 @@ + + + diff --git a/frontend/src/components/dashboard/assignments/AssignmentSubmissionsModal.vue b/frontend/src/components/dashboard/assignments/AssignmentSubmissionsModal.vue new file mode 100644 index 000000000..23c6c0a97 --- /dev/null +++ b/frontend/src/components/dashboard/assignments/AssignmentSubmissionsModal.vue @@ -0,0 +1,109 @@ + + + diff --git a/frontend/src/components/dashboard/submission/UploadModal.vue b/frontend/src/components/dashboard/submission/UploadModal.vue index 5e9a02db4..ab91b8982 100644 --- a/frontend/src/components/dashboard/submission/UploadModal.vue +++ b/frontend/src/components/dashboard/submission/UploadModal.vue @@ -66,6 +66,7 @@ export default { selectedValidatorId: 0, selectedValidatorData: null, files: null, + assignmentId: null, steps: [{ title: "Select User" }, { title: "Select Config" }, { title: "Upload File" }], selectionTable: [ { name: "ID", key: "id", sortable: true }, @@ -132,11 +133,12 @@ export default { }, }, methods: { - open() { + open(assignmentId = null) { this.files = null; this.selectedUser = []; this.selectedValidatorId = 0; this.formData = {}; + this.assignmentId = assignmentId; this.$refs.uploadStepper.open(); }, handleValidatorChange(validatorData) { @@ -167,7 +169,8 @@ export default { userId: this.selectedUser[0].id, group: this.formData.group, validationConfigurationId: this.selectedValidatorId, - projectId: this.projectId, + projectId: this.projectId, + assignmentId: this.assignmentId, files: Object.keys(this.files).map((k) => ({ content: this.files[k], fileName: this.files[k].name })), }; this.$refs.uploadStepper.setWaiting(true); From affdaa1593d8eaf68257b72b09af3682e9384ca2 Mon Sep 17 00:00:00 2001 From: karimouf Date: Mon, 23 Mar 2026 15:44:53 +0200 Subject: [PATCH 04/63] refactor: remove assignment grouping --- .../20260316122741-create-assignment.js | 11 ++------ backend/db/models/assignment.js | 26 ++++++++--------- .../src/components/dashboard/Assignments.vue | 28 ++++++++++++++++--- 3 files changed, 39 insertions(+), 26 deletions(-) diff --git a/backend/db/migrations/20260316122741-create-assignment.js b/backend/db/migrations/20260316122741-create-assignment.js index e0285557a..95683f141 100644 --- a/backend/db/migrations/20260316122741-create-assignment.js +++ b/backend/db/migrations/20260316122741-create-assignment.js @@ -38,15 +38,10 @@ module.exports = { onUpdate: 'CASCADE', onDelete: 'CASCADE', }, - assignmentGroup: { - type: Sequelize.STRING, - allowNull: true, - defaultValue: null, - }, - groupIdx: { + maxRevisions: { type: Sequelize.INTEGER, - allowNull: true, - defaultValue: null, + allowNull: false, + defaultValue: 1, }, start: { type: Sequelize.DATE, diff --git a/backend/db/models/assignment.js b/backend/db/models/assignment.js index 667e7a1e5..b9be1d08a 100644 --- a/backend/db/models/assignment.js +++ b/backend/db/models/assignment.js @@ -45,19 +45,14 @@ module.exports = (sequelize, DataTypes) => { help: "Select a workflow assignment source.", }, { - key: "assignmentGroup", - label: "Assignment Group:", - placeholder: "group-a", - type: "text", - required: false, - }, - { - key: "groupIdx", - label: "Group Order Index:", - placeholder: "0", + key: "maxRevisions", + label: "Maximum Revisions:", + placeholder: "1", type: "number", - min: 0, - required: false, + min: 1, + required: true, + default: 1, + help: "Maximum number of allowed revision copies for this assignment.", }, { key: "start", @@ -133,8 +128,11 @@ module.exports = (sequelize, DataTypes) => { description: DataTypes.TEXT, studyId: DataTypes.INTEGER, workflowId: DataTypes.INTEGER, - assignmentGroup: DataTypes.STRING, - groupIdx: DataTypes.INTEGER, + maxRevisions: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 1, + }, start: DataTypes.DATE, end: DataTypes.DATE, validationConfigurationId: DataTypes.INTEGER, diff --git a/frontend/src/components/dashboard/Assignments.vue b/frontend/src/components/dashboard/Assignments.vue index 18d51616a..cc7d17a10 100644 --- a/frontend/src/components/dashboard/Assignments.vue +++ b/frontend/src/components/dashboard/Assignments.vue @@ -63,8 +63,7 @@ export default { { name: "Description", key: "description", multiline: true }, { name: "Study ID", key: "studyId" }, { name: "Workflow ID", key: "workflowId" }, - { name: "Group", key: "assignmentGroup" }, - { name: "Group Idx", key: "groupIdx" }, + { name: "Max Revisions", key: "maxRevisions" }, { name: "Start", key: "start" }, { name: "End", key: "end" }, { @@ -154,8 +153,7 @@ export default { ...assignment, studyId: assignment.studyId ?? "-", workflowId: assignment.workflowId ?? "-", - assignmentGroup: assignment.assignmentGroup ?? "-", - groupIdx: assignment.groupIdx ?? "-", + maxRevisions: assignment.maxRevisions ?? 1, start: assignment.start ? new Date(assignment.start).toLocaleString() : "-", end: assignment.end ? new Date(assignment.end).toLocaleString() : "-", allowReUpload: Boolean(assignment.allowReUpload), @@ -239,6 +237,27 @@ export default { ...copyData } = originalAssignment; + const assignmentById = new Map(this.assignments.map((assignment) => [assignment.id, assignment])); + let revisionDepth = 0; + let currentAssignment = originalAssignment; + const visited = new Set(); + + while (currentAssignment?.parentAssignmentId && !visited.has(currentAssignment.id)) { + visited.add(currentAssignment.id); + revisionDepth++; + currentAssignment = assignmentById.get(currentAssignment.parentAssignmentId); + } + + const maxRevisions = Number(originalAssignment.maxRevisions ?? 1); + if (revisionDepth >= maxRevisions) { + this.eventBus.emit("toast", { + title: "Revision limit reached", + message: `Maximum of ${maxRevisions} revision${maxRevisions === 1 ? "" : "s"} reached for this assignment.`, + variant: "warning", + }); + return; + } + this.$socket.emit( "appDataUpdate", { @@ -265,6 +284,7 @@ export default { data: { ...copyData, parentAssignmentId: params.id, + maxRevisions, }, }, (result) => { From ef80e43291e9f7f401fe53700ebd73278c9579ad Mon Sep 17 00:00:00 2001 From: karimouf Date: Wed, 25 Mar 2026 18:31:24 +0200 Subject: [PATCH 05/63] refactor: add assignment and check revision limit --- backend/webserver/sockets/document.js | 44 +++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/backend/webserver/sockets/document.js b/backend/webserver/sockets/document.js index 4aaa9a55b..4137510de 100644 --- a/backend/webserver/sockets/document.js +++ b/backend/webserver/sockets/document.js @@ -940,7 +940,7 @@ class DocumentSocket extends Socket { * @throws {Error} - If the upload fails, or if saving to server fails */ async uploadSingleSubmission(data, options) { - const {files, userId, group, validationConfigurationId, projectId} = data; + const {files, userId, group, validationConfigurationId, projectId, assignmentId} = data; const transaction = options.transaction; try { const result = await this.validator.validateSubmissionFiles(files, validationConfigurationId); @@ -948,14 +948,52 @@ class DocumentSocket extends Socket { if (!result.success) { throw new Error(result.message || "Validation failed"); } - const previousSubmission = await this.models["submission"].getParentSubmission(userId, projectId, true, {transaction}); + + let previousSubmissionId = null; + + if (assignmentId) { + const assignment = await this.models["assignment"].getById(assignmentId, {transaction}); + if (!assignment) { + throw new Error(`Assignment with id ${assignmentId} not found`); + } + + const assignmentSubmissions = await this.models["submission"].findAll({ + where: { + assignmentId, + userId, + deleted: false, + }, + order: [["createdAt", "DESC"]], + raw: true, + transaction, + }); + + const latestSubmission = assignmentSubmissions[0] || null; + const submissionCount = assignmentSubmissions.length; + + if (assignment.maxRevisions !== null && assignment.maxRevisions !== undefined) { + if (submissionCount > assignment.maxRevisions) { + throw new Error( + `Maximum revisions reached for this assignment (${submissionCount}/${assignment.maxRevisions})` + ); + } + } + + previousSubmissionId = latestSubmission ? latestSubmission.id : null; + } else { + const previousSubmission = await this.models["submission"].getParentSubmission(userId, projectId, true, {transaction}); + previousSubmissionId = previousSubmission ? previousSubmission.id : null; + } + + const submission = await this.models["submission"].add({ userId, group, validationConfigurationId, createdByUserId: this.userId, - previousSubmissionId: previousSubmission ? previousSubmission.id : null, + previousSubmissionId, + assignmentId: assignmentId || null, }, {transaction}); for (const file of files) { await this.addDocument( From 111ccc32eed2d01188c7e1ed1fe83ea7f1d38113 Mon Sep 17 00:00:00 2001 From: karimouf Date: Sun, 29 Mar 2026 19:53:22 +0200 Subject: [PATCH 06/63] feat: add header buttons --- frontend/src/basic/dashboard/card/Card.vue | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/frontend/src/basic/dashboard/card/Card.vue b/frontend/src/basic/dashboard/card/Card.vue index 7729dc61e..97c554c34 100644 --- a/frontend/src/basic/dashboard/card/Card.vue +++ b/frontend/src/basic/dashboard/card/Card.vue @@ -8,13 +8,19 @@
{{ title }}
-
- +
+
+ + +
From b5be1d3f71a8939552b243f189f31973c276e834 Mon Sep 17 00:00:00 2001 From: karimouf Date: Sun, 29 Mar 2026 19:55:18 +0200 Subject: [PATCH 07/63] feat: add unlimited in slider --- frontend/src/basic/form/Slider.vue | 37 ++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/frontend/src/basic/form/Slider.vue b/frontend/src/basic/form/Slider.vue index a8105845d..4fe4b1665 100644 --- a/frontend/src/basic/form/Slider.vue +++ b/frontend/src/basic/form/Slider.vue @@ -45,28 +45,55 @@ 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 === Number(this.currentData) + (item) => item.from === this.displayValue ); if (mapping) { return mapping.to; } } - return this.currentData; + return this.displayValue; }, }, watch: { currentData() { - this.$emit("update:modelValue", Number(this.currentData)); + this.$emit("update:modelValue", this.emittedValue); }, modelValue() { - this.currentData = this.modelValue; + this.currentData = this.normalizedModelValue; }, }, beforeMount() { - this.currentData = this.modelValue; + this.currentData = this.normalizedModelValue; }, } From 65e10ed527f43642e875b1904e43763397ab2ea4 Mon Sep 17 00:00:00 2001 From: karimouf Date: Mon, 30 Mar 2026 12:13:27 +0200 Subject: [PATCH 08/63] refactor: split assignments into 2 views --- .../src/components/dashboard/Assignments.vue | 305 +---------- .../assignments/AdminAssignmentsView.vue | 482 ++++++++++++++++++ .../assignments/UserAssignmentsView.vue | 410 +++++++++++++++ 3 files changed, 900 insertions(+), 297 deletions(-) create mode 100644 frontend/src/components/dashboard/assignments/AdminAssignmentsView.vue create mode 100644 frontend/src/components/dashboard/assignments/UserAssignmentsView.vue diff --git a/frontend/src/components/dashboard/Assignments.vue b/frontend/src/components/dashboard/Assignments.vue index cc7d17a10..62df56ac2 100644 --- a/frontend/src/components/dashboard/Assignments.vue +++ b/frontend/src/components/dashboard/Assignments.vue @@ -1,310 +1,21 @@ diff --git a/frontend/src/components/dashboard/assignments/UserAssignmentsView.vue b/frontend/src/components/dashboard/assignments/UserAssignmentsView.vue new file mode 100644 index 000000000..44e029e91 --- /dev/null +++ b/frontend/src/components/dashboard/assignments/UserAssignmentsView.vue @@ -0,0 +1,410 @@ + + + From d43914d486353f457f0c675e66542c7191cc1fe8 Mon Sep 17 00:00:00 2001 From: karimouf Date: Mon, 30 Mar 2026 12:16:58 +0200 Subject: [PATCH 09/63] feat: add new role feature --- frontend/src/components/dashboard/Users.vue | 13 ++ .../dashboard/users/RoleManagementModal.vue | 138 ++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 frontend/src/components/dashboard/users/RoleManagementModal.vue diff --git a/frontend/src/components/dashboard/Users.vue b/frontend/src/components/dashboard/Users.vue index c8d3646e2..774b601ac 100644 --- a/frontend/src/components/dashboard/Users.vue +++ b/frontend/src/components/dashboard/Users.vue @@ -16,6 +16,13 @@ icon="shield-lock" @click="$refs.rightsManagementModal.open()" /> + + + + + + + + + + + + From b0603b62cd5bb4797a57cd736ef1c1eaf64ac372 Mon Sep 17 00:00:00 2001 From: karimouf Date: Mon, 30 Mar 2026 12:17:16 +0200 Subject: [PATCH 10/63] feat: assignment view rights --- ...325144936-extend-assignment-view_rights.js | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 backend/db/migrations/20260325144936-extend-assignment-view_rights.js diff --git a/backend/db/migrations/20260325144936-extend-assignment-view_rights.js b/backend/db/migrations/20260325144936-extend-assignment-view_rights.js new file mode 100644 index 000000000..f2703c11a --- /dev/null +++ b/backend/db/migrations/20260325144936-extend-assignment-view_rights.js @@ -0,0 +1,71 @@ +'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), + }, + {} + ); + } +}; From 1fe0540135f2ffda9126918981f81610be4b817b Mon Sep 17 00:00:00 2001 From: karimouf Date: Mon, 30 Mar 2026 12:26:38 +0200 Subject: [PATCH 11/63] feat: add study usage count in document --- ...29142000-extend-document-study_usage_count.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 backend/db/migrations/20260329142000-extend-document-study_usage_count.js diff --git a/backend/db/migrations/20260329142000-extend-document-study_usage_count.js b/backend/db/migrations/20260329142000-extend-document-study_usage_count.js new file mode 100644 index 000000000..121338c37 --- /dev/null +++ b/backend/db/migrations/20260329142000-extend-document-study_usage_count.js @@ -0,0 +1,16 @@ +'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'); + }, +}; From 77162fd9a2fc501658b27dabfeb42b00dbc2b310 Mon Sep 17 00:00:00 2001 From: karimouf Date: Mon, 30 Mar 2026 12:29:52 +0200 Subject: [PATCH 12/63] feat: add user roles assignment + closed assignment --- ...000-extend-assignment-assigned_role_ids.js | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 backend/db/migrations/20260329120000-extend-assignment-assigned_role_ids.js diff --git a/backend/db/migrations/20260329120000-extend-assignment-assigned_role_ids.js b/backend/db/migrations/20260329120000-extend-assignment-assigned_role_ids.js new file mode 100644 index 000000000..d5f21f8fd --- /dev/null +++ b/backend/db/migrations/20260329120000-extend-assignment-assigned_role_ids.js @@ -0,0 +1,23 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('assignment', 'assignedRoleIds', { + type: Sequelize.ARRAY(Sequelize.INTEGER), + allowNull: true, + defaultValue: [], + }); + + await queryInterface.addColumn('assignment', 'closed', { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.removeColumn('assignment', 'closed'); + await queryInterface.removeColumn('assignment', 'assignedRoleIds'); + }, +}; From 46c10d17391e77e32399293305b55417f5745ecd Mon Sep 17 00:00:00 2001 From: karimouf Date: Mon, 30 Mar 2026 12:30:17 +0200 Subject: [PATCH 13/63] feat: add userId + public keys to assignment --- .../20260316122741-create-assignment.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/backend/db/migrations/20260316122741-create-assignment.js b/backend/db/migrations/20260316122741-create-assignment.js index 95683f141..6d2035ac7 100644 --- a/backend/db/migrations/20260316122741-create-assignment.js +++ b/backend/db/migrations/20260316122741-create-assignment.js @@ -18,6 +18,11 @@ module.exports = { type: Sequelize.TEXT, allowNull: true, }, + public: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, studyId: { type: Sequelize.INTEGER, allowNull: true, @@ -38,6 +43,16 @@ module.exports = { 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, From cf3c526a99efa12626e6be46137ac4f36d41def6 Mon Sep 17 00:00:00 2001 From: karimouf Date: Mon, 30 Mar 2026 12:31:08 +0200 Subject: [PATCH 14/63] feat: add user filter to assignments --- backend/db/models/assignment.js | 45 ++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/backend/db/models/assignment.js b/backend/db/models/assignment.js index b9be1d08a..25db24914 100644 --- a/backend/db/models/assignment.js +++ b/backend/db/models/assignment.js @@ -1,9 +1,34 @@ 'use strict'; const MetaModel = require("../MetaModel.js"); +const { Op } = require("sequelize"); module.exports = (sequelize, DataTypes) => { class Assignment extends MetaModel { static autoTable = true; + + /** + * Apply visibility filter for assignments based on assigned roles. + * Non-admin users can always see their own assignments and assignments + * that are assigned to at least one of their roles. + */ + static async getUserFilter(userId) { + const roleIds = await sequelize.models.user_role_matching.getUserRolesById(userId); + const isAdmin = await sequelize.models.user_role_matching.isAdminInUserRoles(roleIds); + if (isAdmin) { + return {}; + } + + if (!Array.isArray(roleIds) || roleIds.length === 0) { + return { userId }; + } + + return { + [Op.or]: [ + { userId }, + { assignedRoleIds: { [Op.overlap]: roleIds } }, + ], + }; + } static fields = [ { key: "title", @@ -47,12 +72,18 @@ module.exports = (sequelize, DataTypes) => { { key: "maxRevisions", label: "Maximum Revisions:", - placeholder: "1", - type: "number", + 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.", + help: "Maximum number of allowed revision copies for this assignment. Move to the end for unlimited.", }, { key: "start", @@ -128,6 +159,8 @@ module.exports = (sequelize, DataTypes) => { description: DataTypes.TEXT, studyId: DataTypes.INTEGER, workflowId: DataTypes.INTEGER, + userId: DataTypes.INTEGER, + public: DataTypes.BOOLEAN, maxRevisions: { type: DataTypes.INTEGER, allowNull: false, @@ -136,9 +169,15 @@ module.exports = (sequelize, DataTypes) => { start: DataTypes.DATE, end: DataTypes.DATE, validationConfigurationId: DataTypes.INTEGER, + assignedRoleIds: { + type: DataTypes.ARRAY(DataTypes.INTEGER), + allowNull: true, + defaultValue: [], + }, parentAssignmentId: DataTypes.INTEGER, previousSubmissionAssignmentId: DataTypes.INTEGER, allowReUpload: DataTypes.BOOLEAN, + closed: DataTypes.DATE, deleted: DataTypes.BOOLEAN, deletedAt: DataTypes.DATE, createdAt: DataTypes.DATE, From ed5dda5b55ae0c7dc82f4920909c8eb00422bdc8 Mon Sep 17 00:00:00 2001 From: karimouf Date: Mon, 30 Mar 2026 12:45:34 +0200 Subject: [PATCH 15/63] feat: updating study usage count --- backend/db/models/study.js | 49 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/backend/db/models/study.js b/backend/db/models/study.js index 4b0ba74ad..9a65f3024 100644 --- a/backend/db/models/study.js +++ b/backend/db/models/study.js @@ -222,10 +222,49 @@ 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}); } + + for (const documentId of documentIds) { + await Study.updateDocumentStudyUsageCount(documentId, 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; } /** @@ -297,6 +336,16 @@ module.exports = (sequelize, DataTypes) => { } } + const usedDocumentIds = [...new Set( + Object.values(studyStepsMap) + .map((step) => step.documentId) + .filter(Boolean) + )]; + + for (const documentId of usedDocumentIds) { + await Study.updateDocumentStudyUsageCount(documentId, options); + } + } /** From d85e8fa7b344393598837928bdc26298216d9e74 Mon Sep 17 00:00:00 2001 From: karimouf Date: Mon, 30 Mar 2026 12:47:28 +0200 Subject: [PATCH 16/63] fix: remove unused group key in submission --- backend/db/models/document.js | 12 ++++++++++++ backend/db/models/submission.js | 1 - backend/webserver/Socket.js | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/backend/db/models/document.js b/backend/db/models/document.js index 27474d339..ee8bc696a 100644 --- a/backend/db/models/document.js +++ b/backend/db/models/document.js @@ -52,6 +52,13 @@ module.exports = (sequelize, DataTypes) => { required: false, default: false }, + { + key: "studyUsageCount", + label: "Number of studies using this document", + type: "text", + required: false, + default: 0, + }, ] /** @@ -330,6 +337,11 @@ module.exports = (sequelize, DataTypes) => { projectId: DataTypes.INTEGER, submissionId: DataTypes.INTEGER, originalFilename: DataTypes.STRING, + studyUsageCount: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + }, }, { sequelize: sequelize, modelName: 'document', diff --git a/backend/db/models/submission.js b/backend/db/models/submission.js index 8e8a47936..1a053cf21 100644 --- a/backend/db/models/submission.js +++ b/backend/db/models/submission.js @@ -244,7 +244,6 @@ module.exports = (sequelize, DataTypes) => { parentSubmissionId: DataTypes.INTEGER, previousSubmissionId: DataTypes.INTEGER, extId: DataTypes.INTEGER, - group: DataTypes.INTEGER, additionalSettings: DataTypes.JSONB, validationConfigurationId: DataTypes.INTEGER, deleted: DataTypes.BOOLEAN, diff --git a/backend/webserver/Socket.js b/backend/webserver/Socket.js index 2395d5725..fbd64359d 100644 --- a/backend/webserver/Socket.js +++ b/backend/webserver/Socket.js @@ -452,7 +452,7 @@ module.exports = class Socket { 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); + const userFilter = await 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 From a12b63e0c05e8cc5eb512be12a822f6c90a4b837 Mon Sep 17 00:00:00 2001 From: karimouf Date: Mon, 30 Mar 2026 12:48:59 +0200 Subject: [PATCH 17/63] feat: adapt uploadSingleSubmission function --- backend/webserver/sockets/document.js | 175 ++++++++++++++++++++++++-- 1 file changed, 166 insertions(+), 9 deletions(-) diff --git a/backend/webserver/sockets/document.js b/backend/webserver/sockets/document.js index 23ec6c79c..6961f4cbf 100644 --- a/backend/webserver/sockets/document.js +++ b/backend/webserver/sockets/document.js @@ -952,7 +952,7 @@ class DocumentSocket extends Socket { * @throws {Error} - If the upload fails, or if saving to server fails */ async uploadSingleSubmission(data, options) { - const {files, userId, group, validationConfigurationId, projectId, assignmentId} = data; + const {files, userId, group, validationConfigurationId, projectId, assignmentId, submissionId} = data; const transaction = options.transaction; try { const result = await this.validator.validateSubmissionFiles(files, validationConfigurationId); @@ -961,6 +961,20 @@ class DocumentSocket extends Socket { throw new Error(result.message || "Validation failed"); } + if (assignmentId && submissionId) { + return await this.replaceAssignmentSubmission( + { + files, + userId, + group, + validationConfigurationId, + assignmentId, + submissionId, + }, + {transaction} + ); + } + let previousSubmissionId = null; if (assignmentId) { @@ -975,23 +989,62 @@ class DocumentSocket extends Socket { userId, deleted: false, }, - order: [["createdAt", "DESC"]], raw: true, transaction, }); - const latestSubmission = assignmentSubmissions[0] || null; - const submissionCount = assignmentSubmissions.length; + 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 resolveChainTailId = (startId) => { + let currentId = startId; + const visited = new Set(); + + while (childByParentId.has(currentId) && !visited.has(currentId)) { + visited.add(currentId); + currentId = childByParentId.get(currentId); + } + + return currentId; + }; - if (assignment.maxRevisions !== null && assignment.maxRevisions !== undefined) { - if (submissionCount > assignment.maxRevisions) { + 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 (${submissionCount}/${assignment.maxRevisions})` + `Maximum revisions reached for this assignment (${chainDepth}/${assignment.maxRevisions})` ); } } - - previousSubmissionId = latestSubmission ? latestSubmission.id : null; } else { const previousSubmission = await this.models["submission"].getParentSubmission(userId, projectId, true, {transaction}); previousSubmissionId = previousSubmission ? previousSubmission.id : null; @@ -1025,6 +1078,110 @@ class DocumentSocket extends Socket { } } + /** + * Replace an existing assignment submission by creating a new one, + * deleting the old one, and reconnecting submission chain pointers. + * + * @param {Object} data + * @param {Array} data.files + * @param {number} data.userId + * @param {number} data.group + * @param {number} data.validationConfigurationId + * @param {number} data.assignmentId + * @param {number} data.submissionId + * @param {Object} options + * @param {Object} options.transaction + * @returns {Promise} replacement information + */ + async replaceAssignmentSubmission(data, options) { + const {files, userId, group, validationConfigurationId, assignmentId, submissionId} = 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`); + } + + 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 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, + assignmentId, + }, {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"].updateById(oldSubmission.id, {deleted: true}, {transaction}); + + for (const file of files) { + await this.addDocument( + { + file: file.content, + name: file.fileName, + userId, + isUploaded: true, + submissionId: newSubmission.id, + }, + {transaction} + ); + } + + return { + replacedSubmissionId: oldSubmission.id, + newSubmissionId: newSubmission.id, + }; + } + /** * Send a document to the client * From 669ca4e318660092eda88272cac6006cf3dc2b9a Mon Sep 17 00:00:00 2001 From: karimouf Date: Mon, 30 Mar 2026 12:50:06 +0200 Subject: [PATCH 18/63] feat: assignment modal components --- .../dashboard/assignments/AssignmentModal.vue | 175 ++++++++-- .../AssignmentSubmissionsModal.vue | 261 ++++++++++++++- .../assignments/AssignmentUploadModal.vue | 310 ++++++++++++++++++ 3 files changed, 711 insertions(+), 35 deletions(-) create mode 100644 frontend/src/components/dashboard/assignments/AssignmentUploadModal.vue diff --git a/frontend/src/components/dashboard/assignments/AssignmentModal.vue b/frontend/src/components/dashboard/assignments/AssignmentModal.vue index b8da83c9b..f3f7b41e7 100644 --- a/frontend/src/components/dashboard/assignments/AssignmentModal.vue +++ b/frontend/src/components/dashboard/assignments/AssignmentModal.vue @@ -1,47 +1,184 @@ @@ -104,87 +96,21 @@ From a7da6fc876b9e6f22b2cd02c201e3eed21652257 Mon Sep 17 00:00:00 2001 From: karimouf Date: Mon, 30 Mar 2026 19:42:38 +0200 Subject: [PATCH 24/63] feat: extend submission with name and desc --- ...2500-extend-submission-name-description.js | 23 +++++++++++++++++++ backend/db/models/submission.js | 2 ++ 2 files changed, 25 insertions(+) create mode 100644 backend/db/migrations/20260330112500-extend-submission-name-description.js diff --git a/backend/db/migrations/20260330112500-extend-submission-name-description.js b/backend/db/migrations/20260330112500-extend-submission-name-description.js new file mode 100644 index 000000000..f02e57dfc --- /dev/null +++ b/backend/db/migrations/20260330112500-extend-submission-name-description.js @@ -0,0 +1,23 @@ +'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/models/submission.js b/backend/db/models/submission.js index abbfec45b..953684e33 100644 --- a/backend/db/models/submission.js +++ b/backend/db/models/submission.js @@ -250,6 +250,8 @@ module.exports = (sequelize, DataTypes) => { parentSubmissionId: DataTypes.INTEGER, previousSubmissionId: DataTypes.INTEGER, extId: DataTypes.INTEGER, + name: DataTypes.STRING, + description: DataTypes.TEXT, additionalSettings: DataTypes.JSONB, validationConfigurationId: DataTypes.INTEGER, deleted: DataTypes.BOOLEAN, From 6c8ee7d02204278393ad9983c7b8e460437557e1 Mon Sep 17 00:00:00 2001 From: karimouf Date: Mon, 30 Mar 2026 19:44:34 +0200 Subject: [PATCH 25/63] feat: add submission name column --- .../dashboard/assignments/AssignmentSubmissionsTable.vue | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/components/dashboard/assignments/AssignmentSubmissionsTable.vue b/frontend/src/components/dashboard/assignments/AssignmentSubmissionsTable.vue index 6b7a89625..767ca25a1 100644 --- a/frontend/src/components/dashboard/assignments/AssignmentSubmissionsTable.vue +++ b/frontend/src/components/dashboard/assignments/AssignmentSubmissionsTable.vue @@ -48,6 +48,7 @@ export default { }, columns: [ { name: "ID", key: "id" }, + { name: "Submission Name", key: "submissionName" }, { name: "Username", key: "userName" }, { name: "Studies Using", key: "studyUsageCount" }, { name: "Created At", key: "createdAt" }, @@ -99,6 +100,7 @@ export default { allowReUpload: (Boolean(this.assignment?.allowReUpload) || this.hasAdminRights) && !isStudyLocked, isStudyLocked, studyUsageCount, + submissionName: submission.name || "-", userName: user?.userName || this.authUser?.userName || "unknown", group: submission.group ?? "-", createdAt: submission.createdAt ? new Date(submission.createdAt).toLocaleString() : "-", From d4f26448ea8e813dfac8c2b9b65f95820c4c397b Mon Sep 17 00:00:00 2001 From: karimouf Date: Mon, 30 Mar 2026 20:51:52 +0200 Subject: [PATCH 26/63] feat: add a description and name fields --- .../assignments/AssignmentUploadModal.vue | 41 +++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/dashboard/assignments/AssignmentUploadModal.vue b/frontend/src/components/dashboard/assignments/AssignmentUploadModal.vue index b07f8ca34..4241206da 100644 --- a/frontend/src/components/dashboard/assignments/AssignmentUploadModal.vue +++ b/frontend/src/components/dashboard/assignments/AssignmentUploadModal.vue @@ -19,6 +19,10 @@ {{ assignmentDescription }}

+ -
- Validation schema is preselected from this assignment. -
+ - - -