diff --git a/backend/db/migrations/20260316122741-create-assignment.js b/backend/db/migrations/20260316122741-create-assignment.js new file mode 100644 index 000000000..5dff16512 --- /dev/null +++ b/backend/db/migrations/20260316122741-create-assignment.js @@ -0,0 +1,137 @@ +'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, + }, + public: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + projectId: { + type: Sequelize.INTEGER, + allowNull: true, + references: { + model: 'project', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + 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', + }, + 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', + }, + 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 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/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/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), + }, + {} + ); + } +}; 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'); + }, +}; 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'); + }, +}; diff --git a/backend/db/migrations/20260330103000-extend-assignment-admin-view-rights.js b/backend/db/migrations/20260330103000-extend-assignment-admin-view-rights.js new file mode 100644 index 000000000..1e62a0d14 --- /dev/null +++ b/backend/db/migrations/20260330103000-extend-assignment-admin-view-rights.js @@ -0,0 +1,87 @@ +'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 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/migrations/20260331130000-transform-submissions-nav-default.js b/backend/db/migrations/20260331130000-transform-submissions-nav-default.js new file mode 100644 index 000000000..938ac378e --- /dev/null +++ b/backend/db/migrations/20260331130000-transform-submissions-nav-default.js @@ -0,0 +1,43 @@ +"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 new file mode 100644 index 000000000..16dfd08c8 --- /dev/null +++ b/backend/db/migrations/20260406152554-basic-setting-email_template_submission_upload.js @@ -0,0 +1,31 @@ +'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.', + }, +]; + +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 new file mode 100644 index 000000000..681a7abab --- /dev/null +++ b/backend/db/migrations/20260406152625-basic-placeholder-email_template_type_7_submission_upload.js @@ -0,0 +1,63 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ + +const placeholders = [ + { + type: 7, + placeholderKey: 'username', + placeholderLabel: 'Recipient username', + placeholderType: 'text', + placeholderDescription: 'Assignment owner receiving this notification.', + }, + { + 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.', + }, +]; + +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/models/assignment.js b/backend/db/models/assignment.js new file mode 100644 index 000000000..13f229102 --- /dev/null +++ b/backend/db/models/assignment.js @@ -0,0 +1,207 @@ +'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(), + }, + ]; + /** + * 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", + 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: "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.", + }, + ]; + 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.init( + { + title: DataTypes.STRING, + description: DataTypes.TEXT, + studyId: DataTypes.INTEGER, + workflowId: DataTypes.INTEGER, + projectId: DataTypes.INTEGER, + userId: DataTypes.INTEGER, + public: DataTypes.BOOLEAN, + maxRevisions: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 1, + }, + start: DataTypes.DATE, + end: DataTypes.DATE, + validationConfigurationId: DataTypes.INTEGER, + assignedRoleIds: { + type: DataTypes.ARRAY(DataTypes.INTEGER), + allowNull: true, + defaultValue: [], + }, + parentAssignmentId: DataTypes.INTEGER, + allowReUpload: DataTypes.BOOLEAN, + notifyOnSubmissionUpload: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true, + }, + 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/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/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); + } + } /** diff --git a/backend/db/models/submission.js b/backend/db/models/submission.js index a33afd170..a9d08b7c8 100644 --- a/backend/db/models/submission.js +++ b/backend/db/models/submission.js @@ -9,6 +9,12 @@ 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 = []; @@ -18,6 +24,11 @@ module.exports = (sequelize, DataTypes) => { as: "documents", }); + Submission.belongsTo(models["assignment"], { + foreignKey: "assignmentId", + as: "assignment", + }); + Submission.belongsTo(models["submission"], { foreignKey: "parentSubmissionId", as: "parentSubmission", @@ -141,8 +152,11 @@ 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, @@ -234,10 +248,12 @@ 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, - group: DataTypes.INTEGER, + name: DataTypes.STRING, + description: DataTypes.TEXT, additionalSettings: DataTypes.JSONB, validationConfigurationId: DataTypes.INTEGER, deleted: DataTypes.BOOLEAN, diff --git a/backend/db/models/template.js b/backend/db/models/template.js index 5e9a2c323..030bff0c5 100644 --- a/backend/db/models/template.js +++ b/backend/db/models/template.js @@ -24,7 +24,7 @@ module.exports = (sequelize, DataTypes) => { return {[Op.or]: [{userId: userId}, {public: true}]}; } else { // Non-admins: own templates (types 4, 5 only) OR public templates from others (types 4, 5 only) - // Email templates (types 1, 2, 3, 6) are admin-only + // Email templates (types 1, 2, 3, 6, 7) are admin-only return { [Op.or]: [ {[Op.and]: [{userId: userId}, {type: {[Op.in]: [4, 5]}}]}, @@ -66,7 +66,7 @@ module.exports = (sequelize, DataTypes) => { /** * Override getAutoTable to apply custom filtering for templates: * - All users (including admins): own templates OR public templates from others - * - Non-admins: exclude email templates (types 1, 2, 3, 6) - admin-only + * - Non-admins: exclude email templates (types 1, 2, 3, 6, 7) - admin-only */ static async getAutoTable(filterList = [], userId = null, attributes = null) { const {Op} = require("sequelize"); @@ -149,6 +149,10 @@ module.exports = (sequelize, DataTypes) => { name: "Email - Study Close", value: 6 }, + { + name: "Email - Submission upload", + value: 7 + }, { name: "Document - General", value: 4 @@ -464,12 +468,12 @@ module.exports = (sequelize, DataTypes) => { ); } - // updateData sets context.currentUserId (same pattern as study model) - if (options.context?.currentUserId === undefined) { + // appDataUpdate / updateData passes callerUserId so hooks can enforce ownership + if (options.callerUserId === undefined) { return; } - if (template.userId !== options.context.currentUserId) { + if (template.userId !== options.callerUserId) { 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 57ebf3481..51e9eb88e 100644 --- a/backend/db/models/user_role.js +++ b/backend/db/models/user_role.js @@ -4,6 +4,7 @@ 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/utils/emailHelper.js b/backend/utils/emailHelper.js index d76497a64..0255691cf 100644 --- a/backend/utils/emailHelper.js +++ b/backend/utils/emailHelper.js @@ -53,6 +53,11 @@ 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 {Object} models - Database models * @param {Object} logger - Logger instance * @returns {Promise<{subject: string, body: string, isHtml: boolean}>} Email subject, body, and whether body is HTML diff --git a/backend/utils/templateResolver.js b/backend/utils/templateResolver.js index 07e9c923d..31ff845b3 100644 --- a/backend/utils/templateResolver.js +++ b/backend/utils/templateResolver.js @@ -117,6 +117,17 @@ 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); + } + return replacements; } @@ -323,7 +334,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) + * @param {number} templateType - Template type (e.g. 1, 2, 3, 6, 7) * @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 2395d5725..2aa23c614 100644 --- a/backend/webserver/Socket.js +++ b/backend/webserver/Socket.js @@ -441,55 +441,89 @@ 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}; + } - 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 { + // 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 (accessRights.length > 0) { - if (relevantAccessMap.every(item => item.limitation)) { - const {filter: limitedFilter, columns} = this.handleLimitations( - tableName, - allFilter, - accessRights, - relevantAccessMap, - userId - ); + if (!fullRowAccess) { + // --- Ownership: user always sees their own rows when table has userId --- + if (hasUserIdAttribute) { + rowVisibilityConditions.push({userId}); + } - allFilter = limitedFilter; - allAttributes['include'] = columns; - } else { // do without limitations - allAttributes['include'] = [...new Set( - accessRights - .filter(a => a.columns) - .flatMap(a => a.columns) - )]; + // --- User-level row filter --- + if (hasModelUserFilter) { + const userFilter = await model.getUserFilter(userId, isAdmin); + if (Object.keys(userFilter).length > 0) { + rowVisibilityConditions.push(userFilter); + } else { + // getUserFilter returns {} → grants full row access (e.g. for admins) + fullRowAccess = true; } - - } else { + } else if (!hasUserIdAttribute && accessRights.length === 0) { this.logger.warn("User with id " + userId + " requested table " + tableName + " without access rights"); return {filter: allFilter, attributes: allAttributes, accessAllowed: false}; } + + // --- Access-map limitations (ORed with user filter conditions) --- + if (!fullRowAccess && accessRights.length > 0) { + const limitedAccessMap = relevantAccessMap.filter(item => item.limitation); + const hasUnlimitedRights = accessRights.length > limitedAccessMap.length; + + if (hasUnlimitedRights) { + // At least one right has no limitation → unlimited row access for that right + fullRowAccess = true; + } else if (limitedAccessMap.length > 0) { + // All rights carry limitations → add each as an additional OR condition + limitedAccessMap.forEach(a => { + const idField = a.access.target || 'id'; + rowVisibilityConditions.push({[idField]: {[Op.in]: [...new Set(a.limitation)]}}); + }); + } + } + } + + // --- Column restrictions from access rights (applied regardless of row logic) --- + if (accessRights.length > 0) { + allAttributes['include'] = [...new Set( + accessRights + .filter(a => a.columns) + .flatMap(a => a.columns) + )]; } + + // Apply row-visibility: baseFilter AND (condition1 OR condition2 OR ...) + // Skipped entirely when fullRowAccess is true (no row restriction needed). + if (!fullRowAccess && rowVisibilityConditions.length > 0) { + allFilter = { + [Op.and]: [ + allFilter, + rowVisibilityConditions.length === 1 + ? rowVisibilityConditions[0] + : {[Op.or]: rowVisibilityConditions}, + ], + }; + } + return {filter: allFilter, attributes: allAttributes, accessAllowed: true}; } @@ -643,7 +677,7 @@ module.exports = class Socket { allFilter = filtersAndAttributes.filter; allAttributes = filtersAndAttributes.attributes; - + console.log("Final filter for " + tableName + ": ", allFilter, allAttributes); let data = await this.models[tableName].getAll({ where: allFilter, attributes: allAttributes, diff --git a/backend/webserver/sockets/document.js b/backend/webserver/sockets/document.js index 8e57863cf..d118d3852 100644 --- a/backend/webserver/sockets/document.js +++ b/backend/webserver/sockets/document.js @@ -11,6 +11,7 @@ const Validator = require("../../utils/validator.js"); const {Op} = require('sequelize'); const {applyTemplateToDocument} = require("../../utils/documentTemplateHelper.js"); const {generateError} = require("../../utils/generic.js"); +const {getEmailContent} = require("../../utils/emailHelper.js"); const UPLOAD_PATH = `${__dirname}/../../../files`; @@ -859,6 +860,15 @@ 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 @@ -870,23 +880,51 @@ 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); + const validationResult = await this.validator.validateSubmissionFiles(tempFiles, data.validationConfigurationId? data.validationConfigurationId : (assignment ? assignment.validationConfigurationId : null)); if (!validationResult.success) { throw new Error(validationResult.message || "Validation failed"); } - // 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}); + + // 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, + }); + + 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; + } + // 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: previousSubmission ? previousSubmission.id : null, + previousSubmissionId, projectId: submission.projectId, - group: data.group, - validationConfigurationId: data.validationConfigurationId, + assignmentId: assignmentId || null, + name: submission.name ?? null, + description: submission.description ?? null, + validationConfigurationId: assignment ? assignment.validationConfigurationId : (data.validationConfigurationId || null), }, {transaction} ); @@ -937,6 +975,56 @@ class DocumentSocket extends Socket { return {downloadedSubmissions, downloadedErrors}; } + /** + * Send submission upload/reupload notification email to assignment owner. + * + * @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 {string} data.eventType - Upload event type ('first_upload' or 'reupload') + * @returns {Promise} + */ + async sendSubmissionUploadEmail(data) { + const {assignmentId, submissionId, 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 user = await this.models["user"].getById(assignment.userId); + if (!user || !user.email) { + this.server.logger.warn(`Cannot send submission upload email: assignment owner ${assignment.userId} has no email`); + return; + } + + const eventLabel = eventType === "reupload" ? "Reuploaded" : "Uploaded"; + const eventLabelLower = eventType === "reupload" ? "reuploaded" : "uploaded"; + + const emailContent = await getEmailContent( + "email.template.submissionUpload", + "submissionUpload", + { + userId: assignment.userId, + assignmentName: assignment.title, + assignmentId, + submissionId, + eventType: eventLabelLower, + eventLabel, + eventLabelLower, + }, + this.models, + this.logger + ); + + await this.server.sendMail(user.email, emailContent.subject, emailContent.body, {isHtml: emailContent.isHtml}); + } + /** * Upload a single submission to the DB. * @@ -946,13 +1034,15 @@ 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} = data; + const {files, userId, group, validationConfigurationId, projectId, assignmentId, submissionId, name, description} = data; const transaction = options.transaction; try { const result = await this.validator.validateSubmissionFiles(files, validationConfigurationId); @@ -960,14 +1050,109 @@ 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}); + + if (assignmentId && submissionId) { + return await this.replaceAssignmentSubmission( + { + files, + userId, + group, + validationConfigurationId, + assignmentId, + submissionId, + name, + description, + }, + {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, + }, + 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 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; + }; + + 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, 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, + name: name ?? null, + description: description ?? null, }, {transaction}); for (const file of files) { await this.addDocument( @@ -981,12 +1166,152 @@ class DocumentSocket extends Socket { {transaction} ); } + + if (assignmentId) { + transaction.afterCommit(async () => { + try { + await this.sendSubmissionUploadEmail({ + assignmentId, + submissionId: submission.id, + 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 + * @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 {string|null} [data.name] + * @param {string|null} [data.description] + * @param {Object} options + * @param {Object} options.transaction + * @returns {Promise} replacement information + */ + async replaceAssignmentSubmission(data, options) { + const {files, userId, group, validationConfigurationId, 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`); + } + + 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 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, + 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"].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} + ); + } + + transaction.afterCommit(async () => { + try { + await this.sendSubmissionUploadEmail({ + assignmentId: assignment.id, + submissionId: newSubmission.id, + 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 * diff --git a/backend/webserver/sockets/template.js b/backend/webserver/sockets/template.js index 95cd35ae5..e1dec7493 100644 --- a/backend/webserver/sockets/template.js +++ b/backend/webserver/sockets/template.js @@ -33,7 +33,7 @@ class TemplateSocket extends Socket { if (!data.name || !data.description || data.type === undefined || data.content === undefined) { throw new Error("Missing required fields: name, description, type, content"); } - if (!(await this.isAdmin()) && [1, 2, 3, 6].includes(data.type)) { + if (!(await this.isAdmin()) && [1, 2, 3, 6, 7].includes(data.type)) { throw new Error("Access denied: Only administrators can create email templates"); } @@ -195,8 +195,8 @@ class TemplateSocket extends Socket { */ async addPlaceholder(data, options) { if (!(await this.isAdmin())) throw new Error("Access denied"); - if (!data.templateType || ![1, 2, 3, 4, 5, 6].includes(data.templateType)) { - throw new Error("Template type is required and must be 1-6"); + 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.placeholderKey || !data.placeholderLabel || !data.placeholderType) { throw new Error("Missing required fields: placeholderKey, placeholderLabel, placeholderType"); @@ -232,7 +232,7 @@ class TemplateSocket extends Socket { async updatePlaceholder(data, options) { if (!(await this.isAdmin())) throw new Error("Access denied"); if (!data.id) throw new Error("Placeholder ID is required"); - + const updateData = {}; if (data.placeholderLabel !== undefined) updateData.placeholderLabel = data.placeholderLabel; if (data.placeholderType !== undefined) updateData.placeholderType = data.placeholderType; @@ -243,9 +243,9 @@ class TemplateSocket extends Socket { } return await this.models["placeholder"].updateById( - data.id, - updateData, - { transaction: options.transaction } + data.id, + updateData, + { transaction: options.transaction } ); } @@ -455,7 +455,7 @@ class TemplateSocket extends Socket { }); if (edits.length === 0) { - if ([1, 2, 3, 6].includes(template.type)) { + if ([1, 2, 3, 6, 7].includes(template.type)) { const templateContentModel = this.models["template_content"]; const langRow = await templateContentModel.findOne({ where: { templateId, language, deleted: false }, @@ -496,8 +496,8 @@ class TemplateSocket extends Socket { const editsDelta = new Delta(dbToDelta(edits)); const mergedDelta = baseContent.compose(editsDelta); - // Email templates (types 1, 2, 3, 6) must include all required placeholders - if ([1, 2, 3, 6].includes(template.type)) { + // Email templates (types 1, 2, 3, 6, 7) must include all required placeholders + if ([1, 2, 3, 6, 7].includes(template.type)) { const missing = await getMissingRequiredPlaceholders( { ops: mergedDelta.ops }, template.type, @@ -609,7 +609,7 @@ class TemplateSocket extends Socket { if (!data.sourceTemplateId) throw new Error("Source template ID is required"); const source = await this.models["template"].getById(data.sourceTemplateId); - if (!(await this.isAdmin()) && [1, 2, 3, 6].includes(source?.type)) { + if (!(await this.isAdmin()) && [1, 2, 3, 6, 7].includes(source?.type)) { throw new Error("Access denied: Only administrators can copy email templates"); } @@ -678,11 +678,11 @@ class TemplateSocket extends Socket { throw new Error("You can only delete templates that you own"); } - if (template.public && [1, 2, 3, 6].includes(template.type)) { + if (template.public && [1, 2, 3, 6, 7].includes(template.type)) { throw new Error("Public email templates cannot be deleted"); } - if ([1, 2, 3, 6].includes(template.type)) { + if ([1, 2, 3, 6, 7].includes(template.type)) { const usedBySettings = await this.models["setting"].findAll({ where: { key: {[Op.like]: "email.template.%"}, diff --git a/files/email-fallbacks/submissionUpload.txt b/files/email-fallbacks/submissionUpload.txt new file mode 100644 index 000000000..0e247c247 --- /dev/null +++ b/files/email-fallbacks/submissionUpload.txt @@ -0,0 +1,11 @@ +CARE - Submission {{eventLabel}} + +Hello, + +An assignment submission has been {{eventLabelLower}}. + +Assignment ID: {{assignmentId}} +Submission ID: {{submissionId}} + +Best regards, +The CARE Team 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 }}
-
- +
+
+ + +
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; }, } diff --git a/frontend/src/components/dashboard/Assignments.vue b/frontend/src/components/dashboard/Assignments.vue new file mode 100644 index 000000000..78a842900 --- /dev/null +++ b/frontend/src/components/dashboard/Assignments.vue @@ -0,0 +1,462 @@ + + + diff --git a/frontend/src/components/dashboard/Study.vue b/frontend/src/components/dashboard/Study.vue index 7d681af55..b49cdc2ff 100644 --- a/frontend/src/components/dashboard/Study.vue +++ b/frontend/src/components/dashboard/Study.vue @@ -25,6 +25,13 @@ text="Close All Studies" icon="x-octagon" @click="closeStudies" + /> + + @@ -90,6 +98,7 @@ import BulkCloseModal from "@/components/dashboard/study/BulkCloseModal.vue"; import StudyCloseModal from "@/components/dashboard/study/StudyCloseModal.vue"; import SavedTemplatesModal from "./study/SavedTemplatesModal.vue"; import OverViewModal from "./study/OverViewModal.vue"; +import PublishAssessmentModal from "./submission/PublishAssessmentModal.vue"; /** * Dashboard component for handling studies diff --git a/frontend/src/components/dashboard/Submissions.vue b/frontend/src/components/dashboard/Submissions.vue index 914516bd6..957b0264d 100644 --- a/frontend/src/components/dashboard/Submissions.vue +++ b/frontend/src/components/dashboard/Submissions.vue @@ -1,387 +1,224 @@ - - diff --git a/frontend/src/components/dashboard/Templates.vue b/frontend/src/components/dashboard/Templates.vue index 36216452d..b4ace81e7 100644 --- a/frontend/src/components/dashboard/Templates.vue +++ b/frontend/src/components/dashboard/Templates.vue @@ -98,8 +98,8 @@ return { ...t, typeName: this.typeName(t.type), - // Public email templates (types 1, 2, 3, 6) cannot be deleted - canDelete: !(t.public && [1, 2, 3, 6].includes(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)), isCopy, hasUpdate, sourceStatus, @@ -251,6 +251,7 @@ 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 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()" /> + + + + + + + + + + + + + diff --git a/frontend/src/components/dashboard/assignments/AssignmentModal.vue b/frontend/src/components/dashboard/assignments/AssignmentModal.vue new file mode 100644 index 000000000..159cf07f5 --- /dev/null +++ b/frontend/src/components/dashboard/assignments/AssignmentModal.vue @@ -0,0 +1,189 @@ + + + diff --git a/frontend/src/components/dashboard/assignments/AssignmentSubmissionsModal.vue b/frontend/src/components/dashboard/assignments/AssignmentSubmissionsModal.vue new file mode 100644 index 000000000..bee2d5df7 --- /dev/null +++ b/frontend/src/components/dashboard/assignments/AssignmentSubmissionsModal.vue @@ -0,0 +1,197 @@ + + + diff --git a/frontend/src/components/dashboard/assignments/AssignmentSubmissionsTable.vue b/frontend/src/components/dashboard/assignments/AssignmentSubmissionsTable.vue new file mode 100644 index 000000000..2524cea45 --- /dev/null +++ b/frontend/src/components/dashboard/assignments/AssignmentSubmissionsTable.vue @@ -0,0 +1,347 @@ + + + diff --git a/frontend/src/components/dashboard/assignments/AssignmentUploadModal.vue b/frontend/src/components/dashboard/assignments/AssignmentUploadModal.vue new file mode 100644 index 000000000..acd8489ee --- /dev/null +++ b/frontend/src/components/dashboard/assignments/AssignmentUploadModal.vue @@ -0,0 +1,345 @@ + + + + + diff --git a/frontend/src/components/dashboard/assignments/UserAssignmentsView.vue b/frontend/src/components/dashboard/assignments/UserAssignmentsView.vue new file mode 100644 index 000000000..48e4ef2c5 --- /dev/null +++ b/frontend/src/components/dashboard/assignments/UserAssignmentsView.vue @@ -0,0 +1,236 @@ + + + diff --git a/frontend/src/components/dashboard/settings/SettingItem.vue b/frontend/src/components/dashboard/settings/SettingItem.vue index 8e1c0618d..f8da75c89 100644 --- a/frontend/src/components/dashboard/settings/SettingItem.vue +++ b/frontend/src/components/dashboard/settings/SettingItem.vue @@ -93,7 +93,7 @@ export default { }, emailTemplates() { const allTemplates = this.$store.getters["table/template/getAll"] - .filter(t => !t.deleted && (t.type === 1 || t.type === 2 || t.type === 3 || t.type === 6)); + .filter(t => !t.deleted && (t.type === 1 || t.type === 2 || t.type === 3 || t.type === 6 || t.type === 7)); // Show only the user's own templates (includes copies since copies have userId === currentUser) const visibleTemplates = allTemplates.filter(t => t.userId === this.user?.id); @@ -128,6 +128,8 @@ export default { requiredType = 2; // Email - Study Session } else if (setting.key === "email.template.assignment") { requiredType = 3; // Email - Assignment + } else if (setting.key === "email.template.submissionUpload") { + requiredType = 7; // Email - Submission upload } else if (setting.key === "email.template.studyClosed") { requiredType = 6; // Email - Study Close } diff --git a/frontend/src/components/dashboard/submission/ImportModal.vue b/frontend/src/components/dashboard/submission/ImportModal.vue index 83c3a4aff..d5cb17c49 100644 --- a/frontend/src/components/dashboard/submission/ImportModal.vue +++ b/frontend/src/components/dashboard/submission/ImportModal.vue @@ -28,29 +28,8 @@ :max-table-height="400" /> - - -