diff --git a/backend/db/migrations/20260316122741-create-assignment.js b/backend/db/migrations/20260316122741-create-assignment.js index 41f0ee19f..5dff16512 100644 --- a/backend/db/migrations/20260316122741-create-assignment.js +++ b/backend/db/migrations/20260316122741-create-assignment.js @@ -105,6 +105,11 @@ module.exports = { allowNull: false, defaultValue: false, }, + notifyOnSubmissionUpload: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true, + }, deleted: { type: Sequelize.BOOLEAN, allowNull: false, 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 index 63741daaa..13f229102 100644 --- a/backend/db/models/assignment.js +++ b/backend/db/models/assignment.js @@ -129,6 +129,14 @@ module.exports = (sequelize, DataTypes) => { 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"], { @@ -177,6 +185,11 @@ module.exports = (sequelize, DataTypes) => { }, parentAssignmentId: DataTypes.INTEGER, allowReUpload: DataTypes.BOOLEAN, + notifyOnSubmissionUpload: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true, + }, closed: DataTypes.DATE, deleted: DataTypes.BOOLEAN, deletedAt: DataTypes.DATE, 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/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/sockets/document.js b/backend/webserver/sockets/document.js index 95f9741a2..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`; @@ -974,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. * @@ -1115,6 +1166,20 @@ 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); @@ -1229,6 +1294,18 @@ class DocumentSocket extends Socket { ); } + 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, 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/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/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/templates/PublicTemplatesModal.vue b/frontend/src/components/dashboard/templates/PublicTemplatesModal.vue index cd121e63e..455af5677 100644 --- a/frontend/src/components/dashboard/templates/PublicTemplatesModal.vue +++ b/frontend/src/components/dashboard/templates/PublicTemplatesModal.vue @@ -141,6 +141,7 @@ export default { case 4: return "Document - General"; case 5: return "Document - Study"; case 6: return "Email - Study Close"; + case 7: return "Email - Submission upload"; default: return "Unknown"; } }, diff --git a/frontend/src/components/dashboard/templates/PublishModal.vue b/frontend/src/components/dashboard/templates/PublishModal.vue index afdfa5363..16fe8840d 100644 --- a/frontend/src/components/dashboard/templates/PublishModal.vue +++ b/frontend/src/components/dashboard/templates/PublishModal.vue @@ -82,7 +82,7 @@ export default { return this.$store.getters["table/template/get"](this.id); }, isEmailTemplate() { - return this.template && [1, 2, 3, 6].includes(this.template.type); + return this.template && [1, 2, 3, 6, 7].includes(this.template.type); }, visibilityMessage() { return this.isEmailTemplate diff --git a/frontend/src/components/editor/Editor.vue b/frontend/src/components/editor/Editor.vue index aeddded23..2b566f0e5 100644 --- a/frontend/src/components/editor/Editor.vue +++ b/frontend/src/components/editor/Editor.vue @@ -203,10 +203,10 @@ export default { return null; }, hasPlaceholders() { - // Only email templates (types 1, 2, 3, 6) have placeholders + // Only email templates (types 1, 2, 3, 6, 7) have placeholders // Document templates (types 4, 5) have no placeholders if (!this.template) return false; - return [1, 2, 3, 6].includes(this.template.type); + return [1, 2, 3, 6, 7].includes(this.template.type); }, readOnlyOverwrite() { if (this.sidebarContent === 'history' ) { diff --git a/frontend/src/components/editor/sidebar/TemplateConfigurator.vue b/frontend/src/components/editor/sidebar/TemplateConfigurator.vue index a1ab63aae..1be0a92aa 100644 --- a/frontend/src/components/editor/sidebar/TemplateConfigurator.vue +++ b/frontend/src/components/editor/sidebar/TemplateConfigurator.vue @@ -91,6 +91,7 @@ 4: { placeholders: [] }, // Document - General (no placeholders) 5: { placeholders: [] }, // Document - Study (no placeholders) 6: { placeholders: [] }, // Email - Study Close + 7: { placeholders: [] }, // Email - Submission upload }, placeholderCounts: {}, invalidPlaceholders: [], @@ -105,7 +106,7 @@ }, templateTypeName() { if (!this.templateType) return "Unknown"; - const types = { 1: "Email - General", 2: "Email - Study Session", 3: "Email - Assignment", 4: "Document - General", 5: "Document - Study", 6: "Email - Study Close" }; + const types = { 1: "Email - General", 2: "Email - Study Session", 3: "Email - Assignment", 4: "Document - General", 5: "Document - Study", 6: "Email - Study Close", 7: "Email - Submission upload" }; return types[this.templateType] || "Unknown"; }, availablePlaceholders() { @@ -189,6 +190,13 @@ username: "The username of the session owner who had an open session when the study was closed.", studyName: "The name of the study that was closed.", }, + 7: { // Email - Submission upload + username: "The assignment owner receiving this email (not the user who uploaded).", + assignmentName: "The title of the assignment.", + eventType: "Lowercase sentence text: \"uploaded\" or \"reuploaded\".", + assignmentId: "Internal assignment ID.", + submissionId: "Internal submission ID.", + }, }; return (longDescriptions[type] && longDescriptions[type][key]) || placeholder.description;