From 465d916ce53b0d92437d50f12297e281d8b4951b Mon Sep 17 00:00:00 2001 From: Mohammad Elwan Date: Sat, 11 Apr 2026 18:20:34 +0200 Subject: [PATCH 1/7] feat : add setting and placeholders for submission upload email --- ...etting-email_template_submission_upload.js | 31 ++++++++ ...email_template_type_7_submission_upload.js | 70 +++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 backend/db/migrations/20260406152554-basic-setting-email_template_submission_upload.js create mode 100644 backend/db/migrations/20260406152625-basic-placeholder-email_template_type_7_submission_upload.js 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 00000000..16dfd08c --- /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 00000000..7b15b1fd --- /dev/null +++ b/backend/db/migrations/20260406152625-basic-placeholder-email_template_type_7_submission_upload.js @@ -0,0 +1,70 @@ +'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.', + }, + { + type: 7, + placeholderKey: 'link', + placeholderLabel: 'Submission link', + placeholderType: 'link', + placeholderDescription: 'Link to open the submission in the dashboard.', + required: true, + }, + { + type: 7, + placeholderKey: 'eventType', + placeholderLabel: 'Upload event', + placeholderType: 'text', + placeholderDescription: 'Whether the submission was first 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 }, {}); + }, +}; From 8776a4addb948742b1e306fcd1c050146f34689b Mon Sep 17 00:00:00 2001 From: Mohammad Elwan Date: Sat, 11 Apr 2026 18:25:03 +0200 Subject: [PATCH 2/7] feat : add submission upload email template type and placeholder handling --- backend/db/models/template.js | 14 +++++++++----- backend/utils/templateResolver.js | 13 ++++++++++++- backend/webserver/sockets/template.js | 26 +++++++++++++------------- 3 files changed, 34 insertions(+), 19 deletions(-) diff --git a/backend/db/models/template.js b/backend/db/models/template.js index 5e9a2c32..030bff0c 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/templateResolver.js b/backend/utils/templateResolver.js index 6b57fc28..af2faa8e 100644 --- a/backend/utils/templateResolver.js +++ b/backend/utils/templateResolver.js @@ -109,6 +109,17 @@ async function buildReplacementMap(context, models, options = {}) { replacements["~studyName~"] = context.studyName; } + // 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; } @@ -315,7 +326,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/template.js b/backend/webserver/sockets/template.js index 95cd35ae..e1dec749 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.%"}, From ba159ecda49034df2dc54be8d9753b1b7245c771 Mon Sep 17 00:00:00 2001 From: Mohammad Elwan Date: Sat, 11 Apr 2026 18:30:48 +0200 Subject: [PATCH 3/7] feat : notify assignment owner on submission upload with template or fallback --- backend/utils/emailHelper.js | 6 ++ backend/webserver/sockets/document.js | 79 ++++++++++++++++++++++ files/email-fallbacks/submissionUpload.txt | 12 ++++ 3 files changed, 97 insertions(+) create mode 100644 files/email-fallbacks/submissionUpload.txt diff --git a/backend/utils/emailHelper.js b/backend/utils/emailHelper.js index 9ca94ca6..113161b6 100644 --- a/backend/utils/emailHelper.js +++ b/backend/utils/emailHelper.js @@ -52,6 +52,12 @@ async function getEmailFallbackContent(key, variables = {}) { * @param {string} [context.studyName] - Study name (studyClosed) * @param {string} [context.userName] - User name (registration, passwordReset, verification) * @param {number} [context.tokenExpiry] - Token expiry hours + * @param {string} [context.eventType] - Human-readable upload event (submission upload HTML template) + * @param {string} [context.eventLabel] - Title-style label for submission upload fallback + * @param {string} [context.eventLabelLower] - Lowercase label for submission upload fallback + * @param {string} [context.submissionLink] - Submission dashboard URL 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/webserver/sockets/document.js b/backend/webserver/sockets/document.js index 6961f4cb..26c94429 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`; @@ -937,6 +938,58 @@ 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; + } + + 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 baseUrl = await this.models["setting"].get("system.baseUrl") || "localhost:3000"; + const eventLabel = eventType === "reupload" ? "Reuploaded" : "Uploaded"; + const eventLabelLower = eventType === "reupload" ? "reuploaded" : "uploaded"; + const submissionLink = `http://${baseUrl}/dashboard/submission/${submissionId}`; + const eventTypeDisplay = eventType === "reupload" ? "Reupload" : "First upload"; + + const emailContent = await getEmailContent( + "email.template.submissionUpload", + "submissionUpload", + { + userId: assignment.userId, + assignmentName: assignment.title, + assignmentId, + submissionId, + baseUrl, + link: submissionLink, + eventType: eventTypeDisplay, + eventLabel, + eventLabelLower, + submissionLink, + }, + this.models, + this.logger + ); + + await this.server.sendMail(user.email, emailContent.subject, emailContent.body, {isHtml: emailContent.isHtml}); + } + /** * Upload a single submission to the DB. * @@ -1072,6 +1125,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); @@ -1176,6 +1243,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/files/email-fallbacks/submissionUpload.txt b/files/email-fallbacks/submissionUpload.txt new file mode 100644 index 00000000..305126cd --- /dev/null +++ b/files/email-fallbacks/submissionUpload.txt @@ -0,0 +1,12 @@ +CARE - Submission {{eventLabel}} + +Hello, + +An assignment submission has been {{eventLabelLower}}. + +Assignment ID: {{assignmentId}} +Submission ID: {{submissionId}} +View submission: {{submissionLink}} + +Best regards, +The CARE Team From fbd2d7d000a2d4d4a489e261339eb1ed4d62bbc0 Mon Sep 17 00:00:00 2001 From: Mohammad Elwan Date: Sat, 11 Apr 2026 18:31:29 +0200 Subject: [PATCH 4/7] feat : support submission upload email template in settings and template editor --- frontend/src/components/dashboard/Templates.vue | 5 +++-- .../src/components/dashboard/settings/SettingItem.vue | 4 +++- .../dashboard/templates/PublicTemplatesModal.vue | 1 + .../components/dashboard/templates/PublishModal.vue | 2 +- frontend/src/components/editor/Editor.vue | 4 ++-- .../editor/sidebar/TemplateConfigurator.vue | 11 ++++++++++- 6 files changed, 20 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/dashboard/Templates.vue b/frontend/src/components/dashboard/Templates.vue index 36216452..b4ace81e 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 c8c49f48..3d3cc3e2 100644 --- a/frontend/src/components/dashboard/settings/SettingItem.vue +++ b/frontend/src/components/dashboard/settings/SettingItem.vue @@ -82,7 +82,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); @@ -115,6 +115,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 cd121e63..455af567 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 afdfa536..16fe8840 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 aeddded2..2b566f0e 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 a1ab63aa..0d35db5c 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,14 @@ 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.", + link: "Link to the submission in the dashboard.", + eventType: "Whether this was a first upload or a reupload.", + assignmentId: "Internal assignment ID.", + submissionId: "Internal submission ID.", + }, }; return (longDescriptions[type] && longDescriptions[type][key]) || placeholder.description; From 16cccf60c39085dd79ef91308224676b044f11c2 Mon Sep 17 00:00:00 2001 From: Mohammad Elwan Date: Sat, 11 Apr 2026 19:44:46 +0200 Subject: [PATCH 5/7] feat : add notify on submission upload field to assignment --- .../migrations/20260316122741-create-assignment.js | 5 +++++ backend/db/models/assignment.js | 13 +++++++++++++ backend/utils/emailHelper.js | 3 +-- backend/webserver/sockets/document.js | 12 +++++------- .../editor/sidebar/TemplateConfigurator.vue | 3 +-- 5 files changed, 25 insertions(+), 11 deletions(-) diff --git a/backend/db/migrations/20260316122741-create-assignment.js b/backend/db/migrations/20260316122741-create-assignment.js index 41f0ee19..5dff1651 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/models/assignment.js b/backend/db/models/assignment.js index 63741daa..70d903ab 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: "Email owner on submission upload:", + type: "switch", + default: false, + required: false, + help: "If enabled, the assignment owner receives 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/utils/emailHelper.js b/backend/utils/emailHelper.js index 3e9fc9d2..0255691c 100644 --- a/backend/utils/emailHelper.js +++ b/backend/utils/emailHelper.js @@ -53,10 +53,9 @@ 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] - Human-readable upload event (submission upload HTML template) + * @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 {string} [context.submissionLink] - Submission dashboard URL 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 diff --git a/backend/webserver/sockets/document.js b/backend/webserver/sockets/document.js index b31859f6..d118d385 100644 --- a/backend/webserver/sockets/document.js +++ b/backend/webserver/sockets/document.js @@ -993,17 +993,18 @@ class DocumentSocket extends Socket { 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 baseUrl = await this.models["setting"].get("system.baseUrl") || "localhost:3000"; const eventLabel = eventType === "reupload" ? "Reuploaded" : "Uploaded"; const eventLabelLower = eventType === "reupload" ? "reuploaded" : "uploaded"; - const submissionLink = `http://${baseUrl}/dashboard/submission/${submissionId}`; - const eventTypeDisplay = eventType === "reupload" ? "Reupload" : "First upload"; const emailContent = await getEmailContent( "email.template.submissionUpload", @@ -1013,12 +1014,9 @@ class DocumentSocket extends Socket { assignmentName: assignment.title, assignmentId, submissionId, - baseUrl, - link: submissionLink, - eventType: eventTypeDisplay, + eventType: eventLabelLower, eventLabel, eventLabelLower, - submissionLink, }, this.models, this.logger diff --git a/frontend/src/components/editor/sidebar/TemplateConfigurator.vue b/frontend/src/components/editor/sidebar/TemplateConfigurator.vue index 0d35db5c..1be0a92a 100644 --- a/frontend/src/components/editor/sidebar/TemplateConfigurator.vue +++ b/frontend/src/components/editor/sidebar/TemplateConfigurator.vue @@ -193,8 +193,7 @@ 7: { // Email - Submission upload username: "The assignment owner receiving this email (not the user who uploaded).", assignmentName: "The title of the assignment.", - link: "Link to the submission in the dashboard.", - eventType: "Whether this was a first upload or a reupload.", + eventType: "Lowercase sentence text: \"uploaded\" or \"reuploaded\".", assignmentId: "Internal assignment ID.", submissionId: "Internal submission ID.", }, From e6c4f03481ce4cf9a61e1334b0dcc32998301650 Mon Sep 17 00:00:00 2001 From: Mohammad Elwan Date: Sat, 11 Apr 2026 19:45:22 +0200 Subject: [PATCH 6/7] feat : require assignment name and drop link placeholder for submission upload emails --- ...laceholder-email_template_type_7_submission_upload.js | 9 +-------- files/email-fallbacks/submissionUpload.txt | 1 - 2 files changed, 1 insertion(+), 9 deletions(-) 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 index 7b15b1fd..681a7aba 100644 --- 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 @@ -16,13 +16,6 @@ const placeholders = [ placeholderLabel: 'Assignment name', placeholderType: 'text', placeholderDescription: 'Name of the assignment.', - }, - { - type: 7, - placeholderKey: 'link', - placeholderLabel: 'Submission link', - placeholderType: 'link', - placeholderDescription: 'Link to open the submission in the dashboard.', required: true, }, { @@ -30,7 +23,7 @@ const placeholders = [ placeholderKey: 'eventType', placeholderLabel: 'Upload event', placeholderType: 'text', - placeholderDescription: 'Whether the submission was first uploaded or reuploaded.', + placeholderDescription: 'Lowercase: "uploaded" or "reuploaded".', }, { type: 7, diff --git a/files/email-fallbacks/submissionUpload.txt b/files/email-fallbacks/submissionUpload.txt index 305126cd..0e247c24 100644 --- a/files/email-fallbacks/submissionUpload.txt +++ b/files/email-fallbacks/submissionUpload.txt @@ -6,7 +6,6 @@ An assignment submission has been {{eventLabelLower}}. Assignment ID: {{assignmentId}} Submission ID: {{submissionId}} -View submission: {{submissionLink}} Best regards, The CARE Team From 8c30b5973cf077b6a252305538f83c66679a7dc0 Mon Sep 17 00:00:00 2001 From: Mohammad Elwan Date: Sat, 11 Apr 2026 19:54:18 +0200 Subject: [PATCH 7/7] refactor: reword notify-on-submission-upload toggle label and help --- backend/db/models/assignment.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/db/models/assignment.js b/backend/db/models/assignment.js index 70d903ab..13f22910 100644 --- a/backend/db/models/assignment.js +++ b/backend/db/models/assignment.js @@ -131,11 +131,11 @@ module.exports = (sequelize, DataTypes) => { }, { key: "notifyOnSubmissionUpload", - label: "Email owner on submission upload:", + label: "Notify on Submission Upload:", type: "switch", default: false, required: false, - help: "If enabled, the assignment owner receives an email when a student uploads or re-uploads a submission.", + help: "If enabled, sends an email when a student uploads or re-uploads a submission.", }, ]; static associate(models) {