diff --git a/backend/db/migrations/20251210095020-extend-nav_element-templates.js b/backend/db/migrations/20251210095020-extend-nav_element-templates.js new file mode 100644 index 000000000..b1c10ca79 --- /dev/null +++ b/backend/db/migrations/20251210095020-extend-nav_element-templates.js @@ -0,0 +1,50 @@ +'use strict'; + +const navElements=[ + { + name: "Templates", + groupId: "Default", + icon: "file-earmark-ruled", + order: 14, + admin: false, + path: "templates", + component: "Templates", + }, +]; + +/** @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/20251210095029-extend-user_rights.js b/backend/db/migrations/20251210095029-extend-user_rights.js new file mode 100644 index 000000000..c0c577ce1 --- /dev/null +++ b/backend/db/migrations/20251210095029-extend-user_rights.js @@ -0,0 +1,33 @@ +'use strict'; + +const userRights=[ + { + name: "frontend.dashboard.templates.view", + description: "access to view templates in the dashboard", + }, +]; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.bulkInsert( + "user_right", + userRights.map((right) => { + right["createdAt"] = new Date(); + right["updatedAt"] = new Date(); + return right; + }), + {} + ); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.bulkDelete( + "user_right", + { + name: userRights.map((r) => r.name), + }, + {} + ); + }, +}; diff --git a/backend/db/migrations/20251210095035-extend-role_rights.js b/backend/db/migrations/20251210095035-extend-role_rights.js new file mode 100644 index 000000000..f847d454d --- /dev/null +++ b/backend/db/migrations/20251210095035-extend-role_rights.js @@ -0,0 +1,44 @@ +'use strict'; + +const roleRights=[ + { + role:"user", + userRightName:"frontend.dashboard.templates.view" + } +] + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + 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.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) }, + {} + ); + }, +}; diff --git a/backend/db/migrations/20251210121131-create-template.js b/backend/db/migrations/20251210121131-create-template.js new file mode 100644 index 000000000..58845d667 --- /dev/null +++ b/backend/db/migrations/20251210121131-create-template.js @@ -0,0 +1,75 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('template', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + name: { + type: Sequelize.STRING, + allowNull: false, + }, + description: { + type: Sequelize.TEXT, + allowNull: false, + }, + userId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'user', + key: 'id', + }, + onUpdate: 'CASCADE', + }, + published: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + type: { + type: Sequelize.INTEGER, + allowNull: false, + }, + sourceId: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + }, + defaultLanguage: { + type: Sequelize.STRING, + allowNull: false, + defaultValue: 'en', + }, + deleted: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('template'); + }, +}; diff --git a/backend/db/migrations/20251227183831-create-placeholder.js b/backend/db/migrations/20251227183831-create-placeholder.js new file mode 100644 index 000000000..558435208 --- /dev/null +++ b/backend/db/migrations/20251227183831-create-placeholder.js @@ -0,0 +1,106 @@ +'use strict'; + +/** + * Create placeholder table and seed placeholder definitions + * for all placeholder types (currently template email types). + * + * @type {import('sequelize-cli').Migration} + */ + +const placeholders = [ + // Type 1: Email - General + { type: 1, placeholderKey: 'username', placeholderLabel: 'Recipient username', placeholderType: 'text', placeholderDescription: 'Username of the email recipient.' }, + { type: 1, placeholderKey: 'firstName', placeholderLabel: 'Recipient first name', placeholderType: 'text', placeholderDescription: 'First name of the email recipient.' }, + { type: 1, placeholderKey: 'lastName', placeholderLabel: 'Recipient last name', placeholderType: 'text', placeholderDescription: 'Last name of the email recipient.' }, + { type: 1, placeholderKey: 'link', placeholderLabel: 'Link', placeholderType: 'link', placeholderDescription: 'Action link in the email (e.g. reset or verification URL).', required: true }, + + // Type 2: Email - Study Session + { type: 2, placeholderKey: 'username', placeholderLabel: 'Recipient username', placeholderType: 'text', placeholderDescription: 'Submission owner receiving this session email.' }, + { type: 2, placeholderKey: 'link', placeholderLabel: 'Review link', placeholderType: 'link', placeholderDescription: 'Link to open the review in read-only mode.', required: true }, + + // Type 3: Email - Assignment + { type: 3, placeholderKey: 'username', placeholderLabel: 'Recipient username', placeholderType: 'text', placeholderDescription: 'Username of the assigned reviewer.' }, + { type: 3, placeholderKey: 'assignmentType', placeholderLabel: 'Assignment type', placeholderType: 'text', placeholderDescription: 'Whether the assignment is document or submission.' }, + { type: 3, placeholderKey: 'assignmentName', placeholderLabel: 'Assignment name', placeholderType: 'text', placeholderDescription: 'Name of the assignment or study.' }, + { type: 3, placeholderKey: 'link', placeholderLabel: 'Assignment link', placeholderType: 'link', placeholderDescription: 'Link to start the assigned review session.', required: true }, + + // Type 6: Email - Study Close + { type: 6, placeholderKey: 'username', placeholderLabel: 'Recipient username', placeholderType: 'text', placeholderDescription: 'Username of the session owner with an open session at study close.' }, + { type: 6, placeholderKey: 'studyName', placeholderLabel: 'Study name', placeholderType: 'text', placeholderDescription: 'Name of the study that was closed.', required: true }, +]; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('placeholder', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + type: { + type: Sequelize.INTEGER, + allowNull: false, + }, + placeholderKey: { + type: Sequelize.STRING, + allowNull: false, + }, + placeholderLabel: { + type: Sequelize.STRING, + allowNull: false, + }, + placeholderType: { + type: Sequelize.STRING, + allowNull: false, + }, + placeholderDescription: { + type: Sequelize.TEXT, + allowNull: true, + }, + required: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + deleted: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + }); + + // Seed placeholder definitions + 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.dropTable('placeholder'); + }, +}; diff --git a/backend/db/migrations/20251230211246-basic-setting-email_templates.js b/backend/db/migrations/20251230211246-basic-setting-email_templates.js new file mode 100644 index 000000000..70109ffd5 --- /dev/null +++ b/backend/db/migrations/20251230211246-basic-setting-email_templates.js @@ -0,0 +1,70 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ + +const settings = [ + { + key: "email.template.passwordReset", + value: "", + type: "number", + description: "Template type for password reset emails (Email - General). Leave empty to use default hardcoded email." + }, + { + key: "email.template.verification", + value: "", + type: "number", + description: "Template type for email verification emails (Email - General). Leave empty to use default hardcoded email." + }, + { + key: "email.template.registration", + value: "", + type: "number", + description: "Template type for registration welcome emails (Email - General). Leave empty to use default hardcoded email." + }, + { + key: "email.template.sessionStart", + value: "", + type: "number", + description: "Template type for session start emails (Email - Study Session). Leave empty to use default hardcoded email." + }, + { + key: "email.template.sessionFinish", + value: "", + type: "number", + description: "Template type for session finish emails (Email - Study Session). Leave empty to use default hardcoded email." + }, + { + key: "email.template.assignment", + value: "", + type: "number", + description: "Template type for assignment notification emails (Email - Assignment). Leave empty to use default hardcoded email." + }, + { + key: "email.template.studyClosed", + value: "", + type: "number", + description: "Template type for study closed emails (Email - Study Close). Leave empty to use default hardcoded email." + }, +]; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.bulkInsert( + 'setting', + settings.map((t) => ({ + ...t, + createdAt: new Date(), + updatedAt: new Date(), + })), + { returning: true } + ); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.bulkDelete( + "setting", + { key: settings.map((t) => t.key) }, + {} + ); + }, +}; diff --git a/backend/db/migrations/20260116185403-create-template-edit.js b/backend/db/migrations/20260116185403-create-template-edit.js new file mode 100644 index 000000000..84842038c --- /dev/null +++ b/backend/db/migrations/20260116185403-create-template-edit.js @@ -0,0 +1,78 @@ +"use strict"; + +/** + * Migration to create template_edit table. + * + * This table stores draft edits for templates (like document_edit does for documents). + * Enables stable content for resolution/viewing while owner sees live edits. + * + * @type {import('sequelize-cli').Migration} + */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable("template_edit", { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER, + }, + templateId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { model: "template", key: "id" }, + onDelete: "CASCADE", + }, + userId: { + type: Sequelize.INTEGER, + allowNull: true, + }, + draft: { + type: Sequelize.BOOLEAN, + defaultValue: true, + }, + offset: { + type: Sequelize.INTEGER, + }, + operationType: { + type: Sequelize.INTEGER, // 0: Insert, 1: Delete, 2: Attribute-Change + }, + span: { + type: Sequelize.INTEGER, + }, + text: { + type: Sequelize.STRING, + }, + attributes: { + type: Sequelize.JSONB, + }, + order: { + type: Sequelize.INTEGER, + }, + language: { + type: Sequelize.STRING, + allowNull: false, + }, + deleted: { + type: Sequelize.BOOLEAN, + defaultValue: false, + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE, + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE, + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true, + }, + }); + }, + + async down(queryInterface) { + await queryInterface.dropTable("template_edit"); + }, +}; diff --git a/backend/db/migrations/20260118181103-extend-study-enable-email-notifications.js b/backend/db/migrations/20260118181103-extend-study-enable-email-notifications.js new file mode 100644 index 000000000..19080df56 --- /dev/null +++ b/backend/db/migrations/20260118181103-extend-study-enable-email-notifications.js @@ -0,0 +1,21 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ + +/** + * Add enableEmailNotifications column to the study table. + * Controls session start/finish emails. + */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('study', 'enableEmailNotifications', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.removeColumn('study', 'enableEmailNotifications'); + }, +}; diff --git a/backend/db/migrations/20260130202032-create-template-content.js b/backend/db/migrations/20260130202032-create-template-content.js new file mode 100644 index 000000000..fa048ae13 --- /dev/null +++ b/backend/db/migrations/20260130202032-create-template-content.js @@ -0,0 +1,67 @@ +'use strict'; + +/** + * Create template_content table for multi-language template content. + * + * @type {import('sequelize-cli').Migration} + */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('template_content', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER, + }, + templateId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { model: 'template', key: 'id' }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + language: { + type: Sequelize.STRING, + allowNull: false, + }, + content: { + type: Sequelize.JSONB, + allowNull: false, + }, + deleted: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.fn('NOW'), + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.fn('NOW'), + }, + }); + + await queryInterface.addIndex('template_content', ['templateId', 'language'], { + unique: true, + name: 'template_content_template_id_language_unique', + }); + }, + + async down(queryInterface) { + await queryInterface.removeIndex( + 'template_content', + 'template_content_template_id_language_unique' + ); + await queryInterface.dropTable('template_content'); + }, +}; diff --git a/backend/db/models/placeholder.js b/backend/db/models/placeholder.js new file mode 100644 index 000000000..538d36cae --- /dev/null +++ b/backend/db/models/placeholder.js @@ -0,0 +1,44 @@ +'use strict'; +const MetaModel = require("../MetaModel.js"); + +module.exports = (sequelize, DataTypes) => { + /** + * Placeholder model + * Stores placeholder definitions for different placeholders (e.g. template placeholders). + */ + class Placeholder extends MetaModel { + static autoTable = true; + + /** + * Helper method for defining associations. + * This method is not a part of Sequelize lifecycle. + * The `models/index` file will call this method automatically. + */ + static associate(models) { + // No association + } + } + + Placeholder.init( + { + type: DataTypes.INTEGER, + placeholderKey: DataTypes.STRING, + placeholderLabel: DataTypes.STRING, + placeholderDescription: DataTypes.TEXT, + placeholderType: DataTypes.STRING, + required: DataTypes.BOOLEAN, + deleted: DataTypes.BOOLEAN, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE, + deletedAt: DataTypes.DATE, + }, + { + sequelize, + modelName: "placeholder", + tableName: "placeholder", + } + ); + + return Placeholder; +}; + diff --git a/backend/db/models/study.js b/backend/db/models/study.js index b1521346e..bd6e3d4a2 100644 --- a/backend/db/models/study.js +++ b/backend/db/models/study.js @@ -103,6 +103,12 @@ module.exports = (sequelize, DataTypes) => { help: "This text will be displayed at the beginning of the user study!", type: "editor", required: true + }, { + key: "enableEmailNotifications", + label: "Send email notification on session start/finish", + type: "switch", + default: false, + help: "When enabled, the study owner receives an email each time a participant starts or finishes a session." }, { key: "timeLimit", type: "slider", @@ -114,7 +120,8 @@ module.exports = (sequelize, DataTypes) => { max: 180, step: 1, default: 0, - textMapping: [{from: 0, to: "unlimited"}] + textMapping: [{from: 0, to: "unlimited"}], + advanced: true }, { key: "limitSessions", type: "slider", @@ -126,7 +133,8 @@ module.exports = (sequelize, DataTypes) => { max: 200, step: 1, default: 0, - textMapping: [{from: 0, to: "unlimited"}] + textMapping: [{from: 0, to: "unlimited"}], + advanced: true }, { key: "limitSessionsPerUser", type: "slider", @@ -138,42 +146,48 @@ module.exports = (sequelize, DataTypes) => { max: 200, step: 1, default: 0, - textMapping: [{from: 0, to: "unlimited"}] + textMapping: [{from: 0, to: "unlimited"}], + advanced: true + }, { + key: "start", + label: "Study sessions can't start before", + type: "datetime", + size: 6, + default: null, + advanced: true + }, { + key: "end", + label: "Study sessions can't start after:", + type: "datetime", + size: 6, + default: null, + advanced: true }, { key: "collab", label: "Should the study be collaborative?", type: "switch", default: false, - }, - { - key: "anonymize", - label: "Should the comments be anonymized?", - type: "switch", - default: false, - }, { - key: "resumable", - label: "Should the study be resumable?", - type: "switch", - default: false, - }, { - key: "multipleSubmit", - label: "Allow multiple submissions?", - type: "switch", - default: false, - help: "Specify whether participants can submit their study multiple times." - }, { - key: "start", - label: "Study sessions can't start before", - type: "datetime", - size: 6, - default: null, - }, { - key: "end", - label: "Study sessions can't start after:", - type: "datetime", - size: 6, - default: null, - },]; + advanced: true + }, { + key: "anonymize", + label: "Should the comments be anonymized?", + type: "switch", + default: false, + advanced: true + }, { + key: "resumable", + label: "Should the study be resumable?", + type: "switch", + default: false, + advanced: true + }, { + key: "multipleSubmit", + label: "Allow multiple submissions?", + type: "switch", + default: false, + help: "Specify whether participants can submit their study multiple times.", + advanced: true + },]; /** * Check if a study is still open @@ -238,6 +252,12 @@ module.exports = (sequelize, DataTypes) => { const workflowStep = workflowSteps[i]; const stepDocument = options.context.stepDocuments.find(doc => doc.id === workflowStep.id); const customConfig = stepDocument?.configuration || {}; + + // Create context object that includes study data + const studyContext = { + ...study.dataValues || study + }; + const plainStudyStep = await sequelize.models.study_step.add({ studyId: study.id, stepNumber: i + 1, @@ -248,7 +268,7 @@ module.exports = (sequelize, DataTypes) => { allowBackward: workflowStep.allowBackward, studyStepDocument: null, configuration: customConfig - }, { transaction: options.transaction, context: study, doNotDuplicate: options.doNotDuplicate }); + }, { transaction: options.transaction, context: studyContext, doNotDuplicate: options.doNotDuplicate}); const studyStep = await sequelize.models.study_step.findByPk(plainStudyStep.id, { transaction: options.transaction @@ -381,6 +401,7 @@ module.exports = (sequelize, DataTypes) => { createdAt: DataTypes.DATE, projectId: DataTypes.INTEGER, anonymize: DataTypes.BOOLEAN, + enableEmailNotifications: DataTypes.BOOLEAN, parentStudyId: { type: DataTypes.INTEGER, allowNull: true, diff --git a/backend/db/models/study_step.js b/backend/db/models/study_step.js index 4a89fcd9e..d98815a17 100644 --- a/backend/db/models/study_step.js +++ b/backend/db/models/study_step.js @@ -2,6 +2,7 @@ const MetaModel = require("../MetaModel.js"); const path = require("path"); const {promises: fs} = require("fs"); +const {applyTemplateToDocument} = require("../../utils/documentTemplateHelper.js"); const UPLOAD_PATH = `${__dirname}/../../../files`; const stepTypes = Object.freeze({ @@ -114,6 +115,8 @@ module.exports = (sequelize, DataTypes) => { hideInFrontend: true }, {transaction: options.transaction}); + let documentCopied = false; + if (data.workflowStepId) { const workflowStep = await sequelize.models.workflow_step.getById(data.workflowStepId, {transaction: options.transaction}); @@ -170,11 +173,25 @@ module.exports = (sequelize, DataTypes) => { await sequelize.models.document_edit.bulkCreate(newEdits, {transaction: options.transaction}); } - + documentCopied = true; } } } + // Resolve Type 5 template at study creation time (per-step template selection) + const configuredTemplateId = data.configuration && data.configuration.documentTemplateId + ? data.configuration.documentTemplateId + : null; + + if (!documentCopied && configuredTemplateId) { + await applyTemplateToDocument( + newDocument, + configuredTemplateId, + sequelize.models, + {transaction: options.transaction} + ); + } + data.documentId = newDocument.id; } else { diff --git a/backend/db/models/template.js b/backend/db/models/template.js new file mode 100644 index 000000000..12ea71679 --- /dev/null +++ b/backend/db/models/template.js @@ -0,0 +1,456 @@ +'use strict'; +const MetaModel = require("../MetaModel.js"); + +module.exports = (sequelize, DataTypes) => { + /** + * Template model + * Stores reusable templates + */ + class Template extends MetaModel { + static autoTable = true; + + /** + * Get the user filter for templates based on userId and admin status + * This can be used by Socket.js to apply filtering consistently + * @param {number} userId - The user ID + * @param {boolean} isAdmin - Whether the user is an admin + * @returns {Object} Sequelize filter object + */ + static getUserFilter(userId, isAdmin) { + const {Op} = require("sequelize"); + + if (isAdmin) { + // Admins: own templates (all types) OR published templates from others + return {[Op.or]: [{userId: userId}, {published: true}]}; + } else { + // Non-admins: own templates (types 4, 5 only) OR published templates from others (types 4, 5 only) + // Email templates (types 1, 2, 3, 6) are admin-only + return { + [Op.or]: [ + {[Op.and]: [{userId: userId}, {type: {[Op.in]: [4, 5]}}]}, + {[Op.and]: [{published: true}, {type: {[Op.in]: [4, 5]}}]} + ] + }; + } + } + + /** + * Override getAutoTable to apply custom filtering for templates: + * - All users (including admins): own templates OR published templates from others + * - Non-admins: exclude email templates (types 1, 2, 3, 6) - admin-only + */ + static async getAutoTable(filterList = [], userId = null, attributes = null) { + const {Op} = require("sequelize"); + + let filter = {deleted: false}; + + for (let filterItem of filterList) { + if (filterItem.key in this.getAttributes() && filterItem.key !== 'userId') { + if (filterItem.values && filterItem.values.length > 0) { + filter[filterItem.key] = {[Op.or]: filterItem.values}; + } else { + if (filterItem.type === "not") { + filter[filterItem.key] = {[Op.not]: filterItem.value}; + } else { + filter[filterItem.key] = filterItem.value; + } + } + } + } + + if (userId && 'userId' in this.getAttributes()) { + let isAdmin = false; + try { + const roleIds = await sequelize.models.user_role_matching.getUserRolesById(userId); + isAdmin = await sequelize.models.user_role_matching.isAdminInUserRoles(roleIds); + } catch (err) { + console.warn("Could not determine admin status for user", userId, err); + } + + const userFilter = this.getUserFilter(userId, isAdmin); + // Non-admins: also include templates that are the source of their copies (so "Update available" works) + if (!isAdmin && userFilter[Op.or]) { + const copies = await Template.findAll({ + where: { userId, sourceId: { [Op.ne]: null }, deleted: false }, + attributes: ["sourceId"], + raw: true, + }); + const sourceIds = copies.map((c) => c.sourceId).filter(Boolean); + if (sourceIds.length > 0) { + userFilter[Op.or] = Array.isArray(userFilter[Op.or]) + ? [...userFilter[Op.or], { id: { [Op.in]: sourceIds } }] + : [{ id: { [Op.in]: sourceIds } }]; + } + } + Object.assign(filter, userFilter); + } + + let options = {where: filter, raw: true}; + if (attributes && attributes.length > 0) { + options.attributes = [...new Set([...attributes, 'id'])]; + } + + return await this.findAll(options); + } + + static fields = [ + { + key: "name", + label: "Name", + type: "text", + required: true, + }, + { + key: "description", + label: "Description", + type: "textarea", + required: true + }, + // Published field is excluded from form (handled via table action buttons only) + { + key: "type", + label: "Type", + type: "select", + required: true, + options: [ + { + name: "Choose type", + value: null, + disabled: true + }, + { + name: "Email - General", + value: 1 + }, + { + name: "Email - Study Session", + value: 2 + }, + { + name: "Email - Assignment", + value: 3 + }, + { + name: "Email - Study Close", + value: 6 + }, + { + name: "Document - General", + value: 4 + }, + { + name: "Document - Study", + value: 5 + } + ], + }, + { + key: "defaultLanguage", + label: "Default language", + type: "select", + required: true, + options: [ + { name: "English", value: "en" }, + { name: "Deutsch", value: "de" }, + { name: "Français", value: "fr" }, + ], + }, + ]; + /** + * Copy a published template for a different user. + * Creates a new template row with sourceId linking to the original, + * and copies all template_content rows. + * + * @param {number} sourceTemplateId - The ID of the source template to copy + * @param {number} userId - The ID of the user creating the copy + * @param {Object} [overrides={}] - Optional property overrides (e.g. name, force) + * @param {Object} options - Database options including transaction + * @returns {Promise} The copied template + */ + static async copyTemplate(sourceTemplateId, userId, overrides = {}, options = {}) { + const transaction = options.transaction; + + const source = await Template.findByPk(sourceTemplateId, { transaction }); + if (!source) { + throw new Error(`Template with id ${sourceTemplateId} not found`); + } + if (!source.published) { + throw new Error("Only published templates can be copied"); + } + if (source.userId === userId) { + throw new Error("Cannot copy your own template"); + } + + // Prevent duplicate copy (unless overrides.force is true) + if (!overrides.force) { + const existing = await Template.findOne({ + where: { sourceId: sourceTemplateId, userId, deleted: false }, + transaction, + }); + if (existing) { + throw new Error("You have already copied this template"); + } + } + + // Determine copy name + const copyCount = await Template.count({ + where: { sourceId: sourceTemplateId, userId }, + transaction, + }); + const copyName = overrides.name + || (copyCount === 0 ? `${source.name} (copy)` : `${source.name} (copy ${copyCount + 1})`); + + // Create base template data + const baseData = { + name: copyName, + description: source.description, + type: source.type, + defaultLanguage: source.defaultLanguage, + userId: userId, + published: false, + sourceId: sourceTemplateId, + }; + + const copiedTemplate = await Template.add( + Object.assign(baseData, overrides, { force: undefined }), + { transaction } + ); + + // Copy all template_content rows + await Template.copyLanguageContent(source.id, copiedTemplate.id, options); + + // Copy full edit history snapshot from source into the new copy + await Template.copyEditHistory(source.id, copiedTemplate.id, options); + + // When force=true ("Make new copy"), bump updatedAt on existing copies of this source + // so their status goes back to "Copy" (no longer "Update available") + if (overrides.force) { + const { Op } = require("sequelize"); + const existingCopies = await Template.findAll({ + where: { + sourceId: sourceTemplateId, + userId: userId, + deleted: false, + id: { [Op.ne]: copiedTemplate.id }, + }, + transaction, + }); + for (const copy of existingCopies) { + copy.changed('updatedAt', true); + await copy.save({ fields: ['updatedAt'], transaction }); + } + } + + return copiedTemplate; + } + + /** + * Copy all language content rows from one template to another. + * + * @param {number} sourceTemplateId - Source template ID + * @param {number} targetTemplateId - Target template ID + * @param {Object} options - Database options including transaction + * @returns {Promise} + */ + static async copyLanguageContent(sourceTemplateId, targetTemplateId, options = {}) { + const rows = await sequelize.models.template_content.findAll({ + where: { templateId: sourceTemplateId, deleted: false }, + raw: true, + transaction: options.transaction, + }); + + for (const row of rows) { + await sequelize.models.template_content.add({ + templateId: targetTemplateId, + language: row.language, + content: row.content, + }, { transaction: options.transaction }); + } + } + + /** + * Copy all template_edit history rows from one template to another. + * + * Clones non-deleted template edit rows so that the target template has the same + * edit history snapshot as the source at the time of copying. + * + * @param {number} sourceTemplateId - Source template ID + * @param {number} targetTemplateId - Target template ID + * @param {Object} options + * @returns {Promise} + */ + static async copyEditHistory(sourceTemplateId, targetTemplateId, options = {}) { + const transaction = options.transaction; + + const rows = await sequelize.models.template_edit.findAll({ + where: { templateId: sourceTemplateId, deleted: false }, + raw: true, + transaction, + }); + + if (!rows || rows.length === 0) { + return; + } + + const newRows = rows.map((row) => ({ + ...row, + id: undefined, + templateId: targetTemplateId, + })); + + await sequelize.models.template_edit.bulkCreate(newRows, { transaction }); + } + + /** + * Replace a copy's language content with the current content from its source. + * Updates existing rows in place (or adds if missing) to respect UNIQUE(templateId, language). + * + * @param {number} copyId - The ID of the copied template + * @param {Object} options - Database options including transaction + * @returns {Promise} The updated copy + */ + static async updateFromSource(copyId, options = {}) { + const { Op } = require("sequelize"); + const transaction = options.transaction; + const templateContentModel = sequelize.models.template_content; + + const copy = await Template.findByPk(copyId, { transaction }); + if (!copy || !copy.sourceId) { + throw new Error("Template is not a copy or does not exist"); + } + + const source = await Template.findByPk(copy.sourceId, { transaction }); + if (!source || source.deleted) { + throw new Error("Source template is no longer available"); + } + + // 1. Get all source language content + const sourceRows = await templateContentModel.findAll({ + where: { templateId: source.id, deleted: false }, + raw: true, + transaction, + }); + + // 2. Update or add each language for the copy (avoids UNIQUE(templateId, language) violation) + for (const row of sourceRows) { + const existing = await templateContentModel.findOne({ + where: { templateId: copyId, language: row.language }, + transaction, + }); + if (existing) { + await templateContentModel.update( + { content: row.content, deleted: false, deletedAt: null }, + { where: { id: existing.id }, transaction } + ); + } else { + await templateContentModel.add({ + templateId: copyId, + language: row.language, + content: row.content, + }, { transaction }); + } + } + + // 3. Mark copy rows for languages no longer in source as deleted + const sourceLanguages = sourceRows.map((r) => r.language); + if (sourceLanguages.length > 0) { + await templateContentModel.update( + { deleted: true, deletedAt: new Date() }, + { + where: { + templateId: copyId, + language: { [Op.notIn]: sourceLanguages }, + deleted: false, + }, + transaction, + } + ); + } + + // 4. Delete all draft edits for the copy + await sequelize.models.template_edit.update( + { deleted: true, deletedAt: new Date() }, + { where: { templateId: copyId }, transaction } + ); + + await Template.copyEditHistory(source.id, copyId, options); + + // 5. Sync metadata and touch updatedAt — use instance-level save to ensure DB persistence and hook trigger + const copyInstance = await Template.findByPk(copyId, { transaction }); + copyInstance.defaultLanguage = source.defaultLanguage; + copyInstance.description = source.description; + const copySuffixMatch = copyInstance.name.match(/\s*\(copy(?:\s+\d+)?\)$/); + const copySuffix = copySuffixMatch ? copySuffixMatch[0] : ' (copy)'; + copyInstance.name = source.name + copySuffix; + copyInstance.changed('updatedAt', true); + await copyInstance.save({ + fields: ['defaultLanguage', 'description', 'name', 'updatedAt'], + transaction, + }); + + return await Template.findByPk(copyId, { transaction }); + } + + /** + * Detach a copy from its source by setting sourceId to null. + * After detachment, the template behaves as a normal user-created template. + * + * @param {number} copyId - The ID of the copy to detach + * @param {Object} options - Database options including transaction + * @returns {Promise} The detached template + */ + static async detach(copyId, options = {}) { + const copy = await Template.findByPk(copyId, { transaction: options.transaction }); + if (!copy) { + throw new Error("Template not found"); + } + if (!copy.sourceId) { + throw new Error("Template is not a copy"); + } + await copy.update({ sourceId: null }, { transaction: options.transaction }); + return await Template.findByPk(copyId, { transaction: options.transaction }); + } + + static associate(models) { + Template.hasMany(models["template_content"], { + foreignKey: "templateId", + as: "template_contents", + }); + } + } + Template.init( + { + name: DataTypes.STRING, + description: DataTypes.TEXT, + userId: DataTypes.INTEGER, + published: DataTypes.BOOLEAN, + type: DataTypes.INTEGER, + sourceId: DataTypes.INTEGER, + defaultLanguage: DataTypes.STRING, + deleted: DataTypes.BOOLEAN, + deletedAt: DataTypes.DATE, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE, + }, + { + sequelize, + modelName: "template", + tableName: "template", + hooks: { + beforeUpdate: async (template, options) => { + // Prevent unpublishing: if template was published, cannot be set to false + if ( + template._previousDataValues && + template._previousDataValues.published === true && + template.published === false + ) { + throw new Error( + "Cannot unpublish a template once it has been published" + ); + } + } + } + } + ); + return Template; +} \ No newline at end of file diff --git a/backend/db/models/template_content.js b/backend/db/models/template_content.js new file mode 100644 index 000000000..f419ce6fe --- /dev/null +++ b/backend/db/models/template_content.js @@ -0,0 +1,41 @@ +'use strict'; +const MetaModel = require("../MetaModel.js"); + +/** + * TemplateContent model for storing template content per language. + * Each row holds content for one (templateId, language) pair. + * Draft edits for a language are merged into this content when the editor is closed. + * + */ +module.exports = (sequelize, DataTypes) => { + class TemplateContent extends MetaModel { + static autoTable = false; + + static associate(models) { + TemplateContent.belongsTo(models["template"], { + foreignKey: "templateId", + as: "template", + }); + } + } + + TemplateContent.init( + { + templateId: DataTypes.INTEGER, + language: DataTypes.STRING, + content: DataTypes.JSONB, + deleted: DataTypes.BOOLEAN, + deletedAt: DataTypes.DATE, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE, + }, + { + sequelize, + modelName: "template_content", + tableName: "template_content", + } + ); + + return TemplateContent; +}; + diff --git a/backend/db/models/template_edit.js b/backend/db/models/template_edit.js new file mode 100644 index 000000000..54d19e3da --- /dev/null +++ b/backend/db/models/template_edit.js @@ -0,0 +1,54 @@ +"use strict"; +const MetaModel = require("../MetaModel.js"); + +/** + * TemplateEdit model for storing draft edits on templates + * + * This model mirrors document_edit and enables the draft/save mechanism for templates. + * Draft edits (draft=true) are merged into template.content when the editor is closed. + * + * @author Mohammad Elwan + */ +module.exports = (sequelize, DataTypes) => { + class TemplateEdit extends MetaModel { + // No need to sync to frontend - internal use only + static autoTable = false; + + /** + * Helper method for defining associations. + * This method is not a part of Sequelize lifecycle. + * The `models/index` file will call this method automatically. + */ + static associate(models) { + TemplateEdit.belongsTo(models["template"], { + foreignKey: "templateId", + as: "template", + }); + } + } + + TemplateEdit.init( + { + userId: DataTypes.INTEGER, + templateId: DataTypes.INTEGER, + language: DataTypes.STRING, + draft: DataTypes.BOOLEAN, + offset: DataTypes.INTEGER, + operationType: DataTypes.INTEGER, // 0: Insert, 1: Delete, 2: Attribute-Change (only retain) + span: DataTypes.INTEGER, + text: DataTypes.STRING, + attributes: DataTypes.JSONB, + order: DataTypes.INTEGER, + deleted: DataTypes.BOOLEAN, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE, + deletedAt: DataTypes.DATE, + }, + { + sequelize: sequelize, + modelName: "template_edit", + tableName: "template_edit", + } + ); + return TemplateEdit; +}; diff --git a/backend/utils/documentTemplateHelper.js b/backend/utils/documentTemplateHelper.js new file mode 100644 index 000000000..af9ada958 --- /dev/null +++ b/backend/utils/documentTemplateHelper.js @@ -0,0 +1,67 @@ +'use strict'; +const path = require("path"); +const {promises: fs} = require("fs"); +const {resolveTemplateToDelta} = require("./templateResolver"); +const {deltaToDb} = require("editor-delta-conversion"); + +const UPLOAD_PATH = `${__dirname}/../../files`; + +/** + * Apply a template to a document by: + * - Resolving the template to a Delta + * - Persisting base content as document_edit rows (ground truth) + * - Writing the same Delta to the .delta file (cache) + * + * @author Mohammad Elwan + * + * @param {Object} doc - Document record with at least { id, hash, type } + * @param {number} templateId - Template ID to resolve + * @param {Object} models + * @param {Object} options + * @returns {Promise} + */ +async function applyTemplateToDocument(doc, templateId, models, options = {}) { + if (!doc || !templateId) { + return; + } + + try { + const resolvedDelta = await resolveTemplateToDelta( + templateId, + {}, + models, + options + ); + + const ops = resolvedDelta && Array.isArray(resolvedDelta.ops) ? resolvedDelta.ops : []; + const dbOps = deltaToDb(ops); + + if (Array.isArray(dbOps) && dbOps.length > 0) { + const editPayloads = dbOps.map((op, index) => ({ + ...op, + documentId: doc.id, + draft: false, + studySessionId: null, + studyStepId: null, + order: index, + })); + await models.document_edit.bulkCreate(editPayloads, {transaction: options.transaction}); + } + + const deltaFilePath = path.join(UPLOAD_PATH, `${doc.hash}.delta`); + await fs.writeFile(deltaFilePath, JSON.stringify(resolvedDelta || {ops: []}, null, 2)); + } catch (error) { + // Do not fail the outer operation; just log and continue with an empty document. + if (options.logger) { + options.logger.error("Failed to apply template to document:", error); + } else { + // eslint-disable-next-line no-console + console.error("Failed to apply template to document:", error); + } + } +} + +module.exports = { + applyTemplateToDocument, +}; + diff --git a/backend/utils/emailHelper.js b/backend/utils/emailHelper.js new file mode 100644 index 000000000..9ca94ca68 --- /dev/null +++ b/backend/utils/emailHelper.js @@ -0,0 +1,91 @@ +"use strict"; +const path = require("path"); +const { promises: fs } = require("fs"); +const { resolveTemplate } = require("./templateResolver"); + +const EMAIL_FALLBACKS_DIR = `${__dirname}/../../files/email-fallbacks`; + +/** + * Read fallback email content from disk and substitute {{placeholder}} with variables. + * File format: first line = subject, remainder = body. + * + * @param {string} key - Fallback key (e.g. "assignment") + * @param {Object} variables - Key-value map for substitution + * @returns {Promise<{subject: string, body: string}>} + */ +async function getEmailFallbackContent(key, variables = {}) { + const filePath = path.join(EMAIL_FALLBACKS_DIR, `${key}.txt`); + const raw = await fs.readFile(filePath, "utf8"); + const lines = raw.split(/\r?\n/); + const subject = lines[0] || ""; + const bodyLines = lines.slice(1); + let body = bodyLines.join("\n").trim(); + Object.keys(variables).forEach((k) => { + body = body.replace(new RegExp(`\\{\\{${k}\\}\\}`, "g"), String(variables[k] ?? "")); + }); + const subjectSubstituted = Object.keys(variables).reduce( + (s, k) => s.replace(new RegExp(`\\{\\{${k}\\}\\}`, "g"), String(variables[k] ?? "")), + subject + ); + return { subject: subjectSubstituted, body }; +} + +/** + * Get email content from template or fallback from disk file + * (Same pattern as auth.js getEmailContent) + * + * @author Mohammad Elwan + * + * @param {string} settingKey - Setting key for template ID (e.g., "email.template.sessionStart") + * @param {string} fallbackKey - Key for fallback file (e.g. "assignment") -> email-fallbacks/assignment.txt + * @param {Object} context - Context for template resolution and fallback {{placeholder}} substitution + * @param {number} [context.userId] - User ID for placeholder resolution + * @param {number} [context.creatorId] - Creator ID for placeholder resolution + * @param {number} [context.studyId] - Study ID + * @param {number} [context.studySessionId] - Study session ID + * @param {string} [context.studySessionHash] - Study session hash (for link) + * @param {string} [context.baseUrl] - Base URL for generating links + * @param {string} [context.link] - Direct link (optional) + * @param {string} [context.assignmentType] - Assignment type + * @param {string} [context.assignmentName] - Assignment name + * @param {string} [context.reviewLink] - Review link (sessionFinish) + * @param {string} [context.studyName] - Study name (studyClosed) + * @param {string} [context.userName] - User name (registration, passwordReset, verification) + * @param {number} [context.tokenExpiry] - Token expiry hours + * @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 + */ +async function getEmailContent(settingKey, fallbackKey, context, models, logger) { + try { + const templateIdStr = await models["setting"].get(settingKey); + + // If no template configured or empty, use fallback from disk + if (!templateIdStr || templateIdStr === "" || templateIdStr === "0") { + const fallback = await getEmailFallbackContent(fallbackKey, context); + return { subject: fallback.subject, body: fallback.body, isHtml: false }; + } + + const templateId = parseInt(templateIdStr); + if (isNaN(templateId) || templateId <= 0) { + const fallback = await getEmailFallbackContent(fallbackKey, context); + return { subject: fallback.subject, body: fallback.body, isHtml: false }; + } + + // Resolve template + const resolvedHtml = await resolveTemplate(templateId, context, models); + const fallback = await getEmailFallbackContent(fallbackKey, context); + return { subject: fallback.subject, body: resolvedHtml, isHtml: true }; + } catch (error) { + logger.error(`Failed to resolve template for ${settingKey}:`, error); + try { + const fallback = await getEmailFallbackContent(fallbackKey, context); + return { subject: fallback.subject, body: fallback.body, isHtml: false }; + } catch (fallbackError) { + logger.error(`Failed to read email fallback ${fallbackKey}:`, fallbackError); + throw error; + } + } +} + +module.exports = { getEmailContent, getEmailFallbackContent }; diff --git a/backend/utils/templateResolver.js b/backend/utils/templateResolver.js new file mode 100644 index 000000000..bdf4db7b8 --- /dev/null +++ b/backend/utils/templateResolver.js @@ -0,0 +1,344 @@ +/** + * Template Resolver Utility + * + * Resolves template placeholders with context data and handles privacy/anonymity. + * Converts Quill Delta format templates to resolved HTML or Delta format. + * + * @author Mohammad Elwan + */ +const Delta = require("quill-delta"); + +/** + * Extract plain text from Quill Delta operations + * + * @param {Object} delta - Quill Delta object with ops array + * @returns {string} Plain text extracted from Delta + */ +function extractTextFromDelta(delta) { + if (!delta || !delta.ops) { + return ""; + } + + return delta.ops + .filter(op => op.insert && typeof op.insert === 'string') + .map(op => op.insert) + .join(''); +} + +/** + * Convert plain text to Quill Delta format + * + * @param {string} text - Plain text to convert + * @returns {Object} Quill Delta object + */ +function textToDelta(text) { + if (!text) { + return new Delta(); + } + return new Delta().insert(text); +} + +/** + * Build replacement map from context data. When context.templateType is set, + * queries the placeholder table to determine which placeholders are allowed for that type. + * + * @param {Object} context - Context object (userId, creatorId, studyId, studySessionId, studySessionHash, baseUrl, link, assignmentType, assignmentName, templateType) + * @param {Object} models - Database models + * @param {Object} options - Options (e.g. transaction) + * @returns {Promise} Replacement map with placeholder keys and values + */ +async function buildReplacementMap(context, models, options = {}) { + const replacements = {}; + const templateType = context.templateType; + + let allowed = null; + if (templateType != null) { + const rows = await models["placeholder"].getAllByKey("type", templateType, options); + allowed = rows.map(row => row.placeholderKey); + } + + const allow = (key) => allowed === null || allowed.includes(key); + + // User placeholders + if (context.userId && (allow("username") || allow("firstName") || allow("lastName"))) { + const user = await models["user"].getById(context.userId, options); + if (user) { + const anonymize = context.anonymize || false; + if (allow("username")) replacements["~username~"] = anonymize ? "Anonymous" : (user.userName || ""); + if (allow("firstName")) replacements["~firstName~"] = anonymize ? "Anonymous" : (user.firstName || ""); + if (allow("lastName")) replacements["~lastName~"] = anonymize ? "" : (user.lastName || ""); + } + } + + // Study creator: only when not type-aware + if (allowed === null && context.creatorId) { + const creator = await models["user"].getById(context.creatorId, options); + if (creator) { + const anonymize = context.anonymize || false; + replacements["~creatorUsername~"] = anonymize ? "Anonymous" : (creator.userName || ""); + } + } + + // Link + if (allow("link")) { + if (context.link) { + replacements["~link~"] = context.link; + } else if (context.studySessionHash) { + const baseUrl = context.baseUrl || "localhost:3000"; + replacements["~link~"] = `http://${baseUrl}/review/${context.studySessionHash}`; + } else if (context.studySessionId) { + const session = await models["study_session"].getById(context.studySessionId, options); + if (session && session.hash) { + const baseUrl = context.baseUrl || "localhost:3000"; + replacements["~link~"] = `http://${baseUrl}/review/${session.hash}`; + } + } + } + + // Assignment + if (allow("assignmentType") && context.assignmentType) { + replacements["~assignmentType~"] = context.assignmentType; + } + if (allow("assignmentName") && context.assignmentName) { + replacements["~assignmentName~"] = context.assignmentName; + } + + // Study name + if (allow("studyName") && context.studyName) { + replacements["~studyName~"] = context.studyName; + } + + return replacements; +} + +/** + * Check if study should anonymize participant data + * + * @param {number} studyId - Study ID + * @param {Object} models - Database models + * @param {Object} options - Options object + * @returns {Promise} True if study anonymizes data + */ +async function shouldAnonymize(studyId, models, options = {}) { + if (!studyId) { + return false; + } + + const study = await models["study"].getById(studyId, options); + return study ? (study.anonymize === true) : false; +} + +/** + * Get template content (Delta) for a given template and language from template_content. + * Falls back to template.defaultLanguage if the requested language has no row. + * + * @param {number} templateId - Template ID + * @param {string} language - Language code (e.g. 'en', 'de') + * @param {Object} models - Database models object + * @param {Object} options - Options (e.g. transaction) + * @returns {Promise} Content object with ops array, or null if no row exists + */ +async function getTemplateContentForLanguage(templateId, language, models, options = {}) { + const templateContentModel = models["template_content"]; + if (!templateContentModel) { + return null; + } + const row = await templateContentModel.findOne({ + where: { templateId, language, deleted: false }, + raw: true, + ...options, + }); + return row && row.content ? row.content : null; +} + +/** + * Resolve template placeholders and return HTML string + * Content is loaded from template_content by (templateId, context.language or template.defaultLanguage). + * + * @param {number} templateId - Template ID to resolve + * @param {Object} context - Context object containing: + * - language: Optional language code (defaults to template.defaultLanguage) + * - userId, creatorId, studyId, studySessionId, studySessionHash, baseUrl, link, assignmentType, assignmentName, anonymize + * @param {Object} models - Database models object + * @param {Object} options - Options object + * @param {Object} options.transaction - Database transaction + * @returns {Promise} Resolved template as HTML string + * @throws {Error} If template not found or resolution fails + * @todo Localize resolved content per recipient: at call sites (emailHelper, auth, study_session, assignment, study), + * set context.language from the recipient's preferred language or study/session locale so the resolver picks + * the matching template_content row (e.g. send German template to German users). Currently call sites + * do not set context.language, so everyone receives template.defaultLanguage. + */ +async function resolveTemplate(templateId, context, models, options = {}) { + if (!templateId) { + throw new Error("Template ID is required"); + } + + if (!models) { + throw new Error("Models object is required"); + } + + const template = await models["template"].getById(templateId, options); + if (!template) { + throw new Error(`Template with ID ${templateId} not found`); + } + + if (context.studyId && context.anonymize === undefined) { + context.anonymize = await shouldAnonymize(context.studyId, models, options); + } + + context.templateType = template.type; + + const language = context.language || template.defaultLanguage || "en"; + let content = await getTemplateContentForLanguage(templateId, language, models, options); + if (!content && language !== (template.defaultLanguage || "en")) { + content = await getTemplateContentForLanguage(templateId, template.defaultLanguage || "en", models, options); + } + + const replacements = await buildReplacementMap(context, models, options); + + let text = ""; + if (content && content.ops) { + text = extractTextFromDelta(content); + } + + let resolvedText = text; + for (const [placeholder, value] of Object.entries(replacements)) { + const escapedPlaceholder = placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(escapedPlaceholder, 'g'); + resolvedText = resolvedText.replace(regex, value || ""); + } + + // Make URLs clickable: split by URL pattern, escape non-URL parts, wrap URLs in + const urlPattern = /(https?:\/\/\S+)/g; + const escapeForHtml = (s) => + s.replace(/&/g, '&').replace(//g, '>'); + const escapeForAttr = (s) => + s.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); + const parts = resolvedText.split(urlPattern); + let htmlParts = []; + for (let i = 0; i < parts.length; i++) { + if (i % 2 === 0) { + htmlParts.push(parts[i].replace(/\n/g, '
').replace(/&/g, '&').replace(//g, '>').replace(/<br>/g, '
')); + } else { + const url = parts[i]; + htmlParts.push(`
${escapeForHtml(url)}`); + } + } + const html = htmlParts.join(''); + + return html; +} + +/** + * Resolve template placeholders and return Quill Delta object + * Content is loaded from template_content by (templateId, context.language or template.defaultLanguage). + * + * @param {number} templateId - Template ID to resolve + * @param {Object} context - Context object (same as resolveTemplate; may include language) + * @param {Object} models - Database models object + * @param {Object} options - Options object + * @param {Object} options.transaction - Database transaction + * @returns {Promise} Resolved template as Quill Delta object + * @throws {Error} If template not found or resolution fails + */ +async function resolveTemplateToDelta(templateId, context, models, options = {}) { + if (!templateId) { + throw new Error("Template ID is required"); + } + + if (!models) { + throw new Error("Models object is required"); + } + + const template = await models["template"].getById(templateId, options); + if (!template) { + throw new Error(`Template with ID ${templateId} not found`); + } + + if (context.studyId && context.anonymize === undefined) { + context.anonymize = await shouldAnonymize(context.studyId, models, options); + } + + context.templateType = template.type; + + const language = context.language || template.defaultLanguage || "en"; + let content = await getTemplateContentForLanguage(templateId, language, models, options); + if (!content && language !== (template.defaultLanguage || "en")) { + content = await getTemplateContentForLanguage(templateId, template.defaultLanguage || "en", models, options); + } + + const replacements = await buildReplacementMap(context, models, options); + + let originalDelta = new Delta(); + if (content && content.ops) { + originalDelta = new Delta(content.ops); + } + + let text = extractTextFromDelta(originalDelta); + let resolvedText = text; + + for (const [placeholder, value] of Object.entries(replacements)) { + const escapedPlaceholder = placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(escapedPlaceholder, 'g'); + resolvedText = resolvedText.replace(regex, value || ""); + } + + const resolvedDelta = new Delta(); + + for (const op of originalDelta.ops) { + if (op.insert && typeof op.insert === 'string') { + let insertText = op.insert; + for (const [placeholder, value] of Object.entries(replacements)) { + const escapedPlaceholder = placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(escapedPlaceholder, 'g'); + insertText = insertText.replace(regex, value || ""); + } + + if (op.attributes) { + resolvedDelta.insert(insertText, op.attributes); + } else { + resolvedDelta.insert(insertText); + } + } else if (op.retain) { + if (op.attributes) { + resolvedDelta.retain(op.retain, op.attributes); + } else { + resolvedDelta.retain(op.retain); + } + } else if (op.delete) { + resolvedDelta.delete(op.delete); + } + } + + return resolvedDelta; +} + +/** + * 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 {Object} models - Database models + * @param {Object} [options] + * @returns {Promise} Array of missing required placeholder keys (e.g. ['link']) + */ +async function getMissingRequiredPlaceholders(content, templateType, models, options = {}) { + const rows = await models["placeholder"].getAllByKey("type", templateType, options); + const requiredKeys = rows.filter((r) => r.required === true).map((r) => r.placeholderKey); + if (requiredKeys.length === 0) return []; + + const text = extractTextFromDelta(content && content.ops ? { ops: content.ops } : content); + const missing = []; + for (const key of requiredKeys) { + const token = `~${key}~`; + if (!text.includes(token)) missing.push(key); + } + return missing; +} + +module.exports = { + resolveTemplate, + resolveTemplateToDelta, + getMissingRequiredPlaceholders, +}; \ No newline at end of file diff --git a/backend/webserver/Server.js b/backend/webserver/Server.js index 7fd46df6f..4ffc1bc57 100644 --- a/backend/webserver/Server.js +++ b/backend/webserver/Server.js @@ -171,21 +171,29 @@ module.exports = class Server { * Send a mail * @param to email address * @param subject of the mail - * @param text of the mail + * @param body body of the mail (plain text or HTML depending on options.isHtml) + * @param {Object} [options] options + * @param {boolean} [options.isHtml] if true, body is sent as HTML (Content-Type text/html); otherwise as plain text * @returns {Promise} */ - async sendMail(to, subject, text) { + async sendMail(to, subject, body, options = {}) { if (!this.mailer) { this.logger.warn(`Email service not configured. Would send email to ${to} with subject: ${subject}`); return; } - - this.mailer.sendMail({ + + const mailOptions = { from: await this.db.models['setting'].get("system.mailService.senderAddress"), to: to, - subject: subject, - text: text - }, (err, info) => { + subject: subject + }; + if (options.isHtml === true) { + mailOptions.html = body; + } else { + mailOptions.text = body; + } + + this.mailer.sendMail(mailOptions, (err, info) => { if (err) { this.logger.error(err); } else { diff --git a/backend/webserver/Socket.js b/backend/webserver/Socket.js index 759bd9544..e3da36019 100644 --- a/backend/webserver/Socket.js +++ b/backend/webserver/Socket.js @@ -441,13 +441,22 @@ module.exports = class Socket { const filteredAccessMap = await this.filterAccessMap(accessMap, userId, rolesUpdatedAt); const relevantAccessMap = filteredAccessMap.filter(item => item.hasAccess); const accessRights = relevantAccessMap.map(item => item.access); - if (await this.isAdmin(userId, rolesUpdatedAt) || this.models[tableName].publicTable) { // is allowed to see everything + // Special handling for templates: even admins should see filtered results (own + published from others) + const isTemplateTable = tableName === "template"; + if ((await this.isAdmin(userId, rolesUpdatedAt) || this.models[tableName].publicTable) && !isTemplateTable) { // is allowed to see everything (except templates) // no adaption of the filter or attributes needed } else if (this.models[tableName].autoTable && 'userId' in this.models[tableName].getAttributes() && accessRights.length === 0) { - // is allowed to see only his data and possible if there is a public attribute + // is allowed to see only his data and possible if there is a public or published attribute const userFilter = {}; if ("public" in this.models[tableName].getAttributes()) { userFilter[Op.or] = [{userId: userId}, {public: true}]; + } else if ("published" in this.models[tableName].getAttributes()) { + if (tableName === "template" && typeof this.models[tableName].getUserFilter === "function") { + const isAdmin = await this.isAdmin(userId, rolesUpdatedAt); + Object.assign(userFilter, this.models[tableName].getUserFilter(userId, isAdmin)); + } else { + userFilter[Op.or] = [{userId: userId}, {published: true}]; + } } else { userFilter['userId'] = userId; } @@ -829,9 +838,15 @@ module.exports = class Socket { if (filter[Op.or]) { return filter[Op.or].some(subfilter => this.matchesFilter(entry, subfilter)); } - return Object.entries(filter).every(([key, val]) => - entry[key] === val - ); + return Object.entries(filter).every(([key, val]) => { + if (val && typeof val === "object" && Op.in in val) { + return Array.isArray(val[Op.in]) && val[Op.in].includes(entry[key]); + } + if (val && typeof val === "object" && Op.ne in val) { + return entry[key] !== val[Op.ne]; + } + return entry[key] === val; + }); } /** @@ -854,8 +869,13 @@ module.exports = class Socket { this.io.to(socket.id).emit(tableName + "Refresh", data); continue } - // if socket is admin or table is public, also just send - if (await this.isAdmin(userId, rolesUpdatedAt) || this.models[tableName].publicTable) { + const isTemplateTable = tableName === "template" && + typeof this.models[tableName].getUserFilter === "function"; + const isAdmin = await this.isAdmin(userId, rolesUpdatedAt); + const isPublicTable = this.models[tableName].publicTable; + + // For template table, apply filtering + if (!isTemplateTable && (isAdmin || isPublicTable)) { this.io.to(socket.id).emit(tableName + "Refresh", data); continue } @@ -866,6 +886,20 @@ module.exports = class Socket { continue; } allFilter = filtersAndAttributes.filter; + // For template table, non-admins must also receive updates to templates that are the source of their copies + if (isTemplateTable && !isAdmin && allFilter[Op.or]) { + const copies = await this.models[tableName].findAll({ + where: { userId, sourceId: { [Op.ne]: null }, deleted: false }, + attributes: ["sourceId"], + raw: true, + }); + const sourceIds = copies.map((c) => c.sourceId).filter(Boolean); + if (sourceIds.length > 0) { + const orList = Array.isArray(allFilter[Op.or]) ? [...allFilter[Op.or]] : [allFilter[Op.or]]; + orList.push({ id: { [Op.in]: sourceIds } }); + allFilter = { ...allFilter, [Op.or]: orList }; + } + } const filteredData = data.filter(entry => this.matchesFilter(entry, allFilter)); this.io.to(socket.id).emit(tableName + "Refresh", filteredData); } diff --git a/backend/webserver/routes/auth.js b/backend/webserver/routes/auth.js index 143d0a3fc..e2485382a 100644 --- a/backend/webserver/routes/auth.js +++ b/backend/webserver/routes/auth.js @@ -8,6 +8,9 @@ */ const passport = require('passport'); const { generateToken, decodeToken } = require('../../utils/auth'); +const { resolveTemplate } = require('../../utils/templateResolver'); +const { getEmailFallbackContent } = require('../../utils/emailHelper'); + /** * Route for user management @@ -76,6 +79,58 @@ module.exports = function (server) { return { allowed: true }; } + /** + * Helper function to get email content from template or fallback to hardcoded text + * @param {string} settingKey - Setting key for template ID (e.g., "email.template.passwordReset") + * @param {string} fallbackSubject - Fallback email subject + * @param {string} fallbackBody - Fallback email body (plain text) + * @param {Object} context - Context object for template resolution + * @param {number} context.userId - User ID for placeholder resolution + * @param {string} context.link - Link for placeholder resolution + * @returns {Promise<{subject: string, body: string, isHtml: boolean}>} Email subject, body, and whether body is HTML + */ + async function getEmailContent(settingKey, fallbackKey, context) { + try { + const templateIdStr = await server.db.models['setting'].get(settingKey); + + // If no template configured or empty, use fallback from disk + if (!templateIdStr || templateIdStr === "" || templateIdStr === "0") { + const fallback = await getEmailFallbackContent(fallbackKey, context); + return { subject: fallback.subject, body: fallback.body, isHtml: false }; + } + + const templateId = parseInt(templateIdStr); + if (isNaN(templateId) || templateId <= 0) { + const fallback = await getEmailFallbackContent(fallbackKey, context); + return { subject: fallback.subject, body: fallback.body, isHtml: false }; + } + + // Resolve template + const baseUrl = context.baseUrl || await getBaseUrl(); + const resolvedHtml = await resolveTemplate( + templateId, + { + userId: context.userId, + baseUrl: baseUrl, + link: context.link + }, + server.db.models, + context.options || {} + ); + const fallback = await getEmailFallbackContent(fallbackKey, context); + return { subject: fallback.subject, body: resolvedHtml, isHtml: true }; + } catch (error) { + server.logger.error(`Failed to resolve template for ${settingKey}:`, error); + try { + const fallback = await getEmailFallbackContent(fallbackKey, context); + return { subject: fallback.subject, body: fallback.body, isHtml: false }; + } catch (fallbackError) { + server.logger.error(`Failed to read email fallback ${fallbackKey}:`, fallbackError); + throw error; + } + } + } + /** * Login Procedure */ @@ -236,18 +291,25 @@ module.exports = function (server) { ); const baseUrl = await getBaseUrl(); const verificationLink = `http://${baseUrl}/login?token=${verificationToken}`; + + const emailContent = await getEmailContent( + "email.template.registration", + "registration", + { + userId: newUser.id, + baseUrl: baseUrl, + link: verificationLink, + userName: data.userName, + tokenExpiry, + options: { transaction: transaction }, + } + ); + await server.sendMail( - data.email, - "Welcome to CARE - Please verify your email address", - `Welcome to CARE, ${data.userName}! You've successfully registered a new account. - -To complete your registration, please verify your email address by clicking the link below: -${verificationLink} - -This link will expire in ${tokenExpiry} hours. If you didn't create a CARE account, you can safely ignore this email. - -Thanks, -The CARE Team` + data.email, + emailContent.subject, + emailContent.body, + { isHtml: emailContent.isHtml } ); await transaction.commit(); res.status(201).json({message: "User was successfully created. Please check your email to verify your account.", emailVerificationRequired: true}); // TODO: Adjust link as needed @@ -301,17 +363,20 @@ The CARE Team` // Send email with the full encoded token const baseUrl = await getBaseUrl(); const resetLink = `http://${baseUrl}/reset-password?token=${resetToken}`; - await server.sendMail(user.email, "CARE Password Reset Request", `Hello ${user.userName}, - -We received a request to reset the password for your CARE account. - -To set a new password, please click the link below: -${resetLink} - -This link will expire in ${tokenExpiry} hours. If you didn't request a password reset, you can safely ignore this email and your account will remain secure. - -Thanks, -The CARE Team`); + + const emailContent = await getEmailContent( + "email.template.passwordReset", + "passwordReset", + { + userId: user.id, + baseUrl: baseUrl, + link: resetLink, + userName: user.userName, + tokenExpiry, + } + ); + + await server.sendMail(user.email, emailContent.subject, emailContent.body, { isHtml: emailContent.isHtml }); return res.status(200).json({message: "A password reset link has been sent."}); } catch (err) { server.logger.error("Failed to find user:", err); @@ -501,18 +566,24 @@ The CARE Team`); // Send verification email const baseUrl = await getBaseUrl(); const verificationLink = `http://${baseUrl}/login?token=${verificationToken}`; + + const emailContent = await getEmailContent( + "email.template.verification", + "verification", + { + userId: user.id, + baseUrl: baseUrl, + link: verificationLink, + userName: user.userName, + tokenExpiry, + } + ); + await server.sendMail( email, - "CARE - Please verify your email address", - `Welcome back to CARE, ${user.userName}! - -To complete your email verification, please click the link below: -${verificationLink} - -This link will expire in ${tokenExpiry} hours. If you didn't request this verification email, you can safely ignore this email. - -Thanks, -The CARE Team` + emailContent.subject, + emailContent.body, + { isHtml: emailContent.isHtml } ); return res.status(200).json({message: "Verification email has been sent."}); diff --git a/backend/webserver/sockets/app.js b/backend/webserver/sockets/app.js index 60d662f8e..6ca6af1aa 100644 --- a/backend/webserver/sockets/app.js +++ b/backend/webserver/sockets/app.js @@ -72,6 +72,7 @@ class AppSocket extends Socket { data.data, {context: data.data, transaction: transaction} ); + return newEntry.id; } diff --git a/backend/webserver/sockets/assignment.js b/backend/webserver/sockets/assignment.js index bdb27301c..086dc9140 100644 --- a/backend/webserver/sockets/assignment.js +++ b/backend/webserver/sockets/assignment.js @@ -1,6 +1,7 @@ const Socket = require("../Socket.js"); const {v4: uuidv4} = require("uuid"); const _ = require("lodash"); +const {getEmailContent} = require("../../utils/emailHelper"); /** * Handle user through websocket @@ -29,12 +30,25 @@ class AssignmentSocket extends Socket { async createAssignment(data, options) { const templateStudySteps = await this.models['study_step'].getAllByKey("studyId", data['template'].id); + const workflowSteps = await this.models['workflow_step'].getSortedWorkflowSteps(data['template'].workflowId); + const workflowStepById = Object.fromEntries(workflowSteps.map((ws) => [ws.id, ws])); + const stepDocuments = []; for (const step of templateStudySteps) { if (step.workflowStepId) { const stepDocument = data['documents'].find(doc => doc.workflowStepId === step.workflowStepId) || null; - const stepDocumentId = stepDocument ? stepDocument.documentId : null; - + const hasOverride = stepDocument != null && stepDocument.documentId != null; + let stepDocumentId = hasOverride ? stepDocument.documentId : step.documentId; + if (!hasOverride) { + const workflowStep = workflowStepById[step.workflowStepId]; + if (workflowStep && workflowStep.workflowStepDocument != null) { + const refStudyStep = templateStudySteps.find((s) => s.workflowStepId === workflowStep.workflowStepDocument); + if (refStudyStep && refStudyStep.documentId != null) { + stepDocumentId = refStudyStep.documentId; + } + } + } + // Determine assignment type and gather context for template replacement let assignmentType, contextData; @@ -43,7 +57,7 @@ class AssignmentSocket extends Socket { contextData = { assignmentType: assignmentType, submissionId: stepDocument?.submissionId || null, - documentId: stepDocument?.documentId || null + documentId: stepDocumentId|| null }; } else if (data.assignmentType === 'submission') { assignmentType = 'submission'; @@ -90,15 +104,21 @@ class AssignmentSocket extends Socket { resumable: true, stepDocuments: stepDocuments } + // Check if email notifications are enabled + const enableEmailNotification = data.enableEmailNotification || false; + const study = await this.models["study"].add(new_study, { transaction: options.transaction, context: new_study, doNotDuplicate: data.assignmentType === 'study_session' }); - await this.addReviewer({ - studyId: study.id, reviewer: data["reviewer"] + studyId: study.id, + reviewer: data["reviewer"], + assignmentType: data.assignmentType || 'document', + assignmentName: study.name, + enableEmailNotification: enableEmailNotification }, options); } @@ -132,12 +152,28 @@ class AssignmentSocket extends Socket { } } - await Promise.all(data['reviewer'].map(reviewer => { + const createdSessions = await Promise.all(data['reviewer'].map(reviewer => { return this.models["study_session"].add({ studyId: data['studyId'], userId: reviewer['id'], }, {transaction: options.transaction}); })); + // Send assignment notification emails if enabled + if (data.enableEmailNotification && createdSessions.length > 0) { + options.transaction.afterCommit(async () => { + try { + for (const session of createdSessions) { + await this.sendAssignmentEmail(session, { + assignmentType: data.assignmentType || 'document', + assignmentName: data.assignmentName || currentStudy.name + }); + } + } catch (error) { + this.server.logger.error(`Failed to send assignment emails:`, error); + } + }); + } + } /** @@ -326,6 +362,8 @@ class AssignmentSocket extends Socket { documents: assignment["document"], // Pass through optional properties if they exist ...(data.assignmentType && { assignmentType: data.assignmentType }), + ...(data.emailTemplateId && { emailTemplateId: data.emailTemplateId }), + enableEmailNotification: data.enableEmailNotification, }; await this.createAssignment(assignmentData, options); } @@ -411,6 +449,8 @@ class AssignmentSocket extends Socket { documents: assignment["document"], // Pass through optional properties if they exist ...(data.assignmentType && { assignmentType: data.assignmentType }), + ...(data.emailTemplateId && { emailTemplateId: data.emailTemplateId }), + enableEmailNotification: data.enableEmailNotification, }; await this.createAssignment(assignmentData, options); } @@ -547,6 +587,57 @@ class AssignmentSocket extends Socket { return result; } + /** + * Send assignment notification email using configured template + * @param {Object} studySession - Study session object + * @param {Object} assignmentContext - Assignment context data + * @param {string} assignmentContext.assignmentType - Type of assignment ('document' or 'submission') + * @param {string} assignmentContext.assignmentName - Name of the assignment + * @returns {Promise} + */ + async sendAssignmentEmail(studySession, assignmentContext = {}) { + const study = await this.models['study'].getById(studySession.studyId); + if (!study) return; + + // Get reviewer email + const user = await this.models['user'].getById(studySession.userId); + if (!user || !user.email) { + this.server.logger.warn(`Cannot send assignment email: user ${studySession.userId} has no email`); + return; + } + + // Get baseUrl from settings + const baseUrl = await this.models["setting"].get("system.baseUrl") || "localhost:3000"; + const assignmentLink = `http://${baseUrl}/session/${studySession.hash}`; + + // Get email content from template or fallback. Set context.link so ~link~ resolves to /session/ for the reviewer. + const emailContent = await getEmailContent( + "email.template.assignment", + "assignment", + { + userId: studySession.userId, + creatorId: study.userId, + studyId: study.id, + studySessionId: studySession.id, + studySessionHash: studySession.hash, + baseUrl: baseUrl, + link: assignmentLink, + assignmentType: assignmentContext.assignmentType || 'document', + assignmentName: assignmentContext.assignmentName || study.name + }, + this.models, + this.logger + ); + + // Send email + await this.server.sendMail( + user.email, + emailContent.subject, + emailContent.body, + { isHtml: emailContent.isHtml } + ); + } + /** * Retrieve all the assignments a course has. * diff --git a/backend/webserver/sockets/document.js b/backend/webserver/sockets/document.js index 78015a74a..09998c431 100644 --- a/backend/webserver/sockets/document.js +++ b/backend/webserver/sockets/document.js @@ -9,6 +9,7 @@ const {enqueueDocumentTask} = require("../../utils/queue.js"); const {dbToDelta} = require("editor-delta-conversion"); const Validator = require("../../utils/validator.js"); const {Op} = require('sequelize'); +const {applyTemplateToDocument} = require("../../utils/documentTemplateHelper.js"); const UPLOAD_PATH = `${__dirname}/../../../files`; @@ -294,6 +295,7 @@ class DocumentSocket extends Socket { * @param {Object} data The data for the new document. * @param {string} data.name The name of the new document. * @param {number} data.type The type identifier for the document (e.g., HTML, MODAL). + * @param {number} [data.templateId] Optional template ID to pre-fill document content (Type 4: Document - General). * @param {Object} options The options object containing the transaction. * @returns {Promise} A promise that resolves with the newly created document's database record. */ @@ -305,6 +307,16 @@ class DocumentSocket extends Socket { projectId: data.projectId }, {transaction: options.transaction}); + // If templateId provided and document is HTML/MODAL type, resolve template and write content + if (data.templateId && (doc.type === docTypes.DOC_TYPE_HTML || doc.type === docTypes.DOC_TYPE_MODAL)) { + await applyTemplateToDocument( + doc, + data.templateId, + this.models, + {transaction: options.transaction, logger: this.server.logger} + ); + } + options.transaction.afterCommit(() => { this.emit("documentRefresh", doc); }); @@ -997,7 +1009,6 @@ class DocumentSocket extends Socket { delta = delta.compose(dbToDelta(edits)); return {document: document, deltas: delta}; } else { - // Get the edits for the base document const edits = await this.models['document_edit'].findAll({ where: { diff --git a/backend/webserver/sockets/study.js b/backend/webserver/sockets/study.js index 15251cff8..fd5f99286 100644 --- a/backend/webserver/sockets/study.js +++ b/backend/webserver/sockets/study.js @@ -1,4 +1,5 @@ const Socket = require("../Socket.js"); +const {getEmailContent} = require("../../utils/emailHelper"); /** * Handle all studies through websocket @@ -71,6 +72,113 @@ class StudySocket extends Socket { } } + /** + * Send study closed email to users with open/unfinished sessions. + * Uses Type 6 templates configured in settings. + * @param {Object} study - Study object + * @returns {Promise} + */ + async sendStudyClosedEmails(study) { + const baseUrl = await this.models["setting"].get("system.baseUrl") || "localhost:3000"; + + try { + const openSessions = await this.models["study_session"].getAllByKey( + "studyId", + study.id, + ); + + const unfinishedSessions = openSessions.filter( + (s) => s.end === null && !s.deleted, + ); + + if (unfinishedSessions.length === 0) { + this.logger.info(`No open sessions found for study ${study.id}, skipping study close emails`); + return; + } + + const userIds = [...new Set(unfinishedSessions.map(s => s.userId))]; + + for (const sessionOwnerId of userIds) { + try { + const user = await this.models['user'].getById(sessionOwnerId); + if (!user || !user.email) { + this.logger.warn(`Cannot send study closed email: user ${sessionOwnerId} has no email`); + continue; + } + + const emailContent = await getEmailContent( + "email.template.studyClosed", + "studyClosed", + { + userId: sessionOwnerId, + studyId: study.id, + studyName: study.name, + baseUrl: baseUrl, + templateType: 6 + }, + this.models, + this.logger + ); + + await this.server.sendMail(user.email, emailContent.subject, emailContent.body, { isHtml: emailContent.isHtml }); + } catch (error) { + this.logger.error(`Failed to send study closed email to user ${sessionOwnerId}:`, error); + } + } + } catch (error) { + this.logger.error(`Failed to send study closed emails for study ${study.id}:`, error); + } + } + + /** + * Close a single study by setting its closed flag. + * Validates that the study exists and is not already closed. + * Sends study closed emails after the transaction commits (optional, based on notifySessions flag). + * + * @socketEvent studyClose + * @param {object} data The data required to close the study. + * @param {number} data.studyId The ID of the study to close. + * @param {object} options Configuration for the database operation. + * @param {Object} options.transaction A Sequelize DB transaction object to ensure atomicity. + * @returns {Promise} The updated study object. + */ + async closeStudy(data, options) { + if (!data.studyId) { + throw new Error("studyId is required"); + } + + const study = await this.models["study"].getById(data.studyId, {transaction: options.transaction}); + if (!study) { + throw new Error("Study not found"); + } + + if (study.closed) { + throw new Error("Study is already closed"); + } + + const updatedStudy = await this.models["study"].updateById( + data.studyId, + {closed: true}, + {transaction: options.transaction} + ); + + const notifySessions = data.notifySessions === true; + + options.transaction.afterCommit(async () => { + if (!notifySessions) { + return; + } + try { + const updatedStudy = await this.models["study"].getById(data.studyId); + await this.sendStudyClosedEmails(updatedStudy); + } catch (error) { + this.logger.error(`Failed to send study closed emails for study ${data.studyId}:`, error); + } + }); + + return updatedStudy; + } + /** * Closes all studies associated with a given project ID in a loop. * Each study is updated in its own database transaction. Progress is reported to the client after each study is processed. @@ -97,8 +205,18 @@ class StudySocket extends Socket { try { await this.models['study'].updateById(study.id, {closed: true}, {transaction: transaction}); - transaction.afterCommit(() => { + const notifySessions = data.notifySessions === true; + transaction.afterCommit(async () => { this.broadcastTransactionChanges(transaction); + // Send study closed emails after transaction commits (optional, based on notifySessions flag) + if (notifySessions) { + try { + const updatedStudy = await this.models['study'].getById(study.id); + await this.sendStudyClosedEmails(updatedStudy); + } catch (error) { + this.logger.error(`Failed to send study closed emails for study ${study.id}:`, error); + } + } }); await transaction.commit(); } catch (e) { @@ -119,7 +237,7 @@ class StudySocket extends Socket { async init() { this.createSocket("studySaveAsTemplate", this.saveStudyAsTemplate, {}, true); this.createSocket("studyCloseBulk", this.closeBulk, {}, false); - + this.createSocket("studyClose", this.closeStudy, {}, true); } } diff --git a/backend/webserver/sockets/study_session.js b/backend/webserver/sockets/study_session.js index 4730d31ed..6befb280e 100644 --- a/backend/webserver/sockets/study_session.js +++ b/backend/webserver/sockets/study_session.js @@ -1,4 +1,5 @@ const Socket = require("../Socket.js"); +const {getEmailContent} = require("../../utils/emailHelper"); /** * Handle all study sessions through websocket @@ -40,19 +41,176 @@ class StudySessionSocket extends Socket { * @returns {Promise} A promise that resolves with the newly created or updated study session object from the database. */ async startStudySession(data, options) { + let session; + let shouldSendSessionStartEmail = false; if (data.studySessionId && data.studySessionId !== 0) { - // we just start the session - return await this.models["study_session"].updateById(data.studySessionId, + const existing = await this.models["study_session"].getById(data.studySessionId, {transaction: options.transaction}); + if (!existing) { + throw new Error("Study session not found"); + } + shouldSendSessionStartEmail = existing.start == null; + session = await this.models["study_session"].updateById(data.studySessionId, {start: Date.now()}, {transaction: options.transaction} ); } else if (data.studyId) { - // we create a new session - return await this.models["study_session"].add({ + session = await this.models["study_session"].add({ studyId: data.studyId, userId: this.userId, start: Date.now() }, {transaction: options.transaction}); + shouldSendSessionStartEmail = true; } + if (session && shouldSendSessionStartEmail) { + options.transaction.afterCommit(async () => { + try { + await this.sendSessionStartEmail(session); + } catch (error) { + this.server.logger.error(`Failed to send session start email:`, error); + } + }); + } + + return session; + } + + /** + * Send session start email using configured template or fallback + * @param {Object} studySession - Study session object + * @returns {Promise} + */ + async sendSessionStartEmail(studySession) { + const session = await this.models['study_session'].getById(studySession.id); + if (!session || session.deleted || session.start == null) { + return; + } + const study = await this.models['study'].getById(session.studyId); + if (!study) return; + + if (!study.enableEmailNotifications) { + return; + } + + // Get submission owner email (study.userId) + const user = await this.models['user'].getById(study.userId); + if (!user || !user.email) { + this.server.logger.warn(`Cannot send session start email: user ${study.userId} has no email`); + return; + } + + const baseUrl = await this.models["setting"].get("system.baseUrl") || "localhost:3000"; + const emailContent = await getEmailContent( + "email.template.sessionStart", + "sessionStart", + { + userId: study.userId, + creatorId: study.userId, + studyId: study.id, + studySessionId: session.id, + studySessionHash: session.hash, + baseUrl: baseUrl + }, + this.models, + this.logger + ); + await this.server.sendMail(user.email, emailContent.subject, emailContent.body, { isHtml: emailContent.isHtml }); + } + + /** + * Send session finish email using configured template or fallback + * @param {Object} studySession - Study session object + * @returns {Promise} + */ + async sendSessionFinishEmail(studySession) { + const study = await this.models['study'].getById(studySession.studyId); + if (!study) return; + + if (study.closed) { + return; + } + + if (!study.enableEmailNotifications) { + return; + } + + // Get submission owner email (study.userId) + const user = await this.models['user'].getById(study.userId); + if (!user || !user.email) { + this.server.logger.warn(`Cannot send session finish email: user ${study.userId} has no email`); + return; + } + + // Get baseUrl from settings + const baseUrl = await this.models["setting"].get("system.baseUrl") || "localhost:3000"; + const reviewLink = `http://${baseUrl}/review/${studySession.hash}`; + + // Get email content from template or fallback + const emailContent = await getEmailContent( + "email.template.sessionFinish", + "sessionFinish", + { + userId: study.userId, + creatorId: study.userId, + studyId: study.id, + studySessionId: studySession.id, + studySessionHash: studySession.hash, + baseUrl: baseUrl, + reviewLink + }, + this.models, + this.logger + ); + + // Send email + await this.server.sendMail(user.email, emailContent.subject, emailContent.body, { isHtml: emailContent.isHtml }); + } + + /** + * Finish a study session by setting its end date. + * Validates that the session exists, has not already ended, and that the study is not closed. + * Sends a session finish email after the transaction commits. + * + * @socketEvent studySessionFinish + * @param {object} data The data required to finish the session. + * @param {number} data.studySessionId The ID of the study session to finish. + * @param {object} options Configuration for the database operation. + * @param {Object} options.transaction A Sequelize DB transaction object to ensure atomicity. + * @returns {Promise} The updated study session object. + */ + async finishStudySession(data, options) { + if (!data.studySessionId) { + throw new Error("studySessionId is required"); + } + + const session = await this.models["study_session"].getById(data.studySessionId, {transaction: options.transaction}); + if (!session) { + throw new Error("Study session not found"); + } + + if (session.end) { + throw new Error("Study session has already been finished"); + } + + const study = await this.models["study"].getById(session.studyId, {transaction: options.transaction}); + if (study && study.closed) { + throw new Error("Cannot finish session: The study has been closed. Sessions are automatically terminated when a study is closed."); + } + + const updatedSession = await this.models["study_session"].updateById( + data.studySessionId, + {end: Date.now()}, + {transaction: options.transaction} + ); + + // Send session finish email after transaction commits + options.transaction.afterCommit(async () => { + try { + await this.sendSessionFinishEmail(updatedSession); + } catch (error) { + this.server.logger.error(`Failed to send session finish email:`, error); + } + }); + + return updatedSession; } /** @@ -88,6 +246,7 @@ class StudySessionSocket extends Socket { this.createSocket("studySessionSubscribe", this.subscribeToStudySession, {}, false) this.createSocket("studySessionUnsubscribe", this.unsubscribeFromStudySession, {}, false); this.createSocket("studySessionStart", this.startStudySession, {}, true); + this.createSocket("studySessionFinish", this.finishStudySession, {}, true); } } diff --git a/backend/webserver/sockets/template.js b/backend/webserver/sockets/template.js new file mode 100644 index 000000000..0df011285 --- /dev/null +++ b/backend/webserver/sockets/template.js @@ -0,0 +1,778 @@ +"use strict"; +const Socket = require("../Socket"); +const Delta = require("quill-delta"); +const {Op} = require("sequelize"); +const {dbToDelta} = require("editor-delta-conversion"); +const {resolveTemplate, resolveTemplateToDelta, getMissingRequiredPlaceholders} = require("../../utils/templateResolver"); + +/** + * Handle templates through websocket + * + * @author Mohammad Elwan + * @type {TemplateSocket} + * @class TemplateSocket + */ +class TemplateSocket extends Socket { + + /** + * Create a template + * + * @socketEvent templateAdd + * @param {Object} data The data object containing the template info + * @param {string} data.name Template name (required) + * @param {string} data.description Template description (required) + * @param {number} data.type Template type (required, immutable later) + * @param {Object} data.content Initial template content for default language (JSON, required) + * @param {string} [data.defaultLanguage='en'] Default language code + * @param {boolean} [data.published=false] Publish template (makes it visible to all users, cannot be undone) + * @param {Object} options + * @param {Object} options.transaction + * @returns {Promise} + */ + async createTemplate(data, options) { + 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)) { + throw new Error("Access denied: Only administrators can create email templates"); + } + + const defaultLanguage = data.defaultLanguage || "en"; + const templatePayload = { + name: data.name, + description: data.description, + type: data.type, + defaultLanguage, + published: data.published ?? false, + userId: this.userId, + }; + + const template = await this.models["template"].add(templatePayload, { transaction: options.transaction }); + + await this.models["template_content"].add( + { + templateId: template.id, + language: defaultLanguage, + content: data.content, + }, + { transaction: options.transaction } + ); + + return template; + } + + /** + * Get template content (deltas) for editor + * + * Fetches the template and returns its content as Quill Delta format for the given language. + * - For owners: returns stable content from template_content composed with draft edits (like documents) + * - For non-owners: returns only stable content (no drafts) + * + * @socketEvent templateGetContent + * @param {Object} data The data object + * @param {number} data.templateId Template ID (required) + * @param {string} data.language Language code (required, e.g. 'en', 'de') + * @param {Object} options + * @param {Object} options.transaction + * @returns {Promise} + */ + async getContent(data, options){ + if (!data.templateId) throw new Error("Template ID is required"); + if (!data.language) throw new Error("Language is required"); + + const template = await this.models["template"].getById(data.templateId); + if (!template) { + throw new Error("Template not found"); + } + + const isOwner = template.userId === this.userId; + const isPublishedFromOthers = template.published === true && !isOwner; + + if (!isOwner && !isPublishedFromOthers) { + throw new Error("You can only view templates that you own or published templates from others"); + } + + const langRow = await this.models["template_content"].findOne({ + where: { templateId: data.templateId, language: data.language, deleted: false }, + raw: true, + ...options, + }); + + let delta = new Delta(); + if (langRow && langRow.content && langRow.content.ops) { + delta = new Delta(langRow.content.ops); + } + + if (isOwner) { + const draftEdits = await this.models["template_edit"].findAll({ + where: { templateId: data.templateId, language: data.language, draft: true, deleted: false }, + order: [ + ["createdAt", "ASC"], + ["order", "ASC"], + ], + raw: true, + ...options, + }); + + if (draftEdits.length > 0) { + const draftDelta = new Delta(dbToDelta(draftEdits)); + delta = delta.compose(draftDelta); + } + } + + const isNewLanguage = !langRow; + return { template, deltas: delta, isNewLanguage }; + } + + /** + * Save template content edits (deltas) to template_edit table + * + * Saves content edits as draft edits in template_edit table for the given language. + * Drafts are merged into template_content when the editor is closed. + * Users can only edit content of their own templates. + * + * @socketEvent templateEditContent + * @param {Object} data The data object + * @param {number} data.templateId Template ID (required) + * @param {string} data.language Language code (required) + * @param {Array} data.ops Delta operations in database format (from deltaToDb) + * @param {Object} options + * @param {Object} options.transaction + * @returns {Promise} + */ + async editContent(data, options) { + if (!data.templateId) throw new Error("Template ID is required"); + if (!data.language) throw new Error("Language is required"); + if (!data.ops || !Array.isArray(data.ops)) { + throw new Error("Delta operations are required"); + } + + const template = await this.models["template"].getById(data.templateId); + if (!template) { + throw new Error("Template not found"); + } + + // Check ownership: users (including admins) can only edit content of their own templates + if (template.userId !== this.userId) { + throw new Error("You can only edit content of templates that you own"); + } + + // Copied templates cannot be edited + if (template.sourceId) { + throw new Error("Copied templates cannot be edited"); + } + + const bulkEdits = data.ops.map((op, idx) => ({ + userId: this.userId, + templateId: data.templateId, + language: data.language, + draft: true, + order: idx + 1, + ...op, + })); + + await this.models["template_edit"].bulkCreate(bulkEdits, { + transaction: options.transaction, + }); + + return; + } + + + /** + * Update a template + * + * @socketEvent templateUpdate + * @param {Object} data The data object containing the template update + * @param {number} data.id Template ID to update (required) + * @param {string} [data.name] New name + * @param {string} [data.description] New description + * @param {string} [data.defaultLanguage] Default language code + * @param {boolean} [data.published] New published flag (can only be set to true, cannot be unpublished) + * @param {Object} options + * @param {Object} options.transaction + * @returns {Promise} + */ + async updateTemplate(data, options) { + if (!data.id) throw new Error("Template ID is required"); + + // Get current template + const currentTemplate = await this.models["template"].getById(data.id); + if (!currentTemplate) { + throw new Error("Template not found"); + } + + // Check ownership: users (including admins) can only update their own templates + if (currentTemplate.userId !== this.userId) { + throw new Error("You can only update templates that you own"); + } + + // Copied templates cannot be edited + if (currentTemplate.sourceId) { + throw new Error("Copied templates cannot be edited"); + } + + // Prevent editing published templates from others (view-only) + if (currentTemplate.published === true && currentTemplate.userId !== this.userId) { + throw new Error("Published templates from other users are view-only and cannot be edited"); + } + + // Prevent unpublishing: if template is published, cannot set to false + if (currentTemplate.published === true && data.published === false) { + throw new Error("Cannot unpublish a template once it has been published"); + } + + const updateData = {}; + if (data.name !== undefined) updateData.name = data.name; + if (data.description !== undefined) updateData.description = data.description; + if (data.type !== undefined) updateData.type = data.type; + if (data.defaultLanguage !== undefined) updateData.defaultLanguage = data.defaultLanguage; + if (data.published !== undefined) updateData.published = data.published; + + return await this.models["template"].updateById( + data.id, + updateData, + { transaction: options.transaction } + ); + } + + + /** + * Add a placeholder to a template type + * + * @socketEvent templatePlaceholderAdd + * @param {Object} data The data object + * @param {number} data.templateType Template type (required, 1-5) + * @param {string} data.placeholderKey Placeholder key (required, e.g., "username") + * @param {string} data.placeholderLabel Placeholder label (required, e.g., "Username") + * @param {string} data.placeholderType Placeholder type (required, e.g., "text") + * @param {boolean} [data.required=false] Whether placeholder is required + * @param {Object} options + * @param {Object} options.transaction + * @returns {Promise} + */ + 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.placeholderKey || !data.placeholderLabel || !data.placeholderType) { + throw new Error("Missing required fields: placeholderKey, placeholderLabel, placeholderType"); + } + + const payload = { + type: data.templateType, + placeholderKey: data.placeholderKey, + placeholderLabel: data.placeholderLabel, + placeholderType: data.placeholderType, + required: data.required ?? false, + }; + + return await this.models["placeholder"].add( + payload, + { transaction: options.transaction } + ); + } + + /** + * Update a placeholder's metadata + * + * @socketEvent templatePlaceholderUpdate + * @param {Object} data The data object + * @param {number} data.id Placeholder mapping ID (required) + * @param {string} [data.placeholderLabel] New placeholder label + * @param {string} [data.placeholderType] New placeholder type + * @param {boolean} [data.required] New required flag + * @param {Object} options + * @param {Object} options.transaction + * @returns {Promise} + */ + 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; + if (data.required !== undefined) updateData.required = data.required; + + if (Object.keys(updateData).length === 0) { + throw new Error("No fields to update"); + } + + return await this.models["placeholder"].updateById( + data.id, + updateData, + { transaction: options.transaction } + ); + } + + + /** + * Get all placeholders for a template type + * + * @socketEvent templatePlaceholderGetAll + * @param {Object} data The data object + * @param {number} data.templateId Template ID (required) - used to get template type + * @param {Object} options + * @param {Object} options.transaction + * @returns {Promise} + */ + async getAllPlaceholders(data, options) { + if (!data.templateId) throw new Error("Template ID is required"); + + const template = await this.models["template"].getById(data.templateId); + if (!template) { + throw new Error("Template not found"); + } + + // Check access: users (including admins) can view placeholders for their own templates or published templates from others + const isOwner = template.userId === this.userId; + const isPublishedFromOthers = template.published === true && !isOwner; + + if (!isOwner && !isPublishedFromOthers) { + throw new Error("Access denied: You can only view placeholders for templates that you own or published templates from others"); + } + + return await this.models["placeholder"].getAllByKey( + "type", + template.type, + { transaction: options.transaction } + ); + } + + + /** + * Get list of language codes that have content for a template + * + * @socketEvent templateGetLanguages + * @param {Object} data The data object + * @param {number} data.templateId Template ID (required) + * @param {Object} options + * @param {Object} options.transaction + * @returns {Promise>} + */ + async getLanguages(data, options) { + if (!data.templateId) throw new Error("Template ID is required"); + + const template = await this.models["template"].getById(data.templateId); + if (!template) { + throw new Error("Template not found"); + } + const isOwner = template.userId === this.userId; + const isPublishedFromOthers = template.published === true && !isOwner; + if (!isOwner && !isPublishedFromOthers) { + throw new Error("You can only view templates that you own or published templates from others"); + } + + const rows = await this.models["template_content"].findAll({ + where: { templateId: data.templateId, deleted: false }, + attributes: ["language"], + raw: true, + ...options, + }); + const languages = rows.map((r) => r.language).sort(); + return { languages, defaultLanguage: template.defaultLanguage || "en" }; + } + + /** + * Create or ensure a language content row for a template (for "add language" in editor) + * + * When the user adds a new language, creates a row in template_content. + * If content is provided (copy-from-current case), uses that content; otherwise creates empty content. + * + * @socketEvent templateAddLanguageContent + * @param {Object} data The data object + * @param {number} data.templateId Template ID (required) + * @param {string} data.language Language code (required) + * @param {Object} [data.content] Content to copy (optional; if omitted, creates minimal empty content) + * @param {Object} options + * @param {Object} options.transaction + * @returns {Promise} + */ + async addLanguageContent(data, options) { + if (!data.templateId) throw new Error("Template ID is required"); + if (!data.language) throw new Error("Language is required"); + + const template = await this.models["template"].getById(data.templateId); + if (!template) { + throw new Error("Template not found"); + } + if (template.userId !== this.userId) { + throw new Error("You can only add language content to templates that you own"); + } + + const templateContentModel = this.models["template_content"]; + const existing = await templateContentModel.findOne({ + where: { templateId: data.templateId, language: data.language, deleted: false }, + raw: true, + ...options, + }); + if (existing) { + return { success: true, existing: true }; + } + + const content = data.content && data.content.ops + ? data.content + : { ops: [{ insert: "\n" }] }; + + await templateContentModel.add( + { templateId: data.templateId, language: data.language, content }, + { transaction: options.transaction } + ); + return { success: true, existing: false }; + } + + /** + * Resolve template placeholders with context data + * + * Resolves all placeholders in a template using the provided context data. + * Uses context.language or template.defaultLanguage to pick content from template_content. + * Returns resolved content as HTML string or Quill Delta object. + * + * @socketEvent templateResolve + * @param {Object} data The data object + * @param {number} data.templateId Template ID (required) + * @param {Object} data.context Context object for placeholder resolution (required) + * @param {number} [data.context.userId] User/participant ID + * @param {number} [data.context.creatorId] Study creator ID + * @param {number} [data.context.studyId] Study ID (for anonymization check) + * @param {number} [data.context.studySessionId] Study session ID + * @param {string} [data.context.studySessionHash] Study session hash (for link) + * @param {string} [data.context.baseUrl] Base URL for generating links + * @param {string} [data.context.assignmentType] Assignment type + * @param {string} [data.context.assignmentName] Assignment name + * @param {boolean} [data.context.anonymize] Override anonymization + * @param {string} [data.format="html"] Return format: "html" or "delta" + * @param {Object} options + * @param {Object} options.transaction + * @returns {Promise} + */ + async resolveTemplatePlaceholders(data, options) { + if (!(await this.isAdmin())) throw new Error("Access denied"); + if (!data.templateId) throw new Error("Template ID is required"); + if (!data.context || typeof data.context !== 'object') { + throw new Error("Context object is required"); + } + + // Get baseUrl from settings if not provided in context + if (!data.context.baseUrl) { + const baseUrl = await this.models["setting"].get("system.baseUrl", options); + data.context.baseUrl = baseUrl || "localhost:3000"; + } + + const format = data.format || "html"; + + if (format === "delta") { + return await resolveTemplateToDelta( + data.templateId, + data.context, + this.models, + options + ); + } else { + return await resolveTemplate( + data.templateId, + data.context, + this.models, + options + ); + } + } + + /** + * Save template by merging draft edits into template_content for the given language + * + * Merges all draft edits (draft=true) from template_edit for (templateId, language) into + * the content row in template_content, then marks edits as draft=false. + * Called when the editor is closed or when switching language. + * + * @param {number} templateId Template ID to save + * @param {string} language Language code (e.g. 'en', 'de') + * @param {Object} options + * @param {Object} options.transaction + * @returns {Promise} + */ + async saveTemplate(templateId, language, options = {}) { + const template = await this.models["template"].getById(templateId); + if (!template) { + this.logger.error(`Template not found.`); + return; + } + + const edits = await this.models["template_edit"].findAll({ + where: { templateId, language, draft: true, deleted: false }, + order: [ + ["createdAt", "ASC"], + ["order", "ASC"], + ], + raw: true, + ...options, + }); + + if (edits.length === 0) { + if ([1, 2, 3, 6].includes(template.type)) { + const templateContentModel = this.models["template_content"]; + const langRow = await templateContentModel.findOne({ + where: { templateId, language, deleted: false }, + raw: true, + ...options, + }); + let baseContent = new Delta(); + if (langRow && langRow.content && langRow.content.ops) { + baseContent = new Delta(langRow.content.ops); + } + const missing = await getMissingRequiredPlaceholders( + { ops: baseContent.ops }, + template.type, + this.models, + options + ); + if (missing.length > 0) { + const tokens = missing.map((k) => `~${k}~`).join(", "); + throw new Error( + `This email template must include the required placeholder(s): ${tokens}. Add them from the toolbar before saving.` + ); + } + } + return; + } + + const templateContentModel = this.models["template_content"]; + const langRow = await templateContentModel.findOne({ + where: { templateId, language, deleted: false }, + raw: true, + ...options, + }); + + let baseContent = new Delta(); + if (langRow && langRow.content && langRow.content.ops) { + baseContent = new Delta(langRow.content.ops); + } + 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)) { + const missing = await getMissingRequiredPlaceholders( + { ops: mergedDelta.ops }, + template.type, + this.models, + options + ); + if (missing.length > 0) { + const tokens = missing.map((k) => `~${k}~`).join(", "); + throw new Error( + `This email template must include the required placeholder(s): ${tokens}. Add them from the toolbar before saving.` + ); + } + } + + const contentPayload = { content: { ops: mergedDelta.ops } }; + if (langRow) { + await templateContentModel.update(contentPayload, { + where: { id: langRow.id }, + transaction: options.transaction, + }); + } else { + await templateContentModel.add( + { templateId, language, content: contentPayload.content }, + { transaction: options.transaction } + ); + } + + // Mark edits as draft:false + await this.models["template_edit"].update( + { draft: false }, + { + where: { id: edits.map((e) => e.id) }, + transaction: options.transaction, + } + ); + + // Touch template.updatedAt so "Update available" works for copies when source content changes + // NOTE: Must use instance-level save — Model.update() and updateById both fail to persist updatedAt + const templateInstance = await this.models["template"].findByPk(templateId, { transaction: options.transaction }); + templateInstance.changed('updatedAt', true); + await templateInstance.save({ fields: ['updatedAt'], transaction: options.transaction }); + + this.logger.info(`Template saved successfully.`); + } + + /** + * Close template and save if owner + * + * Called when the template editor is closed. Saves the current language's content. + * + * @socketEvent templateClose + * @param {Object} data The data object + * @param {number} data.templateId Template ID (required) + * @param {string} data.language Language code (required) + * @param {Object} options + * @param {Object} options.transaction + * @returns {Promise} + */ + async closeTemplate(data, options) { + if (!data.templateId) throw new Error("Template ID is required"); + if (!data.language) throw new Error("Language is required"); + + const template = await this.models["template"].getById(data.templateId); + if (!template) return; + + if (template.userId === this.userId) { + await this.saveTemplate(data.templateId, data.language, options); + } + } + + /** + * Detach a template copy from its source (set sourceId to null). + * After detachment, the template can be edited like any user-created template. + * + * @socketEvent templateDetach + * @param {Object} data + * @param {number} data.templateId - The copy's template ID (required) + * @param {Object} options + * @param {Object} options.transaction + * @returns {Promise} + */ + async detachTemplate(data, options) { + if (!data.templateId) throw new Error("Template ID is required"); + + const copy = await this.models["template"].getById(data.templateId); + if (!copy) throw new Error("Template not found"); + if (copy.userId !== this.userId) throw new Error("You can only detach your own copies"); + if (!copy.sourceId) throw new Error("Template is not a copy"); + + return await this.models["template"].detach( + data.templateId, + { transaction: options.transaction } + ); + } + + /** + * Copy a published template to the current user's template list + * + * @socketEvent templateCopy + * @param {Object} data + * @param {number} data.sourceTemplateId - Source template ID (required) + * @param {boolean} [data.force=false] - Skip duplicate check (for "Make new copy") + * @param {number} [data.detachTemplateId] - If set, detach this copy after creating the new one + * @param {Object} options + * @param {Object} options.transaction + * @returns {Promise} + */ + async copyTemplate(data, options) { + 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)) { + throw new Error("Access denied: Only administrators can copy email templates"); + } + + const copiedTemplate = await this.models["template"].copyTemplate( + data.sourceTemplateId, + this.userId, + { force: data.force || false }, + { transaction: options.transaction } + ); + + if (data.detachTemplateId) { + const toDetach = await this.models["template"].getById(data.detachTemplateId); + if (toDetach && toDetach.userId === this.userId && toDetach.sourceId) { + await this.models["template"].detach( + data.detachTemplateId, + { transaction: options.transaction } + ); + } + } + + return copiedTemplate; + } + + /** + * Update a copied template with the latest content from its source + * + * @socketEvent templateUpdateFromSource + * @param {Object} data + * @param {number} data.templateId - The copy's template ID (required) + * @param {Object} options + * @param {Object} options.transaction + * @returns {Promise} + */ + async updateFromSource(data, options) { + if (!data.templateId) throw new Error("Template ID is required"); + + const copy = await this.models["template"].getById(data.templateId); + if (!copy) throw new Error("Template not found"); + if (copy.userId !== this.userId) throw new Error("You can only update your own copies"); + + return await this.models["template"].updateFromSource( + data.templateId, + { transaction: options.transaction } + ); + } + + /** + * Soft-delete a template after verifying ownership and usage constraints. + * + * @socketEvent templateDelete + * @param {Object} data + * @param {number} data.templateId - The template ID to delete (required) + * @param {Object} options + * @param {Object} options.transaction + * @returns {Promise} + */ + async deleteTemplate(data, options) { + if (!data.templateId) throw new Error("Template ID is required"); + + const template = await this.models["template"].getById(data.templateId, {transaction: options.transaction}); + if (!template) { + throw new Error("Template not found"); + } + + if (template.userId !== this.userId) { + throw new Error("You can only delete templates that you own"); + } + + if (template.published && [1, 2, 3, 6].includes(template.type)) { + throw new Error("Published email templates cannot be deleted"); + } + + if ([1, 2, 3, 6].includes(template.type)) { + const usedBySettings = await this.models["setting"].findAll({ + where: { + key: {[Op.like]: "email.template.%"}, + value: String(template.id), + }, + raw: true, + transaction: options.transaction, + }); + if (usedBySettings.length > 0) { + const settingNames = usedBySettings.map(s => s.key.replace("email.template.", "")).join(", "); + throw new Error(`Template is currently assigned as an email template (${settingNames}). Please unassign it in Settings before deleting.`); + } + } + + return await this.models["template"].deleteById(data.templateId, {transaction: options.transaction}); + } + + init() { + this.createSocket("templateAdd", this.createTemplate, {}, true); + this.createSocket("templateUpdate", this.updateTemplate, {}, true); + this.createSocket("templateGetContent", this.getContent, {}, false); + this.createSocket("templateGetLanguages", this.getLanguages, {}, false); + this.createSocket("templateEditContent", this.editContent, {}, true); + this.createSocket("templateAddLanguageContent", this.addLanguageContent, {}, true); + this.createSocket("templateClose", this.closeTemplate, {}, true); + this.createSocket("templatePlaceholderAdd", this.addPlaceholder, {}, true); + this.createSocket("templatePlaceholderUpdate", this.updatePlaceholder, {}, true); + this.createSocket("templatePlaceholderGetAll", this.getAllPlaceholders, {}, false); + this.createSocket("templateResolve", this.resolveTemplatePlaceholders, {}, false); + this.createSocket("templateCopy", this.copyTemplate, {}, true); + this.createSocket("templateDetach", this.detachTemplate, {}, true); + this.createSocket("templateUpdateFromSource", this.updateFromSource, {}, true); + this.createSocket("templateDelete", this.deleteTemplate, {}, true); + } +} + +module.exports = TemplateSocket; \ No newline at end of file diff --git a/docs/source/for_developers/frontend/components/components.rst b/docs/source/for_developers/frontend/components/components.rst index 65d0684fb..99e6368ae 100644 --- a/docs/source/for_developers/frontend/components/components.rst +++ b/docs/source/for_developers/frontend/components/components.rst @@ -41,6 +41,7 @@ Each entry links to its dedicated documentation page: dashboard document editor + templates stepmodal study apply_skill_preprocessing @@ -141,6 +142,24 @@ By separating session resumption into its own component, CARE cleanly distinguis ----- +Templates +--------- + +The :doc:`Templates ` component provides **email and document content templates** available from the Dashboard. +Templates are used for system emails (e.g. password reset, verification, registration), session start/finish and assignment emails, study-closed notifications, and pre-filled document content. + +- **Template types** 1–6: Email - General, Email - Study Session, Email - Assignment, Document - General, Document - Study, Email - Study Close. +- Placeholders (e.g. ``~username~``, ``~link~``) are resolved per type by the backend (``backend/utils/templateResolver.js``). +- Content is stored per language in ``template_language_content``; the Editor shows TemplateEditor and, for email types, a **Placeholders** sidebar (TemplateConfigurator) when editing a template (``templateId`` provided). + +.. code-block:: html + + + +For full details on template types, placeholders, and where each type is used, see :doc:`templates`. + +----- + NLP Skill Preprocessing ----------------------- diff --git a/docs/source/for_developers/frontend/components/templates.rst b/docs/source/for_developers/frontend/components/templates.rst new file mode 100644 index 000000000..fe2d55745 --- /dev/null +++ b/docs/source/for_developers/frontend/components/templates.rst @@ -0,0 +1,101 @@ +Templates +========= + +The **Templates** system provides email and document content templates that can be used for system emails, session and assignment notifications, study-closed emails, and pre-filled document content. +Templates are edited in the same Quill-based Editor as documents; placeholder resolution is done by the backend when the template is used. + +Key features include: + + - **Template types** 1–6 (Email - General, Study Session, Assignment, Document - General, Document - Study, Email - Study Close), each with a fixed set of placeholders resolved at runtime. + - **Multi-language content** stored in ``template_language_content``; default language on the ``template`` row. + - **TemplateEditor** and **TemplateConfigurator** (Placeholders sidebar) shown when the Editor is opened with a template (``templateId`` provided). + - **Toolbar and editor behavior** controlled by the same settings as the document editor (see :ref:`Editor Settings `). + +Overview +-------- + +Templates are listed and created from **Dashboard → Templates** (nav from ``nav_element``; see migration ``extend-nav_element-templates``). + +Location: ``frontend/src/components/dashboard/Templates.vue`` + +When you open a template for editing, the Editor loads with ``templateId`` provided; it renders the :doc:`editor` (TemplateEditor) for the main content and, for email types (1, 2, 3, 6), a **Placeholders** sidebar so you can insert allowed placeholders (e.g. ``~username~``, ``~link~``) into the text. + +Location: ``frontend/src/components/editor/sidebar/TemplateConfigurator.vue`` + +Backend storage: + +- **template** — name, type, published, defaultLanguage, userId. +- **template_language_content** — content (Quill Delta) per template and language. +- **template_edit** — draft edits per template and language. +- **template_placeholder_mapping** — placeholder keys and labels per template type (used by the frontend sidebar; resolution rules live in the resolver). + +Location: ``backend/utils/templateResolver.js`` + +Placeholder resolution is implemented there: ``resolveTemplate`` (returns HTML for emails) and ``resolveTemplateToDelta`` (returns Delta for document creation). +Only placeholders listed in ``PLACEHOLDERS_BY_TYPE`` for the template's type are substituted at runtime. + +Implementing the Template Editor +--------------------------------- + +The main Editor provides ``templateId`` via ``provide`` and conditionally shows the Placeholders sidebar when the document is a template with placeholders. +TemplateEditor and TemplateConfigurator are used inside this Editor when editing a template. + +Location: + +- ``frontend/src/components/editor/Editor.vue`` +- ``frontend/src/components/editor/template/TemplateEditor.vue`` +- ``frontend/src/components/editor/sidebar/TemplateConfigurator.vue`` + +.. code-block:: html + + + + + +Template Types and Placeholders Resolved at Runtime +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +At resolution time, only the following placeholder keys are substituted (source: ``PLACEHOLDERS_BY_TYPE`` in ``backend/utils/templateResolver.js``). + +- **Type 1 (Email - General):** ``username``, ``firstName``, ``lastName``, ``link`` +- **Type 2 (Email - Study Session):** ``username``, ``link`` +- **Type 3 (Email - Assignment):** ``username``, ``assignmentType``, ``assignmentName``, ``link`` +- **Type 4 (Document - General):** none +- **Type 5 (Document - Study):** none +- **Type 6 (Email - Study Close):** ``username``, ``studyName`` + +Where Each Type Is Used +~~~~~~~~~~~~~~~~~~~~~~~ + +- **Type 1** — Auth/system emails: settings ``email.template.passwordReset``, ``email.template.verification``, ``email.template.registration``. Location: ``auth.js`` +- **Type 2** — Session start/finish emails: settings ``email.template.sessionStart``, ``email.template.sessionFinish``. Location: ``study_session.js`` +- **Type 3** — Assignment emails: setting ``email.template.assignment``. Location: ``assignment.js`` +- **Type 4** — Pre-fill document content when creating a document with ``templateId`` (``createDocument``). Location: ``document.js`` +- **Type 5** — Document template for a study step (when creating document from template). Location: ``study_step.js`` +- **Type 6** — Study-closed emails: setting ``email.template.studyClosed`` (``sendStudyClosedEmails``). Location: ``study.js`` + +Adding a New Template Type or Placeholder +----------------------------------------- + +1. **Backend:** Add or extend rows in ``template_placeholder_mapping`` via a migration (template type and placeholder key). + Update ``PLACEHOLDERS_BY_TYPE`` and ``buildReplacementMap`` in ``backend/utils/templateResolver.js`` so the new key is filled from context. + If the new type is used from a specific feature (e.g. study close uses type 6), add or adjust the call site to pass the correct context. + +2. **Frontend:** Add the type label and any placeholder descriptions (e.g. ``templateTypeName``, ``placeholderConfigs``, ``longDescriptions``). For the type to appear in the create flow, add it to the type dropdown. + + Location: + + - ``frontend/src/components/editor/sidebar/TemplateConfigurator.vue`` + - ``frontend/src/components/dashboard/templates/TemplateModal.vue`` + +3. **Access:** Ensure ``getUserFilter`` allows the right visibility for the new type (e.g. admin-only for email types, or document types for non-admins). + + Location: ``backend/db/models/template.js`` + +Settings +-------- + +The template editor uses the same toolbar and edit settings as the document editor. +See :ref:`Editor Settings ` in the :doc:`editor` documentation and :ref:`Adding a New Setting ` in :doc:`../../examples/settings` if you need to add or change a setting key. diff --git a/docs/source/for_researchers/study/study_basics.rst b/docs/source/for_researchers/study/study_basics.rst index 881093d88..7fbc273fe 100644 --- a/docs/source/for_researchers/study/study_basics.rst +++ b/docs/source/for_researchers/study/study_basics.rst @@ -191,6 +191,10 @@ Working with Study Templates Study templates allow you to reuse study configurations for future studies, saving time and ensuring consistency. +.. note:: + + **Study templates** (saved study configurations) are different from **email and document content templates**, which define the text of emails (e.g. password reset, session links) and pre-filled document content. For content templates, see :doc:`Email and Document Templates <../templates>`. + **Creating Templates:** You can create a template in two ways: diff --git a/docs/source/for_researchers/templates.rst b/docs/source/for_researchers/templates.rst new file mode 100644 index 000000000..6185b58cf --- /dev/null +++ b/docs/source/for_researchers/templates.rst @@ -0,0 +1,90 @@ +Email and Document Templates +============================= + +This section describes **email and document content templates** in CARE. +These are different from **study templates** (saved study configurations): content templates define the text of emails (e.g. password reset, session links, study closed) and the initial body of documents created from a template. + +You manage content templates from **Dashboard → Templates**. +They are used for: + +- System emails (password reset, verification, registration) +- Session start and session finish emails +- Assignment emails (when a reviewer is assigned to a task) +- Study-closed emails (when a study is closed and participants with open sessions are notified) +- Pre-filled document content when creating a new document from a template + +When to Use Which Template Type +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Each template has a **type** that determines where it can be used and which **placeholders** (e.g. ``~username~``, ``~link~``) are replaced when the template is sent or applied. +Only the placeholders listed below for each type are actually replaced; others will appear as literal text or be empty. + +**Email - General (type 1)** +Used for system emails. Assign the template in **Dashboard → Settings** to the keys for password reset, verification, or registration. +Placeholders that are replaced: ``~username~``, ``~firstName~``, ``~lastName~``, ``~link~``. + +**Email - Study Session (type 2)** +Used for emails when a session is started or finished. Assign the template in Settings to **Session start** and **Session finish**. +Placeholders that are replaced: ``~username~``, ``~link~``. + +**Email - Assignment (type 3)** +Used when a reviewer is assigned to a document or submission. Assign the template in Settings to **Assignment**. +Placeholders that are replaced: ``~username~``, ``~assignmentType~``, ``~assignmentName~``, ``~link~``. + +**Email - Study Close (type 6)** +Used when a study is closed and participants who still had an open session are notified. Assign the template in Settings to **Study closed**; the study must have **Enable Study Close Email Notifications** turned on (see **Checkboxes So Emails Actually Get Sent** below). +Placeholders that are replaced: ``~username~`` (the session owner), ``~studyName~``. + +**Document - General (type 4)** and **Document - Study (type 5)** +Used when creating a new document and pre-filling its content from a template. No placeholders are replaced; use these for static boilerplate. +Type 4 is for general documents; type 5 is for documents used in studies. + +Flow to Create and Use a Template +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. **Create:** In the Dashboard, open **Templates** and click **Add**. Choose the **type** and enter a name (and description). +2. **Edit:** Open the template. Write the body; for email types, use the **Placeholders** sidebar to insert allowed placeholders (e.g. ``~username~``, ``~link~``) where they should be replaced. +3. **Assign:** In **Dashboard → Settings**, open the section that contains the **email template** settings. You will see one setting per email use (e.g. password reset, session start, session finish, assignment, study closed). Each is a **dropdown**: choose a template from the list, or leave **None (use default email)** to use the built-in text. For **document templates** (types 4 and 5), you do not assign them in Settings; choose a template when **creating a document** (e.g. Dashboard → Documents → Create). +4. **Publish (optional):** Publishing makes the template visible to other users. Once published, it cannot be unpublished or deleted (see below). + +If you leave a template setting at **None (use default email)** or the system cannot load the template, CARE uses a **default (fallback) email** for that feature, so the action (e.g. sending the email) still completes with built-in text. To use your own text, assign a template and ensure the relevant checkbox below is enabled. + +Checkboxes So Emails Actually Get Sent +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For template-based emails to be sent, the relevant option must be enabled: + +- **Session start and session finish emails** — The **study** must have **Enable Email Notifications** turned on. When creating or editing the study (e.g. in the study coordinator), enable the **Enable Email Notifications** switch; otherwise session start/finish emails are not sent even if you assigned templates in Settings. +- **Study closed emails** — The **study** must have **Enable Study Close Email Notifications** (or "Send study closed emails") turned on. In the study settings, enable that switch; otherwise study-closed emails are not sent even if you assigned a template in Settings. +- **Assignment emails** — When creating assignments (**Single Assignment** or **Bulk Assignment** from the Studies dashboard), you must **check the option to send email notifications** (e.g. "Send email notification") in that modal before confirming. If it is unchecked, no assignment emails are sent even if you assigned a template in Settings. + +Where Templates Are Used +~~~~~~~~~~~~~~~~~~~~~~~~ + +- **System/auth emails** — Settings (password reset, verification, registration). +- **Session start and session finish emails** — Sent when a participant starts or finishes a session; template set in Settings; study must have **Enable Email Notifications** on. +- **Assignment emails** — Sent when a reviewer is assigned; template set in Settings; checkbox in Single/Bulk Assignment modal must be checked when creating the assignment. +- **Study closed emails** — Sent when a study is closed to participants with open sessions; template set in Settings; study must have **Enable Study Close Email Notifications** on. +- **Document creation** — When creating a document, you can select a document template (type 4 or 5) to pre-fill the content. + +Publishing and Deletion +~~~~~~~~~~~~~~~~~~~~~~~ + +**Publishing** makes an email template (types 1, 2, 3, 6) visible to other users. +**Once a template is published:** + +- It **cannot be unpublished**. +- It **cannot be deleted**. + +This is enforced so that templates already in use (e.g. referenced in Settings or in studies) are not removed, which would break those features. +If you need a different version, create a new template or duplicate an existing one, and avoid publishing until the content is final. + +Errors and Placeholders Not Replaced +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +**Wrong template type** +If you change the type of a template after writing content, placeholders that are not allowed for the new type will **not** be replaced when the template is used (they may appear as literal ``~name~`` or empty). +Choose the correct type when creating the template. + +**Document templates (types 4 and 5)** +No placeholders are replaced in document templates. Any ``~...~`` in the text will remain as-is. diff --git a/docs/source/index.rst b/docs/source/index.rst index 44b253eca..8c119273d 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -37,6 +37,7 @@ The project is developed at the `UKP Lab
@@ -86,6 +86,96 @@ />
+ + +
+ +
+
+ + + + + + + + + + + + + + +
+
+
+
@@ -105,6 +195,7 @@ import FormChoice from "@/basic/form/Choice.vue"; import deepEqual from "deep-equal"; import {computed} from "vue"; import FormFile from "@/basic/form/File.vue"; +import Collapsible from "@/basic/form/Collapsible.vue"; /** * Basic form component for rendering form fields provided by fields prop @@ -125,7 +216,8 @@ export default { FormTextarea, FormEditor, FormTable, - FormChoice + FormChoice, + Collapsible }, provide() { return { @@ -157,6 +249,12 @@ export default { return acc; }, {}); }, + regularFields() { + return this.fields.filter(field => !field.advanced); + }, + advancedFields() { + return this.fields.filter(field => field.advanced === true); + }, }, watch: { currentData: { @@ -193,7 +291,7 @@ export default { }, validate() { return Object.keys(this.$refs) - .filter((child) => typeof this.$refs[child][0].validate === "function") + .filter((child) => this.$refs[child][0] && typeof this.$refs[child][0].validate === "function") .map((child) => this.$refs[child][0].validate()) .every(Boolean); }, diff --git a/frontend/src/basic/Table.vue b/frontend/src/basic/Table.vue index bf5de7a6f..15e1e1a60 100644 --- a/frontend/src/basic/Table.vue +++ b/frontend/src/basic/Table.vue @@ -1010,12 +1010,27 @@ export default { getFilteredButtons(row) { const filteredButtons = this.buttons.filter((b) => { if (!b.filter || !b.filter.length) return true; - return b.filter.some((f) => { - if (f.type === "not") { - return row[f.key] !== f.value; - } - return row[f.key] === f.value; - }); + + // Support filterMode: "and" or "or" (default: "or" for backward compatibility) + const filterMode = b.filterMode || "or"; + + if (filterMode === "and") { + // AND logic: all filters must match + return b.filter.every((f) => { + if (f.type === "not") { + return row[f.key] !== f.value; + } + return row[f.key] === f.value; + }); + } else { + // OR logic (default): at least one filter must match + return b.filter.some((f) => { + if (f.type === "not") { + return row[f.key] !== f.value; + } + return row[f.key] === f.value; + }); + } }); // Update this flag if there are any buttons diff --git a/frontend/src/basic/form/Choice.vue b/frontend/src/basic/form/Choice.vue index 948046906..fc1ed4937 100644 --- a/frontend/src/basic/form/Choice.vue +++ b/frontend/src/basic/form/Choice.vue @@ -170,7 +170,7 @@ export default { this.currentData = this.choices.map((choice) => { // Find matched newValue entry by workflowStepId - const matchedValue = newValue.find((v) => v.workflowStepId === choice.id); + const matchedValue = newValue.find((v) => (v.workflowStepId ?? v.id) === choice.id); return { ...this.fields.reduce((acc, field) => { @@ -188,8 +188,19 @@ export default { }, currentData: { handler(newData) { - if (JSON.stringify(this.modelValue) !== JSON.stringify(newData)) { - this.$emit("update:modelValue", newData); + // When documentId is null and step links to another step's document, send the linked step's documentId + const payload = newData.map((entry) => { + const step = this.workflowSteps.find((s) => s.id === entry.id); + const effective = + entry.documentId != null + ? entry.documentId + : step?.workflowStepDocument != null + ? this.getEffectiveDocumentId(entry.id) + : null; + return { ...entry, documentId: effective }; + }); + if (JSON.stringify(this.modelValue) !== JSON.stringify(payload)) { + this.$emit("update:modelValue", payload); } this.$emit("update:configStatus", this.getConfigurationStatus()); }, @@ -208,6 +219,15 @@ export default { }, }, methods: { + getEffectiveDocumentId(workflowStepId) { + const step = this.workflowSteps.find((s) => s.id === workflowStepId); + if (step && step.workflowStepDocument) { + const sourceEntry = this.currentData.find((entry) => entry.id === step.workflowStepDocument); + return sourceEntry?.documentId ?? sourceEntry?.parentDocumentId ?? null; + } + const currentEntry = this.currentData.find((entry) => entry.id === workflowStepId); + return currentEntry?.documentId ?? currentEntry?.parentDocumentId ?? null; + }, // Resolve the effective document for a workflow step. // Prefers linked document via workflowStepDocument, falls back to step's own document. getResolvedDocumentId(workflowStepId) { @@ -291,10 +311,45 @@ export default { if (index >= 0 && index < this.currentData.length) { // Create a new array to trigger the watcher const updatedCurrentData = [...this.currentData]; - updatedCurrentData[index] = { - ...updatedCurrentData[index], - [fieldKey]: value, - }; + + const currentEntry = updatedCurrentData[index] || {}; + const currentConfig = currentEntry.configuration || {}; + + // Special handling for documentId field: detect document templates and + // store the selected template ID in the per-step configuration while + // keeping documentId null so the backend creates a new document from a template. + if (fieldKey === 'documentId') { + let nextDocumentId = value; + let nextConfig = { ...currentConfig }; + + if (typeof value === 'string' && value.startsWith('template:')) { + const templateId = parseInt(value.replace('template:', ''), 10); + if (!Number.isNaN(templateId)) { + nextConfig = { + ...nextConfig, + documentTemplateId: templateId, + }; + } + nextDocumentId = null; + } else { + // Non-template selection: clear any previous template reference in configuration. + if ('documentTemplateId' in nextConfig) { + const { documentTemplateId, ...rest } = nextConfig; + nextConfig = rest; + } + } + + updatedCurrentData[index] = { + ...currentEntry, + documentId: nextDocumentId, + configuration: nextConfig, + }; + } else { + updatedCurrentData[index] = { + ...currentEntry, + [fieldKey]: value, + }; + } this.currentData = updatedCurrentData; } diff --git a/frontend/src/basic/form/Collapsible.vue b/frontend/src/basic/form/Collapsible.vue new file mode 100644 index 000000000..19e90f30b --- /dev/null +++ b/frontend/src/basic/form/Collapsible.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/frontend/src/basic/form/Select.vue b/frontend/src/basic/form/Select.vue index 75cf1715f..fd2814d39 100644 --- a/frontend/src/basic/form/Select.vue +++ b/frontend/src/basic/form/Select.vue @@ -130,6 +130,12 @@ export default { baseOptions = this.$store.getters["table/" + this.options.options.table + "/getAll"]; } + if ((this.options.options?.prependNone || this.options.prependNone) && this.options.options?.table) { + const valueKey = this.options.options.value || 'id'; + const nameKey = this.options.options.name || 'name'; + baseOptions = [{ [valueKey]: null, [nameKey]: 'None' }, ...baseOptions]; + } + // Filter according to additional Options and add to baseOptions if (this.options.options.additionalOptions) { const mappingFilter = this.options.options.filter.find((filter) => filter.type === "parentData"); @@ -160,6 +166,32 @@ export default { baseOptions = [{ id: null, name: '' }, ...baseOptions]; } + // Add document templates (Type 5) to document dropdown for Editor steps in study creation + if ( + this.options.options.table === 'document' && + this.parentValue?.stepType === 2 && + this.formData?.workflowId + ) { + const currentUserId = this.$store.getters["auth/getUserId"]; + const documentTemplates = this.$store.getters["table/template/getAll"] + .filter(t => t.type === 5 && !t.deleted && t.userId === currentUserId) // Type 5 = Document Template, own only + .map(t => { + const valueKey = this.options.options.value || 'id'; + const nameKey = this.options.options.name || 'name'; + return { + [valueKey]: `template:${t.id}`, + [nameKey]: `${t.name} (document template)`, + id: `template:${t.id}`, + name: `${t.name} (document template)`, + value: `template:${t.id}`, + isTemplateOption: true, + templateId: t.id, + }; + }); + + baseOptions = [...baseOptions, ...documentTemplates]; + } + return baseOptions; }, }, diff --git a/frontend/src/basic/navigation/Topbar.vue b/frontend/src/basic/navigation/Topbar.vue index 1cebc866f..9af515e67 100644 --- a/frontend/src/basic/navigation/Topbar.vue +++ b/frontend/src/basic/navigation/Topbar.vue @@ -201,7 +201,11 @@ export default { await this.$router.push("/login"); }, async toHome() { - await this.$router.push('/dashboard'); + if (this.$route.path.startsWith('/template/')) { + await this.$router.push('/dashboard/templates'); + } else { + await this.$router.push('/dashboard'); + } }, toggleProfileDropdown() { const dropdown = document.getElementById('dropdown-show'); diff --git a/frontend/src/components/Study.vue b/frontend/src/components/Study.vue index ec36b6f58..a2079991e 100644 --- a/frontend/src/components/Study.vue +++ b/frontend/src/components/Study.vue @@ -535,13 +535,9 @@ export default { }, finalFinish(data) { this.$socket.emit( - "appDataUpdate", + "studySessionFinish", { - table: "study_session", - data: { - id: data.studySessionId, - end: Date.now(), - }, + studySessionId: data.studySessionId, }, (result) => { if (result.success) { @@ -562,6 +558,15 @@ export default { this.$refs.studyFinishModal.close(); }, finish() { + // Prevent finishing if study is closed + if (this.studyClosed) { + this.eventBus.emit("toast", { + title: "Cannot finish session", + message: "The study has been closed. Sessions are automatically terminated when a study is closed.", + variant: "warning", + }); + return; + } this.$refs.studyFinishModal.open(); }, handleModalClose(event) { diff --git a/frontend/src/components/Template.vue b/frontend/src/components/Template.vue new file mode 100644 index 000000000..a538f8a43 --- /dev/null +++ b/frontend/src/components/Template.vue @@ -0,0 +1,112 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/dashboard/Documents.vue b/frontend/src/components/dashboard/Documents.vue index f5edff065..242c7b361 100644 --- a/frontend/src/components/dashboard/Documents.vue +++ b/frontend/src/components/dashboard/Documents.vue @@ -63,7 +63,7 @@ import DownloadPDFModal from "./documents/DownloadPDFModal.vue"; */ export default { name: "DashboardDocument", - subscribeTable: ["document", "study"], + subscribeTable: ["document", "study", "template"], components: { StudyModal, UploadModal, diff --git a/frontend/src/components/dashboard/Settings.vue b/frontend/src/components/dashboard/Settings.vue index 874868612..4b9bfa37b 100644 --- a/frontend/src/components/dashboard/Settings.vue +++ b/frontend/src/components/dashboard/Settings.vue @@ -151,6 +151,7 @@ import ChangeUserSettingsModal from "@/components/dashboard/settings/ChangeUserS export default { name: "DashboardSettings", + subscribeTable: ["template"], components: { Card, LoadIcon, diff --git a/frontend/src/components/dashboard/Study.vue b/frontend/src/components/dashboard/Study.vue index dd16b7a4a..97f72d872 100644 --- a/frontend/src/components/dashboard/Study.vue +++ b/frontend/src/components/dashboard/Study.vue @@ -66,6 +66,7 @@ + @@ -85,6 +86,7 @@ import BulkAssignmentsModal from "./study/BulkAssignmentModal.vue"; import SingleAssignmentModal from "./study/SingleAssignmentModal.vue"; import InformationModal from "@/basic/modal/InformationModal.vue"; 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"; @@ -97,6 +99,7 @@ export default { name: "DashboardStudy", components: { BulkCloseModal, + StudyCloseModal, Card, BasicTable, StudyModal, @@ -123,7 +126,7 @@ export default { ] }, 'document', - 'study_session', 'workflow', 'workflow_step', 'study_step'], + 'study_session', 'workflow', 'workflow_step', 'study_step', 'template'], data() { return { options: { @@ -331,6 +334,15 @@ export default { {name: "Sessions", key: "sessions", sortable: true}, {name: "Session Limit", key: "limitSessions", sortable: true}, {name: "Session Limit per User", key: "limitSessionsPerUser", sortable: true}, + { + name: "Session Start/Finish Emails", + key: "enableEmailNotifications", + type: "badge", + typeOptions: { + keyMapping: { true: "Yes", false: "No" }, + classMapping: { true: "bg-success", false: "bg-danger" } + } + }, { name: "Resumable", key: "resumable", @@ -461,28 +473,7 @@ export default { this.$refs.studySessionModal.open(data.params.id); } else if (data.action === "closeStudy") { - - this.$socket.emit("appDataUpdate", { - table: "study", - data: { - id: data.params.id, - closed: true - } - }, (result) => { - if (result.success) { - this.eventBus.emit('toast', { - title: "Study closed", - message: "The study has been closed", - variant: "success" - }); - } else { - this.eventBus.emit('toast', { - title: "Study closing failed", - message: result.message, - variant: "danger" - }); - } - }); + this.$refs.studyCloseModal.open(data.params); } else if (data.action === "saveAsTemplate") { this.saveAsTemplate(data.params); } else if (data.action === "showInformation") { diff --git a/frontend/src/components/dashboard/Templates.vue b/frontend/src/components/dashboard/Templates.vue new file mode 100644 index 000000000..32a475c80 --- /dev/null +++ b/frontend/src/components/dashboard/Templates.vue @@ -0,0 +1,364 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/dashboard/documents/CreateModal.vue b/frontend/src/components/dashboard/documents/CreateModal.vue index 13686d8ee..69d3a505b 100644 --- a/frontend/src/components/dashboard/documents/CreateModal.vue +++ b/frontend/src/components/dashboard/documents/CreateModal.vue @@ -23,6 +23,22 @@ + + + Select a template to pre-fill the document content (Type 4: Document - General) @@ -54,21 +70,34 @@ import Modal from "@/basic/Modal.vue"; export default { name: "DocumentCreateModal", components: {Modal}, + subscribeTable: ["template"], data() { return { name: "", documentType: 1, // Default for General HTML document type + templateId: 0, // 0 = no template }; }, computed: { selectedProjectId() { return this.$store.getters["settings/getValueAsInt"]("projects.default"); }, + documentTemplates() { + const currentUserId = this.$store.getters["auth/getUserId"]; + // Own templates only (Type 4 Document - General), including copies + return this.$store.getters["table/template/getAll"] + .filter(t => t.type === 4 && !t.deleted && t.userId === currentUserId) + .map(t => ({ + id: t.id, + name: t.name + })); + } }, methods: { open() { this.name = ""; this.documentType = 1; // Reset to default type + this.templateId = 0; // Reset template selection this.$refs.createModal.openModal(); }, create() { @@ -83,11 +112,14 @@ export default { this.$refs.createModal.waiting = true; - this.$socket.emit("documentCreate", { - type: this.documentType, + const createData = { + type: this.documentType, name: this.name, projectId: this.selectedProjectId, - }, (res) => { + templateId: this.templateId, + }; + + this.$socket.emit("documentCreate", createData, (res) => { if (res.success) { this.$refs.createModal.close(); this.eventBus.emit("toast", { diff --git a/frontend/src/components/dashboard/settings/SettingItem.vue b/frontend/src/components/dashboard/settings/SettingItem.vue index 4382324cd..c8c49f48f 100644 --- a/frontend/src/components/dashboard/settings/SettingItem.vue +++ b/frontend/src/components/dashboard/settings/SettingItem.vue @@ -32,6 +32,18 @@ class="form-check-input" role="switch" title="Activate/Deactivate NLP support" type="checkbox"> +
+ +

@@ -54,6 +66,7 @@ import EditorModal from "@/basic/editor/Modal.vue"; export default { name: "SettingItem", components: { LoadIcon, EditorModal }, + subscribeTable: ["template"], props: { group: Object, title: String @@ -63,9 +76,53 @@ export default { collapsed: true }; }, + computed: { + user() { + return this.$store.getters["auth/getUser"]; + }, + 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)); + + // Show only the user's own templates (includes copies since copies have userId === currentUser) + const visibleTemplates = allTemplates.filter(t => t.userId === this.user?.id); + + return visibleTemplates.map(t => ({ + id: t.id, + name: t.name, + type: t.type + })); + } + }, methods: { toggleCollapse() { this.collapsed = !this.collapsed; + }, + isEmailTemplateSetting(setting) { + return setting.key && + setting.key.startsWith("email.template.") && + (setting.type === "number" || setting.type === "integer"); + }, + getFilteredEmailTemplates(setting) { + // Determine template type based on setting key + let requiredType = null; + if (setting.key === "email.template.passwordReset" || + setting.key === "email.template.verification" || + setting.key === "email.template.registration") { + requiredType = 1; // Email - General + } else if (setting.key === "email.template.sessionStart" || + setting.key === "email.template.sessionFinish") { + requiredType = 2; // Email - Study Session + } else if (setting.key === "email.template.assignment") { + requiredType = 3; // Email - Assignment + } else if (setting.key === "email.template.studyClosed") { + requiredType = 6; // Email - Study Close + } + + // Filter by type if determined + return requiredType !== null + ? this.emailTemplates.filter(t => t.type === requiredType) + : this.emailTemplates; } } }; diff --git a/frontend/src/components/dashboard/study/BulkAssignmentModal.vue b/frontend/src/components/dashboard/study/BulkAssignmentModal.vue index 8252fe03e..4e11dbef2 100644 --- a/frontend/src/components/dashboard/study/BulkAssignmentModal.vue +++ b/frontend/src/components/dashboard/study/BulkAssignmentModal.vue @@ -37,6 +37,19 @@ :options="assignmentTypeFields" /> +
+
+ + +
+
@@ -41,6 +55,11 @@ export default { BasicModal, BasicButton, }, + data() { + return { + notifySessions: false, + }; + }, computed:{ projectId() { return this.$store.getters["settings/getValueAsInt"]("projects.default"); @@ -48,12 +67,14 @@ export default { }, methods: { open() { + this.notifySessions = false; this.$refs.bulkCloseModal.open(); }, closeAllStudies() { const data = { projectId: this.projectId, ignoreClosedState: false, + notifySessions: this.notifySessions, progressId: this.$refs.bulkCloseModal.getProgressId(), }; this.$refs.bulkCloseModal.startProgress(); @@ -81,4 +102,7 @@ export default { diff --git a/frontend/src/components/dashboard/study/SingleAssignmentModal.vue b/frontend/src/components/dashboard/study/SingleAssignmentModal.vue index 16ae991ed..93bdd2159 100644 --- a/frontend/src/components/dashboard/study/SingleAssignmentModal.vue +++ b/frontend/src/components/dashboard/study/SingleAssignmentModal.vue @@ -37,6 +37,19 @@ :options="assignmentTypeFields" /> +
+
+ + +
+