Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/db/migrations/20260316122741-create-assignment.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ module.exports = {
allowNull: false,
defaultValue: false,
},
notifyOnSubmissionUpload: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true,
},
deleted: {
type: Sequelize.BOOLEAN,
allowNull: false,
Expand Down
Original file line number Diff line number Diff line change
@@ -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) }, {});
},
};
Original file line number Diff line number Diff line change
@@ -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 }, {});
},
};
13 changes: 13 additions & 0 deletions backend/db/models/assignment.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"], {
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 9 additions & 5 deletions backend/db/models/template.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]}}]},
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
);
Expand Down
5 changes: 5 additions & 0 deletions backend/utils/emailHelper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion backend/utils/templateResolver.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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<string[]>} Array of missing required placeholder keys (e.g. ['link'])
Expand Down
77 changes: 77 additions & 0 deletions backend/webserver/sockets/document.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`;

Expand Down Expand Up @@ -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<void>}
*/
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.
*
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading