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
@@ -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"
/>
+
+
+
+
+
+
@@ -449,7 +462,10 @@ export default {
value: true
}]
},
- "submission"
+ "submission",
+ {
+ table: "template",
+ }
],
components: {StepperModal, BasicTable, BasicForm, FormSelect},
data() {
@@ -465,6 +481,7 @@ export default {
workflowMapping: {},
filterHasDocuments: false,
filterSelectedDocuments: false,
+ enableEmailNotification: false,
newStudyOwner: 'session_owner',
documentTableOptions: {
striped: true,
@@ -889,6 +906,22 @@ export default {
]
};
},
+ emailTemplates() {
+ const currentUserId = this.$store.getters["auth/getUserId"];
+ return this.$store.getters["table/template/getAll"]
+ .filter(t => t.type === 3 && !t.deleted && t.userId === currentUserId);
+ },
+ emailTemplateOptions() {
+ return {
+ options: [
+ {value: null, name: 'None (no email will be sent)'},
+ ...this.emailTemplates.map(t => ({
+ value: t.id,
+ name: t.name
+ }))
+ ]
+ };
+ },
reviewerSelectionModeFields() {
const baseOptions = [
{
@@ -1082,6 +1115,7 @@ export default {
this.selectedReviewer = [];
this.selectedAssignments = [];
this.assignmentTypeSelection = {};
+ this.emailTemplateSelection = null;
this.targetWorkflowId = null;
this.workflowMapping = {};
},
@@ -1097,6 +1131,7 @@ export default {
mode: this.reviewerSelectionMode.mode,
roles: this.roles,
assignmentType: this.assignmentType,
+ enableEmailNotification: this.enableEmailNotification,
};
// Add workflowMapping for study_session, documents for others
diff --git a/frontend/src/components/dashboard/study/BulkCloseModal.vue b/frontend/src/components/dashboard/study/BulkCloseModal.vue
index ee9fab9c8..83e30b233 100644
--- a/frontend/src/components/dashboard/study/BulkCloseModal.vue
+++ b/frontend/src/components/dashboard/study/BulkCloseModal.vue
@@ -11,6 +11,20 @@
Are you sure you want to close all open studies?
+
+
+
+
@@ -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"
/>
+
+
+
+
+
+
@@ -222,6 +235,9 @@ export default {
}]
},
"submission",
+ {
+ table: "template",
+ },
{
table: "configuration",
filter: [{key: "type", value: 1}]
@@ -233,6 +249,7 @@ export default {
assignmentTypeSelection: {},
selectedAssignment: [],
selectedReviewer: [],
+ enableEmailNotification: false,
studySessionSelections: [[]],
targetWorkflowId: null,
workflowMapping: {},
@@ -323,6 +340,22 @@ export default {
]
};
},
+ emailTemplates() {
+ const currentUserId = this.$store.getters["auth/getUserId"];
+ return this.$store.getters["table/template/getAll"]
+ .filter(t => t.type === 3 && !t.deleted && t.userId === currentUserId);
+ },
+ emailTemplateOptions() {
+ return {
+ options: [
+ {value: null, name: 'None (no email will be sent)'},
+ ...this.emailTemplates.map(t => ({
+ value: t.id,
+ name: t.name
+ }))
+ ]
+ };
+ },
templates() {
return this.$store.getters["table/study/getFiltered"](item => item.template === true);
},
@@ -783,6 +816,7 @@ export default {
this.baseFileSelections = {};
this.inputGroupValid = false;
this.validationConfigurationNames = {};
+ this.enableEmailNotification = false;
},
createAssignment() {
this.$refs.assignmentStepper.setWaiting(true);
@@ -791,6 +825,9 @@ export default {
template: this.template,
assignmentType: this.assignmentType,
reviewer: this.selectedReviewer,
+ assignment: this.selectedAssignment[0],
+ documents: this.workflowStepsAssignments,
+ enableEmailNotification: this.enableEmailNotification,
selectedAssignments: this.selectedAssignment,
};
diff --git a/frontend/src/components/dashboard/study/StudyCloseModal.vue b/frontend/src/components/dashboard/study/StudyCloseModal.vue
new file mode 100644
index 000000000..e92107d1b
--- /dev/null
+++ b/frontend/src/components/dashboard/study/StudyCloseModal.vue
@@ -0,0 +1,115 @@
+
+
+
+ Close Study
+
+
+
+
+ Are you sure you want to close the study
+ "{{ studyName }}"
+ ?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/dashboard/templates/PublicTemplatesModal.vue b/frontend/src/components/dashboard/templates/PublicTemplatesModal.vue
new file mode 100644
index 000000000..d7e912cd4
--- /dev/null
+++ b/frontend/src/components/dashboard/templates/PublicTemplatesModal.vue
@@ -0,0 +1,182 @@
+
+
+
+ Public Templates
+
+
+
+ Browse published templates from other users. Copy a template to add it to your own list.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/dashboard/templates/PublishModal.vue b/frontend/src/components/dashboard/templates/PublishModal.vue
new file mode 100644
index 000000000..fecce19b6
--- /dev/null
+++ b/frontend/src/components/dashboard/templates/PublishModal.vue
@@ -0,0 +1,148 @@
+
+
+
+ Publish Template
+
+
+
+
+ Template successfully published!
+ The template is now {{ visibilityMessage }}.
+
+
+
+
+ Email templates are only {{ visibilityMessage }} after publishing.
+
+ Do you really want to publish the template?
+
This can not be undone!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/dashboard/templates/TemplateDetachModal.vue b/frontend/src/components/dashboard/templates/TemplateDetachModal.vue
new file mode 100644
index 000000000..f38c3c2cf
--- /dev/null
+++ b/frontend/src/components/dashboard/templates/TemplateDetachModal.vue
@@ -0,0 +1,72 @@
+
+
+
+ Edit Copy
+
+
+
+ Editing this template will detach it from the source. You will no longer receive updates from the original template. Continue?
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/dashboard/templates/TemplateModal.vue b/frontend/src/components/dashboard/templates/TemplateModal.vue
new file mode 100644
index 000000000..abfd5a794
--- /dev/null
+++ b/frontend/src/components/dashboard/templates/TemplateModal.vue
@@ -0,0 +1,152 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/dashboard/templates/TemplateUpdateModal.vue b/frontend/src/components/dashboard/templates/TemplateUpdateModal.vue
new file mode 100644
index 000000000..f33c4b389
--- /dev/null
+++ b/frontend/src/components/dashboard/templates/TemplateUpdateModal.vue
@@ -0,0 +1,110 @@
+
+
+
+ Source template has been updated
+
+
+
+ Replace your copy with the latest content, or create a new copy and detach the current one.
+
+
+ Make new copy: your current copy will be detached and will no longer receive updates.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/editor/Editor.vue b/frontend/src/components/editor/Editor.vue
index 47ae7fc76..c806dd538 100644
--- a/frontend/src/components/editor/Editor.vue
+++ b/frontend/src/components/editor/Editor.vue
@@ -1,8 +1,24 @@
+
+
+
+ Read-only
+
+
+
+
-
+
+
+
+
+
+
+
+
+
@@ -55,10 +78,13 @@ import SidebarConfigurator from "@/components/editor/sidebar/Configurator.vue";
import LoadIcon from "@/basic/Icon.vue";
import {computed} from "vue";
import SidebarTemplate from "@/basic/sidebar/SidebarTemplate.vue";
+import TemplateEditor from "@/components/editor/template/TemplateEditor.vue";
+import TemplateConfigurator from "@/components/editor/sidebar/TemplateConfigurator.vue";
import ReadOnlyIndicator from "@/components/common/ReadOnlyIndicator.vue";
export default {
name: "EditorView",
+ subscribeTable: ["template"],
components: {
SidebarTemplate,
SidebarConfigurator,
@@ -66,6 +92,8 @@ export default {
LoadIcon,
BasicSidebar,
Editor,
+ TemplateEditor,
+ TemplateConfigurator,
ReadOnlyIndicator,
},
provide() {
@@ -73,6 +101,7 @@ export default {
documentId: computed(() => this.documentId),
studyStepId: computed(() => this.studyStepId),
readOnly: computed(() => this.readOnlyOverwrite),
+ templateId: computed(() => this.templateId),
}
},
inject: {
@@ -95,7 +124,12 @@ export default {
props: {
documentId: {
type: Number,
- required: true,
+ required: false,
+ default: 0,
+ },
+ templateId: {
+ type: Number,
+ required: false,
default: 0,
},
sidebarDisabled: {
@@ -136,9 +170,18 @@ export default {
if (this.document && this.document.type === 2) {
return 'configurator';
}
+ // Only show template configurator if template is loaded, not read-only, and has placeholders
+ // Document templates (types 4, 5) have no placeholders, so no sidebar needed
+ if (this.templateId && this.template && !this.readOnlyOverwrite && this.hasPlaceholders) {
+ return 'templateConfigurator';
+ }
return null;
},
sidebarButtons() {
+ // Don't show download button for templates
+ if (this.templateId) {
+ return [];
+ }
return [
{
id: 'download-html',
@@ -153,29 +196,68 @@ export default {
showHTMLDownloadButton() {
return this.$store.getters["settings/getValue"]("editor.toolbar.showHTMLDownload") === "true";
},
+ template() {
+ if (this.templateId && this.templateId > 0) {
+ return this.$store.getters["table/template/get"](Number(this.templateId));
+ }
+ return null;
+ },
+ hasPlaceholders() {
+ // Only email templates (types 1, 2, 3, 6) have placeholders
+ // Document templates (types 4, 5) have no placeholders
+ if (!this.template) return false;
+ return [1, 2, 3, 6].includes(this.template.type);
+ },
readOnlyOverwrite() {
if (this.sidebarContent === 'history' ) {
return this.isSidebarVisible;
}
+ if (this.templateId) {
+ // If template is not loaded yet, default to read-only (safer)
+ if (!this.template) {
+ return true;
+ }
+ // Copies (sourceId set) are always read-only
+ if (this.template.sourceId) {
+ return true;
+ }
+ const currentUserId = this.$store.getters["auth/getUser"]?.id;
+ const isOwner = this.template.userId === currentUserId;
+ const isPublishedFromOthers = this.template.published === true && !isOwner;
+ if (isPublishedFromOthers) {
+ return true;
+ }
+ }
return this.readOnly;
},
showHistory() {
- if (this.readOnly) {
+ if (this.readOnly || this.templateId) {
return false;
}
const showHistoryForUser = this.$store.getters["settings/getValue"]('editor.edits.showHistoryForUser') === "true";
return this.isAdmin || showHistoryForUser;
},
document() {
- return this.$store.getters["table/document/get"](this.documentId);
+ if (this.documentId && this.documentId > 0) {
+ return this.$store.getters["table/document/get"](this.documentId);
+ }
+ return null;
},
},
methods: {
addText(text) {
- this.$refs.editor.addText(text);
+ if (this.templateId) {
+ this.$refs.templateEditor?.addText(text);
+ } else {
+ this.$refs.editor?.addText(text);
+ }
},
isEditorEmpty() {
- return this.$refs.editor.isEditorEmpty();
+ if (this.templateId) {
+ return this.$refs.templateEditor?.isEditorEmpty() || false;
+ } else {
+ return this.$refs.editor?.isEditorEmpty() || false;
+ }
},
handleSidebarChange(view) {
// Update internal state to match sidebar selection
diff --git a/frontend/src/components/editor/sidebar/Configurator.vue b/frontend/src/components/editor/sidebar/Configurator.vue
index a03847d04..48af74281 100644
--- a/frontend/src/components/editor/sidebar/Configurator.vue
+++ b/frontend/src/components/editor/sidebar/Configurator.vue
@@ -15,9 +15,14 @@
-
-
{{ placeholder.label }}
-
{{ placeholder.description }}
+
+
+
{{ placeholder.label }}
+
+
+
{{ placeholder.description }}
@@ -37,8 +42,13 @@
+
+
\ No newline at end of file
diff --git a/frontend/src/components/editor/template/TemplateEditor.vue b/frontend/src/components/editor/template/TemplateEditor.vue
new file mode 100644
index 000000000..c292e065e
--- /dev/null
+++ b/frontend/src/components/editor/template/TemplateEditor.vue
@@ -0,0 +1,728 @@
+
+
+
+
+
+
+ New language
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/router.js b/frontend/src/router.js
index f8f090162..d4a0627a7 100644
--- a/frontend/src/router.js
+++ b/frontend/src/router.js
@@ -51,6 +51,12 @@ const routes = [
props: true,
meta: {requireAuth: true}
},
+ {
+ path: "/template/:templateId",
+ component: () => import('@/components/Template.vue'),
+ props: true,
+ meta: {requireAuth: true}
+ },
{
path: "/review/:studySessionHash", // Review link
component: () => import('@/components/StudySession.vue'),