diff --git a/backend/db/models/template.js b/backend/db/models/template.js index f4c0d519e..5769baad3 100644 --- a/backend/db/models/template.js +++ b/backend/db/models/template.js @@ -3,6 +3,10 @@ const MetaModel = require("../MetaModel.js"); const TranslatableError = require("../../utils/TranslatableError"); const { assertStableEmailTemplateContent } = require("../../utils/helper/templateResolver"); +const emailTemplateTypes = Object.freeze([1, 2, 3, 6, 7]); +const otherTemplateTypes = Object.freeze([4, 5]); +const allTemplateTypes = Object.freeze([...emailTemplateTypes, ...otherTemplateTypes]); + module.exports = (sequelize, DataTypes) => { /** * Template model @@ -10,6 +14,9 @@ module.exports = (sequelize, DataTypes) => { */ class Template extends MetaModel { static autoTable = true; + static emailTemplateTypes = emailTemplateTypes; + static otherTemplateTypes = otherTemplateTypes; + static allTemplateTypes = allTemplateTypes; /** * Get the user filter for templates based on userId and admin status @@ -25,12 +32,12 @@ module.exports = (sequelize, DataTypes) => { // Admins: own templates (all types) OR public templates from others return {[Op.or]: [{userId: userId}, {public: true}]}; } else { - // Non-admins: own templates (types 4, 5 only) OR public templates from others (types 4, 5 only) - // Email templates (types 1, 2, 3, 6, 7) are admin-only + // Non-admins: own templates (otherTemplateTypes only) OR public templates from others (otherTemplateTypes only) + // Email templates (emailTemplateTypes) are admin-only return { [Op.or]: [ - {[Op.and]: [{userId: userId}, {type: {[Op.in]: [4, 5]}}]}, - {[Op.and]: [{public: true}, {type: {[Op.in]: [4, 5]}}]} + {[Op.and]: [{userId: userId}, {type: {[Op.in]: otherTemplateTypes}}]}, + {[Op.and]: [{public: true}, {type: {[Op.in]: otherTemplateTypes}}]} ] }; } @@ -52,7 +59,12 @@ module.exports = (sequelize, DataTypes) => { return baseFilter; } const copies = await Template.findAll({ - where: { userId, sourceId: { [Op.ne]: null }, deleted: false }, + where: { + userId, + sourceId: { [Op.ne]: null }, + deleted: false, + type: { [Op.in]: otherTemplateTypes }, + }, attributes: ["sourceId"], raw: true, }); @@ -68,7 +80,7 @@ module.exports = (sequelize, DataTypes) => { /** * Override getAutoTable to apply custom filtering for templates: * - All users (including admins): own templates OR public templates from others - * - Non-admins: exclude email templates (types 1, 2, 3, 6, 7) - admin-only + * - Non-admins: exclude email templates (emailTemplateTypes) - admin-only */ static async getAutoTable(filterList = [], userId = null, attributes = null) { const {Op} = require("sequelize"); @@ -477,7 +489,7 @@ module.exports = (sequelize, DataTypes) => { if ( template.public === true && template._previousDataValues?.public !== true && - [1, 2, 3, 6, 7].includes(template.type) + emailTemplateTypes.includes(template.type) ) { await assertStableEmailTemplateContent(template.id, sequelize.models, { transaction: options.transaction, @@ -485,14 +497,22 @@ module.exports = (sequelize, DataTypes) => { }); } - // appDataUpdate / updateData passes callerUserId so hooks can enforce ownership - if (options.callerUserId === undefined) { + // appDataUpdate / updateData passes the caller as context.currentUserId + const callerUserId = options.context?.currentUserId; + if (callerUserId === undefined) { return; } - if (template.userId !== options.callerUserId) { - throw new TranslatableError("errors.templates.updateOwnOnly" - ); + if (template.userId !== callerUserId) { + throw new TranslatableError("errors.templates.updateOwnOnly"); + } + + if (emailTemplateTypes.includes(template.type)) { + const roleIds = await sequelize.models.user_role_matching.getUserRolesById(callerUserId); + const isAdmin = await sequelize.models.user_role_matching.isAdminInUserRoles(roleIds); + if (!isAdmin) { + throw new TranslatableError("errors.templates.adminOnlyEmailTemplateUpdate"); + } } const prevSourceId = template._previousDataValues?.sourceId; diff --git a/backend/db/models/user_role_matching.js b/backend/db/models/user_role_matching.js index 024c0c426..e8492a793 100644 --- a/backend/db/models/user_role_matching.js +++ b/backend/db/models/user_role_matching.js @@ -2,6 +2,20 @@ const MetaModel = require("../MetaModel.js"); module.exports = (sequelize, DataTypes) => { + // SequelizeSimpleCache keeps User rows indefinitely (ttl: false). Clear after + // commit so the next findByPk sees the new rolesUpdatedAt. + const clearUserCacheAfterCommit = (options) => { + const clear = () => { + if (sequelize.models.user.cache) { + sequelize.models.user.cache.clear(); + } + }; + if (options.transaction) { + options.transaction.afterCommit(clear); + } else { + clear(); + } + }; class UserRoleMatching extends MetaModel { static autoTable = true; /** @@ -25,7 +39,7 @@ module.exports = (sequelize, DataTypes) => { */ static async getUserRolesById(userId) { const userRoles = await sequelize.models.user_role_matching.findAll({ - where: {userId: userId}, + where: {userId: userId, deleted: false}, raw: true, }); return userRoles.map((role) => role.userRoleId); @@ -117,6 +131,7 @@ module.exports = (sequelize, DataTypes) => { transaction: options.transaction, } ); + clearUserCacheAfterCommit(options); } catch (error) { console.log(error); } @@ -130,6 +145,7 @@ module.exports = (sequelize, DataTypes) => { transaction: options.transaction, } ); + clearUserCacheAfterCommit(options); } catch (error) { console.log(error); } diff --git a/backend/utils/helper/templateResolver.js b/backend/utils/helper/templateResolver.js index 8e51cdf50..686919844 100644 --- a/backend/utils/helper/templateResolver.js +++ b/backend/utils/helper/templateResolver.js @@ -402,7 +402,7 @@ async function assertStableEmailTemplateContent(templateId, models, options = {} if (!template) { throw new TranslatableError("errors.templates.notFound"); } - if (![1, 2, 3, 6, 7].includes(template.type)) { + if (!models["template"].emailTemplateTypes.includes(template.type)) { return; } diff --git a/backend/webserver/Socket.js b/backend/webserver/Socket.js index 52b53bc1c..569183f21 100644 --- a/backend/webserver/Socket.js +++ b/backend/webserver/Socket.js @@ -38,7 +38,7 @@ module.exports = class Socket { .filter((model) => model.autoTable) .map((model) => model.tableName); - // user rights in form: userId: {isAdmin: false, rights: {right1: false, ..}, roles: [role1, ..], lastRolesUpdate: Date} + // user rights in form: userId: {isAdmin: false, rights: {right1: false, ..}, roles: [role1, ..], lastRolesUpdate: Date, rolesUpdatedAtMs} this.userInfo = {}; this.transactionMonitor = new EWMAMonitor(30, this.logger); @@ -295,19 +295,45 @@ module.exports = class Socket { } + /** + * Resolve the user's rolesUpdatedAt as a millisecond timestamp. + * Prefers the DB value so mid-session role changes are visible; falls back to the hint. + * Relies on User.cache being cleared when roles change. + * @param {number} userId The user id + * @param {Date} [rolesUpdatedAt] Date of the last role update of the user + * @returns {Promise} Millisecond timestamp of user.rolesUpdatedAt, or null if unavailable + */ + async getRolesUpdatedAtMs(userId, rolesUpdatedAt = null) { + try { + const user = await this.models["user"].findByPk(userId, { + attributes: ["rolesUpdatedAt"], + raw: true, + }); + if (user?.rolesUpdatedAt) { + return new Date(user.rolesUpdatedAt).getTime(); + } + } catch (_err) { + // fall through to hint + } + if (rolesUpdatedAt) { + return new Date(rolesUpdatedAt).getTime(); + } + return null; + } + /** * Checks and caches whether the user is an admin. - * Note: This method has side effects as it caches the admin status in `this.userInfo[userId].isUserAdmin`. - * This can be problematic if the user's admin status changes - * during their session, as the cached value won't automatically update. + * Reloads roles when DB user.rolesUpdatedAt differs from the cached timestamp so + * mid-session role changes are enforced without reconnecting. * @param {number} userId The id of the user to check admin privileges for * @param {Date} rolesUpdatedAt Date of the last role update of the user * @returns {Promise} True if the user is an admin. */ async isAdmin(userId = this.userId, rolesUpdatedAt = this.rolesUpdatedAt) { - // admin has full rights, so return true directly - if (!this.userInfo[userId] || rolesUpdatedAt > this.userInfo[userId].lastRolesUpdate) { - await this.updateUserInfo(userId); + const rolesUpdatedAtMs = await this.getRolesUpdatedAtMs(userId, rolesUpdatedAt); + const cached = this.userInfo[userId]; + if (!cached || cached.rolesUpdatedAtMs !== rolesUpdatedAtMs) { + await this.updateUserInfo(userId, rolesUpdatedAtMs); } return this.userInfo[userId].isAdmin; } @@ -315,15 +341,17 @@ module.exports = class Socket { /** * Adds access information about the user userId in this.userInfo. * @param {number} userId The id of the user to update access for - * @returns {void} + * @param {number|null} [rolesUpdatedAtMs] Millisecond timestamp of user.rolesUpdatedAt when known + * @returns {Promise} */ - async updateUserInfo(userId) { + async updateUserInfo(userId, rolesUpdatedAtMs = null) { const userAccess = {}; const roleIds = await this.models["user_role_matching"].getUserRolesById(userId); userAccess.roles = roleIds; userAccess.isAdmin = await this.models["user_role_matching"].isAdminInUserRoles(roleIds); userAccess.rights = {}; userAccess.lastRolesUpdate = new Date(); + userAccess.rolesUpdatedAtMs = rolesUpdatedAtMs; this.userInfo[userId] = userAccess; } @@ -335,21 +363,18 @@ module.exports = class Socket { * @returns {Promise} True if the user has the right */ async hasAccess(right, userId = this.userId, rolesUpdatedAt = this.rolesUpdatedAt) { - // admin has full rights, so return true directly - if (!this.userInfo[userId] || rolesUpdatedAt > this.userInfo[userId].lastRolesUpdate) { - await this.updateUserInfo(userId); + // isAdmin refreshes the rights cache when rolesUpdatedAt changed + if (await this.isAdmin(userId, rolesUpdatedAt)) { + return true; } const userInfo = this.userInfo[userId]; - if (userInfo.isAdmin) { - return true; - } else if (userInfo.rights[right]) { + if (userInfo.rights[right] !== undefined) { return userInfo.rights[right]; - } else { - const hasAccess = await this.models["user_role_matching"].hasAccessByUserRoles(userInfo.roles, right); - this.userInfo[userId].rights[right] = hasAccess; - return hasAccess; } + const hasAccess = await this.models["user_role_matching"].hasAccessByUserRoles(userInfo.roles, right); + this.userInfo[userId].rights[right] = hasAccess; + return hasAccess; } /** @@ -555,28 +580,28 @@ module.exports = class Socket { let fullRowAccess = isPublicOrAdmin; if (!fullRowAccess) { - // --- Ownership: user always sees their own rows when table has userId --- - if (hasUserIdAttribute) { - rowVisibilityConditions.push({userId}); - } - - // --- Public rows: always visible regardless of ownership or access rights --- - if ('public' in model.getAttributes()) { - rowVisibilityConditions.push({public: true}); - } - - // --- User-level row filter --- + // --- User-level row filter (authoritative when present, e.g. template type rules) --- if (hasModelUserFilter) { - const userFilter = await model.getUserFilter(userId); + const userFilter = await model.getUserFilter(userId, isAdmin); if (Reflect.ownKeys(userFilter).length > 0) { rowVisibilityConditions.push(userFilter); } else { // getUserFilter returns {} → grants full row access (e.g. for admins) fullRowAccess = true; } - } else if (!hasUserIdAttribute && accessRights.length === 0) { - this.logger.warn("User with id " + userId + " requested table " + tableName + " without access rights"); - return {filter: allFilter, attributes: allAttributes, accessAllowed: false}; + } else { + // --- Ownership: user always sees their own rows when table has userId --- + if (hasUserIdAttribute) { + rowVisibilityConditions.push({userId}); + } + + // --- Public rows: always visible regardless of ownership or access rights --- + if ('public' in model.getAttributes()) { + rowVisibilityConditions.push({public: true}); + } else if (!hasUserIdAttribute && accessRights.length === 0) { + this.logger.warn("User with id " + userId + " requested table " + tableName + " without access rights"); + return {filter: allFilter, attributes: allAttributes, accessAllowed: false}; + } } // --- Access-map limitations (ORed with user filter conditions) --- diff --git a/backend/webserver/sockets/template.js b/backend/webserver/sockets/template.js index 6e52b68a9..a6e869171 100644 --- a/backend/webserver/sockets/template.js +++ b/backend/webserver/sockets/template.js @@ -39,7 +39,7 @@ class TemplateSocket extends Socket { if (!data.name || !data.description || data.type === undefined || data.content === undefined) { throw new TranslatableError("errors.templates.missingCreateFields"); } - if (!(await this.isAdmin()) && [1, 2, 3, 6, 7].includes(data.type)) { + if (!(await this.isAdmin()) && this.models["template"].emailTemplateTypes.includes(data.type)) { throw new TranslatableError("errors.templates.adminOnlyEmailTemplateCreate"); } @@ -73,6 +73,7 @@ class TemplateSocket extends Socket { * 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) + * - Non-admins are rejected for email templates (including owners) * * @socketEvent templateGetContent * @param {Object} data The data object @@ -80,7 +81,10 @@ class TemplateSocket extends Socket { * @param {string} data.language Language code (required, e.g. 'en', 'de') * @param {Object} options * @param {Object} options.transaction - * @returns {Promise} + * @returns {Promise} + * @throws {Error} if templateId or language is missing, or the template does not exist + * @throws {Error} if the caller is not the owner and the template is not public + * @throws {Error} if the caller is not an admin and the template is an email type */ async getContent(data, options){ if (!data.templateId) throw new TranslatableError("errors.templates.templateIdRequired"); @@ -93,10 +97,15 @@ class TemplateSocket extends Socket { const isOwner = template.userId === this.userId; const isPublicFromOthers = template.public === true && !isOwner; + const isAdmin = await this.isAdmin(); + const isEmailType = this.models["template"].emailTemplateTypes.includes(template.type); if (!isOwner && !isPublicFromOthers) { throw new TranslatableError("errors.templates.viewOwnOrPublicOnly"); } + if (!isAdmin && isEmailType) { + throw new TranslatableError("errors.templates.adminOnlyEmailTemplateView"); + } const langRow = await this.models["template_content"].findOne({ where: { templateId: data.templateId, language: data.language, deleted: false }, @@ -129,7 +138,7 @@ class TemplateSocket extends Socket { // when they omit required placeholders. Discard such invalid drafts and fall back // to stable content; valid drafts are still resumed. let composedIsInvalid = false; - if ([1, 2, 3, 6, 7].includes(template.type)) { + if (isEmailType) { const missing = await getMissingRequiredPlaceholders( { ops: composed.ops }, template.type, @@ -195,6 +204,10 @@ class TemplateSocket extends Socket { throw new TranslatableError("errors.templates.copiedCannotBeEdited"); } + if (this.models["template"].emailTemplateTypes.includes(template.type) && !(await this.isAdmin())) { + throw new TranslatableError("errors.templates.adminOnlyEmailTemplateEdit"); + } + const bulkEdits = data.ops.map((op, idx) => ({ userId: this.userId, templateId: data.templateId, @@ -217,7 +230,7 @@ class TemplateSocket extends Socket { * * @socketEvent templatePlaceholderAdd * @param {Object} data The data object - * @param {number} data.templateType Template type (required, 1-5) + * @param {number} data.templateType Template type (required, must be in allTemplateTypes) * @param {string} data.placeholderKey Placeholder key (required, e.g., "username") * @param {string} data.placeholderLabel i18n key for label (required, e.g. "templates.placeholders.labels.emailGeneral.username") * @param {string} data.placeholderType Placeholder type (required, e.g., "text") @@ -228,7 +241,7 @@ class TemplateSocket extends Socket { */ async addPlaceholder(data, options) { if (!(await this.isAdmin())) throw new TranslatableError("errors.templates.accessDenied"); - if (!data.templateType || ![1, 2, 3, 4, 5, 6, 7].includes(data.templateType)) { + if (!data.templateType || !this.models["template"].allTemplateTypes.includes(data.templateType)) { throw new TranslatableError("errors.templates.typeRequired"); } if (!data.placeholderKey || !data.placeholderLabel || !data.placeholderType) { @@ -308,6 +321,9 @@ class TemplateSocket extends Socket { if (!isOwner && !isPublicFromOthers) { throw new TranslatableError("errors.templates.viewPlaceholdersOwnOrPublicOnly"); } + if (this.models["template"].emailTemplateTypes.includes(template.type) && !(await this.isAdmin())) { + throw new TranslatableError("errors.templates.adminOnlyEmailTemplateView"); + } return await this.models["placeholder"].getAllByKey( "type", @@ -339,6 +355,9 @@ class TemplateSocket extends Socket { if (!isOwner && !isPublicFromOthers) { throw new TranslatableError("errors.templates.viewOwnOrPublicOnly"); } + if (this.models["template"].emailTemplateTypes.includes(template.type) && !(await this.isAdmin())) { + throw new TranslatableError("errors.templates.adminOnlyEmailTemplateView"); + } const rows = await this.models["template_content"].findAll({ where: { templateId: data.templateId, deleted: false }, @@ -378,6 +397,9 @@ class TemplateSocket extends Socket { if (template.userId !== this.userId) { throw new TranslatableError("errors.templates.addLanguageOwnOnly"); } + if (this.models["template"].emailTemplateTypes.includes(template.type) && !(await this.isAdmin())) { + throw new TranslatableError("errors.templates.adminOnlyEmailTemplateEdit"); + } const templateContentModel = this.models["template_content"]; const existing = await templateContentModel.findOne({ @@ -488,7 +510,7 @@ class TemplateSocket extends Socket { }); if (edits.length === 0) { - if ([1, 2, 3, 6, 7].includes(template.type)) { + if (this.models["template"].emailTemplateTypes.includes(template.type)) { const templateContentModel = this.models["template_content"]; const langRow = await templateContentModel.findOne({ where: { templateId, language, deleted: false }, @@ -527,8 +549,8 @@ class TemplateSocket extends Socket { const editsDelta = new Delta(dbToDelta(edits)); const mergedDelta = baseContent.compose(editsDelta); - // Email templates (types 1, 2, 3, 6, 7) must include all required placeholders - if ([1, 2, 3, 6, 7].includes(template.type)) { + // Email templates must include all required placeholders + if (this.models["template"].emailTemplateTypes.includes(template.type)) { const missing = await getMissingRequiredPlaceholders( { ops: mergedDelta.ops }, template.type, @@ -592,6 +614,10 @@ class TemplateSocket extends Socket { const template = await this.models["template"].getById(data.templateId); if (!template) return; + if (this.models["template"].emailTemplateTypes.includes(template.type) && !(await this.isAdmin())) { + throw new TranslatableError("errors.templates.adminOnlyEmailTemplateEdit"); + } + if (template.userId === this.userId) { await this.saveTemplate(data.templateId, data.language, options); } @@ -674,7 +700,7 @@ class TemplateSocket extends Socket { if (!data.sourceTemplateId) throw new TranslatableError("errors.templates.sourceTemplateIdRequired"); const source = await this.models["template"].getById(data.sourceTemplateId); - if (!(await this.isAdmin()) && [1, 2, 3, 6, 7].includes(source?.type)) { + if (!(await this.isAdmin()) && this.models["template"].emailTemplateTypes.includes(source?.type)) { throw new TranslatableError("errors.templates.adminOnlyEmailTemplateCopy"); } @@ -714,6 +740,9 @@ class TemplateSocket extends Socket { const copy = await this.models["template"].getById(data.templateId); if (!copy) throw new TranslatableError("errors.templates.notFound"); if (copy.userId !== this.userId) throw new TranslatableError("errors.templates.updateOwnCopiesOnly"); + if (this.models["template"].emailTemplateTypes.includes(copy.type) && !(await this.isAdmin())) { + throw new TranslatableError("errors.templates.adminOnlyEmailTemplateUpdateFromSource"); + } return await this.models["template"].updateFromSource( data.templateId, @@ -743,11 +772,11 @@ class TemplateSocket extends Socket { throw new TranslatableError("errors.templates.deleteOwnOnly"); } - if (template.public && [1, 2, 3, 6, 7].includes(template.type)) { + if (template.public && this.models["template"].emailTemplateTypes.includes(template.type)) { throw new TranslatableError("errors.templates.publicEmailCannotDelete"); } - if ([1, 2, 3, 6, 7].includes(template.type)) { + if (this.models["template"].emailTemplateTypes.includes(template.type)) { const usedBySettings = await this.models["setting"].findAll({ where: { key: {[Op.like]: "email.template.%"}, diff --git a/docs/source/for_developers/frontend/components/templates.rst b/docs/source/for_developers/frontend/components/templates.rst index 4bc18bda1..733a0b3ee 100644 --- a/docs/source/for_developers/frontend/components/templates.rst +++ b/docs/source/for_developers/frontend/components/templates.rst @@ -1,12 +1,12 @@ 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. +The **Templates** system provides email and document content templates that can be used for system emails, session and assignment notifications, study-closed emails, submission-upload emails, and pre-filled document content. +Templates are edited in the same Quill-based Editor as documents. The backend fills placeholders when the template is used. Key features include: - - **Template types** with fixed placeholder sets and usage locations (see table below). + - **Template types** with per-type placeholder sets and usage locations (see table below). - **Multi-language content** stored in ``template_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 `). @@ -18,7 +18,7 @@ Templates are listed and created from **Dashboard → Templates**. See the :doc: 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. +When you open a template for editing, the Editor loads with ``templateId`` provided. It renders the :doc:`editor` (TemplateEditor) for the main content. For email types (1, 2, 3, 6, 7) it also shows a **Placeholders** sidebar so you can insert allowed placeholders (e.g. ``~username~``, ``~link~``). Location: ``frontend/src/components/editor/sidebar/TemplateConfigurator.vue`` @@ -29,10 +29,10 @@ Backend storage: - **template_edit** — draft edits per template and language. - **placeholder** — placeholder keys and labels per template type (used by the frontend sidebar; resolution rules live in the resolver). -Location: ``backend/utils/templateResolver.js`` +Location: ``backend/utils/helper/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. +Placeholder resolution is implemented there: ``resolveTemplate`` (returns HTML for emails) and ``resolveTemplateToDelta`` (returns Delta for document creation). +Only keys stored in the ``placeholder`` table for that type are substituted at runtime. Implementing the Template Editor --------------------------------- @@ -63,7 +63,7 @@ Templates use the same debounced autosave as documents (see :ref:`Debounce Behav **Save-on-leave:** In-app navigation (topbar back, dashboard links) runs ``beforeRouteLeave`` in ``frontend/src/components/Template.vue``. The guard calls ``flushPendingEdits`` on ``TemplateEditor`` (cancels debounce and sends buffered ops), then emits ``templateClose``, which calls ``saveTemplate`` in ``backend/webserver/sockets/template.js`` to merge drafts into ``template_content``. -**Required placeholders:** For email types (1, 2, 3, 6, 7), stable ``template_content`` in every language must include all required placeholders (``getMissingRequiredPlaceholders`` in ``backend/utils/templateResolver.js``). Enforcement points: +**Required placeholders:** For email types (1, 2, 3, 6, 7), stable ``template_content`` in every language must include all required placeholders (``getMissingRequiredPlaceholders`` in ``backend/utils/helper/templateResolver.js``). Enforcement points: - **Editor save:** ``saveTemplate`` rejects merges that omit required placeholders. The user may confirm discard; ``templateDiscardDrafts`` soft-deletes draft rows without updating ``template_content``. - **Publish:** The ``template`` model ``beforeUpdate`` hook calls ``assertStableEmailTemplateContent`` when ``public`` becomes ``true``. @@ -76,31 +76,49 @@ Template Types, Placeholders, and Usage At resolution time, only the placeholder keys listed in the following table are substituted. -+--------------------------+--------+--------------------------------------+------------------------------------------------------------+ -| Template type | Value | Placeholders | Where used | -+==========================+========+======================================+============================================================+ -| Email - General | 1 | ``username``, ``firstName``, | Auth/system emails: settings | -| | | ``lastName``, ``link``\* | ``email.template.passwordReset``, | -| | | | ``email.template.verification``, | -| | | | ``email.template.registration`` in ``auth.js``. | -+--------------------------+--------+--------------------------------------+------------------------------------------------------------+ -| Email - Study Session | 2 | ``username``, ``link``\* | Session start/finish emails: settings | -| | | | ``email.template.sessionStart``, | -| | | | ``email.template.sessionFinish`` in ``study_session.js``. | -+--------------------------+--------+--------------------------------------+------------------------------------------------------------+ -| Email - Assignment | 3 | ``username``, ``assignmentType``, | Assignment emails: setting ``email.template.assignment`` | -| | | ``assignmentName``, ``link``\* | in ``assignment.js``. | -+--------------------------+--------+--------------------------------------+------------------------------------------------------------+ -| Document - General | 4 | none | Pre-fill document content when creating a document with | -| | | | ``templateId`` in ``document.js``. | -+--------------------------+--------+--------------------------------------+------------------------------------------------------------+ -| Document - Study | 5 | none | Document templates for study steps (create from template) | -| | | | in ``study_step.js``. | -+--------------------------+--------+--------------------------------------+------------------------------------------------------------+ -| Email - Study Close | 6 | ``username``, ``studyName``\* | Study-closed emails: setting | -| | | | ``email.template.studyClosed`` (``sendStudyClosedEmails``) | -| | | | in ``study.js``. | -+--------------------------+--------+--------------------------------------+------------------------------------------------------------+ +.. list-table:: + :header-rows: 1 + :widths: 20 8 30 42 + + * - Template type + - Value + - Placeholders + - Where used + * - Email - General + - 1 + - ``username``, ``firstName``, ``lastName``, ``link``\* + - Auth/system emails: settings ``email.template.passwordReset``, + ``email.template.verification``, ``email.template.registration``, + ``email.template.twoFactorOtp``, ``email.template.passwordResetSuccess``. + * - Email - Study Session + - 2 + - ``username``, ``link``\* + - Session start/finish emails: settings ``email.template.sessionStart``, + ``email.template.sessionFinish`` in ``study_session.js``. + * - Email - Assignment + - 3 + - ``username``, ``assignmentType``, ``assignmentName``, ``link``\* + - Assignment emails: setting ``email.template.assignment`` in ``assignment.js``. + * - Document - General + - 4 + - none + - Pre-fill document content when creating a document with ``templateId`` in ``document.js``. + * - Document - Study + - 5 + - none + - Study workflow editor steps: type 5 templates in + ``frontend/src/basic/form/Select.vue`` (document dropdown when ``stepType`` is 2). + * - Email - Study Close + - 6 + - ``username``, ``studyName``\* + - Study-closed emails: setting ``email.template.studyClosed`` + (``sendStudyClosedEmails``) in ``study.js``. + * - Email - Submission upload + - 7 + - ``username``, ``assignmentName``\*, ``eventType``, ``assignmentId``, + ``submissionId``, ``timestamp`` + - Submission upload emails: settings ``email.template.submissionUpload``, + ``email.template.submissionUploadConfirmation`` in ``document.js``. Adding a New Template Type or Placeholder ----------------------------------------- @@ -109,47 +127,33 @@ Adding a New Template Type or Placeholder Placeholders marked with ``*`` in the table above are **required** for that type. If a required placeholder is missing from stable content in any language, validation - fails on editor save, publish, or Settings assignment until it is added. The set of - required placeholders is defined in the ``placeholder`` table (``required: true``) and - enforced via ``getMissingRequiredPlaceholders`` and ``assertStableEmailTemplateContent`` - in ``backend/utils/templateResolver.js``. + fails on editor save, publish, or Settings assignment until it is added. Required + keys are rows in the ``placeholder`` table with ``required: true``. -Here is a concrete example for adding a new placeholder (e.g. ``studyEndDate`` for type 6): +Adding a placeholder +~~~~~~~~~~~~~~~~~~~~ -1. **Backend (DB + resolver):** +For an existing type (example: ``studyEndDate`` on type 6): - - Add a row to the ``placeholder`` table via a migration: +- Add a ``placeholder`` row in a migration (``type``, ``placeholderKey``, label, required, etc.). +- Fill ``~studyEndDate~`` in ``buildReplacementMap`` in + ``backend/utils/helper/templateResolver.js``. The call site (e.g. + ``sendStudyClosedEmails`` in ``study.js``) must pass the value in the resolver context. +- Optional sidebar help: ``longDescriptions`` in + ``frontend/src/components/editor/sidebar/TemplateConfigurator.vue``. - - ``type``: ``6`` (Email - Study Close) - - ``placeholderKey``: ``studyEndDate`` - - other metadata as needed (label, required, etc.) +The Placeholders sidebar loads keys from the ``placeholder`` table. - - Update ``PLACEHOLDERS_BY_TYPE`` / ``buildReplacementMap`` in - ``backend/utils/templateResolver.js`` to fill ``studyEndDate`` from context, for example: +Adding a template type +~~~~~~~~~~~~~~~~~~~~~~ - - In the resolver, when ``context.templateType === 6``, set - ``replacements["~studyEndDate~"] = ``. - - - If the new placeholder is driven by a specific feature (e.g. study close emails), - ensure the call site (e.g. ``sendStudyClosedEmails`` in ``study.js``) passes - whatever additional data is needed into the resolver context. - -2. **Frontend (editor + sidebar):** - - - Add the placeholder to the template configurator configuration so it appears in the - Placeholders sidebar with a name/description (e.g. update - ``placeholderConfigs`` / ``longDescriptions`` in - ``frontend/src/components/editor/sidebar/TemplateConfigurator.vue``). - - - -3. **Access / type visibility:** - - - If the new placeholder is tied to a new template type, also update: - - - The template type dropdown in ``frontend/src/components/dashboard/templates/TemplateModal.vue``. - - ``getUserFilter`` in ``backend/db/models/template.js`` so that only the correct - users (e.g. admins) see or can use that type. +- Add the option to ``fields`` on ``backend/db/models/template.js``. +- Put the type in ``emailTemplateTypes`` or ``otherTemplateTypes`` in + ``backend/db/models/template.js`` and ``frontend/src/assets/templateTypes.js``. +- Add an empty ``placeholderConfigs`` entry in ``TemplateConfigurator.vue`` + so the sidebar can load keys for that type. +- Add the label in the ``typeName`` maps in ``Templates.vue``, + ``PublicTemplatesModal.vue``, and ``TemplateConfigurator.vue``. Settings -------- diff --git a/frontend/src/assets/templateTypes.js b/frontend/src/assets/templateTypes.js new file mode 100644 index 000000000..097f7f9c4 --- /dev/null +++ b/frontend/src/assets/templateTypes.js @@ -0,0 +1,4 @@ +// Keep in sync with the lists on the template model. +export const emailTemplateTypes = Object.freeze([1, 2, 3, 6, 7]); +export const otherTemplateTypes = Object.freeze([4, 5]); +export const allTemplateTypes = Object.freeze([...emailTemplateTypes, ...otherTemplateTypes]); diff --git a/frontend/src/components/dashboard/Templates.vue b/frontend/src/components/dashboard/Templates.vue index 653787250..5b96e3450 100644 --- a/frontend/src/components/dashboard/Templates.vue +++ b/frontend/src/components/dashboard/Templates.vue @@ -63,6 +63,7 @@ import { resolveApiMessage } from "@/assets/utils"; import ExportFormatModal from "@/basic/modal/ExportFormatModal.vue"; import ImportFormatModal from "@/basic/modal/ImportFormatModal.vue"; + import { emailTemplateTypes } from "@/assets/templateTypes"; /** * Templates dashboard component * @@ -122,7 +123,7 @@ ...t, typeName: this.typeName(t.type), // Public email templates (types 1, 2, 3, 6, 7) cannot be deleted - canDelete: !(t.public && [1, 2, 3, 6, 7].includes(t.type)), + canDelete: !(t.public && emailTemplateTypes.includes(t.type)), isCopy, hasUpdate, sourceStatus, diff --git a/frontend/src/components/dashboard/settings/SettingItem.vue b/frontend/src/components/dashboard/settings/SettingItem.vue index 5882edba5..3629557af 100644 --- a/frontend/src/components/dashboard/settings/SettingItem.vue +++ b/frontend/src/components/dashboard/settings/SettingItem.vue @@ -125,6 +125,7 @@ import BasicButton from "@/basic/Button.vue"; import EditorModal from "@/basic/editor/Modal.vue"; import FormHelp from "@/basic/form/Help.vue"; import LogoSvg, { DEFAULT_RE_BG } from "@/basic/icon/LogoSvg.vue"; +import { emailTemplateTypes } from "@/assets/templateTypes"; import { DEFAULT_LOCALE, LOCALE_SETTING_KEY, SUPPORTED_LOCALES } from "@/assets/locale.js"; /** @@ -148,7 +149,7 @@ export default { emailTemplates() { // Show only the user's own templates (copies count, since copies have userId === currentUser). return this.$store.getters["table/template/getAll"] - .filter(t => !t.deleted && [1, 2, 3, 6, 7].includes(t.type) && t.userId === this.user?.id) + .filter(t => !t.deleted && emailTemplateTypes.includes(t.type) && t.userId === this.user?.id) .map(t => ({ id: t.id, name: t.name, type: t.type })); }, isEmailTemplateSetting() { diff --git a/frontend/src/components/dashboard/templates/PublicTemplatesModal.vue b/frontend/src/components/dashboard/templates/PublicTemplatesModal.vue index 9f8adb6a0..ed6e8c2a3 100644 --- a/frontend/src/components/dashboard/templates/PublicTemplatesModal.vue +++ b/frontend/src/components/dashboard/templates/PublicTemplatesModal.vue @@ -29,6 +29,7 @@ import Modal from "@/basic/Modal.vue"; import BasicTable from "@/basic/Table.vue"; import BasicButton from "@/basic/Button.vue"; +import { otherTemplateTypes } from "@/assets/templateTypes"; import { resolveApiMessage } from "@/assets/utils"; /** @@ -73,8 +74,10 @@ export default { .filter(t => t.userId === this.userId && !t.deleted); }, publicTemplates() { + const isAdmin = this.$store.getters["auth/isAdmin"]; return this.$store.getters["table/template/getAll"] .filter(t => t.public && t.userId !== this.userId && !t.deleted) + .filter(t => isAdmin || otherTemplateTypes.includes(t.type)) .map(t => { const alreadyCopied = this.ownTemplates.some( own => own.sourceId === t.id diff --git a/frontend/src/components/dashboard/templates/PublishModal.vue b/frontend/src/components/dashboard/templates/PublishModal.vue index d4588a114..f929394f9 100644 --- a/frontend/src/components/dashboard/templates/PublishModal.vue +++ b/frontend/src/components/dashboard/templates/PublishModal.vue @@ -55,6 +55,7 @@