From 23a1cb7feefc010e7d3881d6f05e52e660cf8bfb Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Tue, 15 Jul 2025 16:08:31 +0200 Subject: [PATCH 01/62] feat: add backend implementation for configurable email templates --- .../0813_CreateEmailTemplatesTable.sql | 22 +++ apps/backend/src/buildContext.ts | 4 + apps/backend/src/config/Tokens.ts | 1 + apps/backend/src/context/index.ts | 4 + .../datasources/EmailTemplateDataSource.ts | 27 ++++ .../mockups/EmailTemplateDataSource.ts | 48 +++++++ .../postgres/EmailTemplateDataSource.ts | 127 ++++++++++++++++++ .../src/datasources/postgres/records.ts | 23 ++++ apps/backend/src/models/EmailTemplate.ts | 10 ++ .../src/mutations/EmailTemplateMutations.ts | 102 ++++++++++++++ .../src/queries/EmailTemplateQueries.ts | 44 ++++++ .../mutations/CreateEmailTemplateMutation.ts | 45 +++++++ .../mutations/DeleteEmailTemplateMutation.ts | 17 +++ .../mutations/UpdateEmailTemplateMutation.ts | 45 +++++++ .../resolvers/queries/EmailTemplateQuery.ts | 15 +++ .../resolvers/queries/EmailTemplatesQuery.ts | 49 +++++++ .../src/resolvers/types/EmailTemplate.ts | 24 ++++ .../emailTemplate/createEmailTemplate.graphql | 19 +++ .../emailTemplate/deleteEmailTemplate.graphql | 5 + .../fragment.emailTemplate.graphql | 8 ++ .../emailTemplate/getEmailTemplate.graphql | 5 + .../emailTemplate/getEmailTemplates.graphql | 8 ++ .../emailTemplate/updateEmailTemplate.graphql | 19 +++ 23 files changed, 671 insertions(+) create mode 100644 apps/backend/db_patches/0813_CreateEmailTemplatesTable.sql create mode 100644 apps/backend/src/datasources/EmailTemplateDataSource.ts create mode 100644 apps/backend/src/datasources/mockups/EmailTemplateDataSource.ts create mode 100644 apps/backend/src/datasources/postgres/EmailTemplateDataSource.ts create mode 100644 apps/backend/src/models/EmailTemplate.ts create mode 100644 apps/backend/src/mutations/EmailTemplateMutations.ts create mode 100644 apps/backend/src/queries/EmailTemplateQueries.ts create mode 100644 apps/backend/src/resolvers/mutations/CreateEmailTemplateMutation.ts create mode 100644 apps/backend/src/resolvers/mutations/DeleteEmailTemplateMutation.ts create mode 100644 apps/backend/src/resolvers/mutations/UpdateEmailTemplateMutation.ts create mode 100644 apps/backend/src/resolvers/queries/EmailTemplateQuery.ts create mode 100644 apps/backend/src/resolvers/queries/EmailTemplatesQuery.ts create mode 100644 apps/backend/src/resolvers/types/EmailTemplate.ts create mode 100644 apps/frontend/src/graphql/emailTemplate/createEmailTemplate.graphql create mode 100644 apps/frontend/src/graphql/emailTemplate/deleteEmailTemplate.graphql create mode 100644 apps/frontend/src/graphql/emailTemplate/fragment.emailTemplate.graphql create mode 100644 apps/frontend/src/graphql/emailTemplate/getEmailTemplate.graphql create mode 100644 apps/frontend/src/graphql/emailTemplate/getEmailTemplates.graphql create mode 100644 apps/frontend/src/graphql/emailTemplate/updateEmailTemplate.graphql diff --git a/apps/backend/db_patches/0813_CreateEmailTemplatesTable.sql b/apps/backend/db_patches/0813_CreateEmailTemplatesTable.sql new file mode 100644 index 0000000000..e123e1268d --- /dev/null +++ b/apps/backend/db_patches/0813_CreateEmailTemplatesTable.sql @@ -0,0 +1,22 @@ +DO +$$ +BEGIN + IF register_patch('CreateEmailTemplatesTable.sql', 'Gergely Nyiri', 'Create emails table', '2025-07-15') THEN + BEGIN + + CREATE TABLE IF NOT EXISTS email_templates ( + email_template_id serial PRIMARY KEY, + created_by INT NOT NULL REFERENCES users(user_id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + name varchar(100) NOT NULL UNIQUE, + description varchar(255) NOT NULL, + subject varchar(160) NOT NULL, + body text + ); + + + END; + END IF; +END; +$$ +LANGUAGE plpgsql; \ No newline at end of file diff --git a/apps/backend/src/buildContext.ts b/apps/backend/src/buildContext.ts index 1e00b1e0f7..136f32e01e 100644 --- a/apps/backend/src/buildContext.ts +++ b/apps/backend/src/buildContext.ts @@ -6,6 +6,7 @@ import UsersLoader from './loaders/UsersLoader'; import PDFServices from './middlewares/factory/factoryServices'; import AdminMutations from './mutations/AdminMutations'; import CallMutations from './mutations/CallMutations'; +import EmailTemplateMutations from './mutations/EmailTemplateMutations'; import ExperimentMutations from './mutations/ExperimentMutation'; import FapMutations from './mutations/FapMutations'; import FeedbackMutations from './mutations/FeedbackMutations'; @@ -32,6 +33,7 @@ import VisitMutations from './mutations/VisitMutations'; import WorkflowMutations from './mutations/WorkflowMutations'; import AdminQueries from './queries/AdminQueries'; import CallQueries from './queries/CallQueries'; +import EmailTemplateQueries from './queries/EmailTemplateQueries'; import EventLogQueries from './queries/EventLogQueries'; import ExperimentQueries from './queries/ExperimentQueries'; import FapQueries from './queries/FapQueries'; @@ -93,6 +95,7 @@ const context: BasicResolverContext = { statusAction: container.resolve(StatusActionQueries), settings: container.resolve(SettingsQueries), experiment: container.resolve(ExperimentQueries), + emailTemplate: container.resolve(EmailTemplateQueries), }, mutations: { admin: container.resolve(AdminMutations), @@ -121,6 +124,7 @@ const context: BasicResolverContext = { status: container.resolve(StatusMutations), workflow: container.resolve(WorkflowMutations), experiment: container.resolve(ExperimentMutations), + emailTemplate: container.resolve(EmailTemplateMutations), }, clients: { scheduler: async () => { diff --git a/apps/backend/src/config/Tokens.ts b/apps/backend/src/config/Tokens.ts index 5b88884c34..391353d85d 100644 --- a/apps/backend/src/config/Tokens.ts +++ b/apps/backend/src/config/Tokens.ts @@ -7,6 +7,7 @@ export const Tokens = { ConfigureLogger: Symbol('ConfigureLogger'), CoProposerClaimDataSource: Symbol('CoProposerClaimDataSource'), EmailEventHandler: Symbol('EmailEventHandler'), + EmailTemplateDataSource: Symbol('EmailTemplateDataSource'), EventBus: Symbol('EventBus'), EventLogsDataSource: Symbol('EventLogsDataSource'), FeedbackDataSource: Symbol('FeedbackDataSource'), diff --git a/apps/backend/src/context/index.ts b/apps/backend/src/context/index.ts index 20a64aa3a9..1f7dcfd175 100644 --- a/apps/backend/src/context/index.ts +++ b/apps/backend/src/context/index.ts @@ -5,6 +5,7 @@ import { Sdk } from '../middlewares/graphqlClient'; import { UserWithRole } from '../models/User'; import AdminMutations from '../mutations/AdminMutations'; import CallMutations from '../mutations/CallMutations'; +import EmailTemplateMutations from '../mutations/EmailTemplateMutations'; import ExperimentMutations from '../mutations/ExperimentMutation'; import FapMutations from '../mutations/FapMutations'; import FeedbackMutations from '../mutations/FeedbackMutations'; @@ -31,6 +32,7 @@ import VisitMutations from '../mutations/VisitMutations'; import WorkflowMutations from '../mutations/WorkflowMutations'; import AdminQueries from '../queries/AdminQueries'; import CallQueries from '../queries/CallQueries'; +import EmailTemplateQueries from '../queries/EmailTemplateQueries'; import EventLogQueries from '../queries/EventLogQueries'; import ExperimentQueries from '../queries/ExperimentQueries'; import FapQueries from '../queries/FapQueries'; @@ -91,6 +93,7 @@ interface ResolverContextQueries { workflow: WorkflowQueries; statusAction: StatusActionQueries; experiment: ExperimentQueries; + emailTemplate: EmailTemplateQueries; } interface ResolverContextMutations { @@ -120,6 +123,7 @@ interface ResolverContextMutations { status: StatusMutations; workflow: WorkflowMutations; experiment: ExperimentMutations; + emailTemplate: EmailTemplateMutations; } interface ResolverContextServices { pdfServices: PDFServices; diff --git a/apps/backend/src/datasources/EmailTemplateDataSource.ts b/apps/backend/src/datasources/EmailTemplateDataSource.ts new file mode 100644 index 0000000000..05d73fdad1 --- /dev/null +++ b/apps/backend/src/datasources/EmailTemplateDataSource.ts @@ -0,0 +1,27 @@ +import { EmailTemplate } from '../models/EmailTemplate'; +import { EmailTemplatesFilter } from '../resolvers/queries/EmailTemplatesQuery'; + +export interface EmailTemplateDataSource { + getEmailTemplate(id: number): Promise; + getEmailTemplates( + filter?: EmailTemplatesFilter + ): Promise<{ totalCount: number; emailTemplates: EmailTemplate[] }>; + + create( + createdByUserId: number, + name: string, + description: string, + subject: string, + body: string + ): Promise; + + update( + id: number, + name: string, + description: string, + subject: string, + body: string + ): Promise; + + delete(id: number): Promise; +} diff --git a/apps/backend/src/datasources/mockups/EmailTemplateDataSource.ts b/apps/backend/src/datasources/mockups/EmailTemplateDataSource.ts new file mode 100644 index 0000000000..3daaba30d3 --- /dev/null +++ b/apps/backend/src/datasources/mockups/EmailTemplateDataSource.ts @@ -0,0 +1,48 @@ +import { EmailTemplate } from '../../models/EmailTemplate'; +import { EmailTemplatesFilter } from '../../resolvers/queries/EmailTemplatesQuery'; +import { EmailTemplateDataSource } from '../EmailTemplateDataSource'; + +export const dummyEmailTemplate = { + id: 1, + createdByUserId: 1, + name: 'Dummy Email Template', + description: 'This is a dummy email template for testing purposes.', + subject: 'Welcome to Our Service', + body: 'Hello, thank you for signing up for our service. We are excited to have you on board!', + createdAt: new Date().toISOString(), +}; + +export class EmailTemplateDataSourceMock implements EmailTemplateDataSource { + async delete(id: number): Promise { + return dummyEmailTemplate; + } + async getEmailTemplates( + filter?: EmailTemplatesFilter + ): Promise<{ totalCount: number; emailTemplates: EmailTemplate[] }> { + return { totalCount: 1, emailTemplates: [dummyEmailTemplate] }; + } + + async getEmailTemplate(id: number): Promise { + return dummyEmailTemplate; + } + + async create( + createdByUserId: number, + name: string, + description: string, + subject: string, + body: string + ): Promise { + return dummyEmailTemplate; + } + + async update( + id: number, + name: string, + description: string, + subject: string, + body: string + ): Promise { + return dummyEmailTemplate; + } +} diff --git a/apps/backend/src/datasources/postgres/EmailTemplateDataSource.ts b/apps/backend/src/datasources/postgres/EmailTemplateDataSource.ts new file mode 100644 index 0000000000..d3b7d5fb6d --- /dev/null +++ b/apps/backend/src/datasources/postgres/EmailTemplateDataSource.ts @@ -0,0 +1,127 @@ +import { GraphQLError } from 'graphql'; +import { inject, injectable } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { EmailTemplate } from '../../models/EmailTemplate'; +import { EmailTemplatesFilter } from '../../resolvers/queries/EmailTemplatesQuery'; +import { EmailTemplateDataSource } from '../EmailTemplateDataSource'; +import database from './database'; +import { createEmailTemplateObject, EmailTemplateRecord } from './records'; + +@injectable() +export default class PostgresEmailTemplateDataSource + implements EmailTemplateDataSource +{ + constructor( + @inject(Tokens.EmailTemplateDataSource) + private emailTemplateDataSource: EmailTemplateDataSource + ) {} + + async getEmailTemplate(id: number): Promise { + return database + .select() + .from('email_templates') + .where('email_templaet_id', id) + .first() + .then((emailTemplate: EmailTemplateRecord) => { + return emailTemplate ? createEmailTemplateObject(emailTemplate) : null; + }); + } + + async getEmailTemplates( + filter?: EmailTemplatesFilter + ): Promise<{ totalCount: number; emailTemplates: EmailTemplate[] }> { + const query = database('email_templates').select(['*']); + + if (filter?.filter) { + query.where('name', 'ilike', `%${filter.filter}%`); + } + + if (filter?.emailTemplateIds) { + query.whereIn('email_template_id', filter.emailTemplateIds); + } + + if (filter?.first) { + query.limit(filter?.first); + } + + if (filter?.offset) { + query.offset(filter?.offset); + } + + return database + .select() + .from('email_templates') + .then((emailTemplates: EmailTemplateRecord[]) => { + return { + totalCount: emailTemplates.length, + emailTemplates: emailTemplates.map(createEmailTemplateObject), + }; + }); + } + + async create( + createdByUserId: number, + name: string, + description: string, + subject: string, + body: string + ): Promise { + return database + .insert( + { + created_by: createdByUserId, + name: name, + description: description, + subject: subject, + body: body, + }, + ['*'] + ) + .from('email_templates') + .then((resultSet: EmailTemplateRecord[]) => + createEmailTemplateObject(resultSet[0]) + ); + } + + async update( + emailTemplateId: number, + name: string, + description: string, + subject: string, + body: string + ): Promise { + return database + .update( + { + name: name, + description: description, + subject: subject, + body: body, + }, + ['*'] + ) + .from('email_templates') + .where('email_templates.email_template_id', emailTemplateId) + .then((resultSet: EmailTemplateRecord[]) => { + return createEmailTemplateObject(resultSet[0]); + }); + } + + async delete(id: number): Promise { + return database + .where('email_templates.email_template_id', id) + .del() + .from('email_templates') + .returning('*') + .then((emailTemplate: EmailTemplateRecord[]) => { + if (emailTemplate === undefined || emailTemplate.length !== 1) { + throw new GraphQLError( + `Could not delete emailTemplate with id:${id}` + ); + } + + return createEmailTemplateObject(emailTemplate[0]); + }); + } +} diff --git a/apps/backend/src/datasources/postgres/records.ts b/apps/backend/src/datasources/postgres/records.ts index bf10b45f41..c87658f95a 100644 --- a/apps/backend/src/datasources/postgres/records.ts +++ b/apps/backend/src/datasources/postgres/records.ts @@ -10,6 +10,7 @@ import { } from '../../models/ConditionEvaluator'; import { CoProposerClaim } from '../../models/CoProposerClaim'; import { Country } from '../../models/Country'; +import { EmailTemplate } from '../../models/EmailTemplate'; import { ExperimentStatus } from '../../models/Experiment'; import { Fap, FapAssignment, FapProposal, FapReviewer } from '../../models/Fap'; import { FapMeetingDecision } from '../../models/FapMeetingDecision'; @@ -340,6 +341,15 @@ export interface CallRecord { readonly is_active: boolean; } +export interface EmailTemplateRecord { + readonly email_template_id: number; + readonly created_by_user_id: number; + readonly name: string; + readonly description: string; + readonly subject: string; + readonly body: string; +} + export interface PageTextRecord { readonly pagetext_id: number; readonly content: string; @@ -1021,6 +1031,19 @@ export const createCallObject = (call: CallRecord) => { ); }; +export const createEmailTemplateObject = ( + emailTemplate: EmailTemplateRecord +) => { + return new EmailTemplate( + emailTemplate.email_template_id, + emailTemplate.created_by_user_id, + emailTemplate.name, + emailTemplate.description, + emailTemplate.subject, + emailTemplate.body + ); +}; + export const createCallHasInstrumentObject = ( callHasInstrument: CallHasInstrumentRecord ) => { diff --git a/apps/backend/src/models/EmailTemplate.ts b/apps/backend/src/models/EmailTemplate.ts new file mode 100644 index 0000000000..bbf71d7fad --- /dev/null +++ b/apps/backend/src/models/EmailTemplate.ts @@ -0,0 +1,10 @@ +export class EmailTemplate { + constructor( + public id: number, + public createdByUserId: number, + public name: string, + public description: string, + public subject: string, + public body: string + ) {} +} diff --git a/apps/backend/src/mutations/EmailTemplateMutations.ts b/apps/backend/src/mutations/EmailTemplateMutations.ts new file mode 100644 index 0000000000..e31da9bf29 --- /dev/null +++ b/apps/backend/src/mutations/EmailTemplateMutations.ts @@ -0,0 +1,102 @@ +import { inject, injectable } from 'tsyringe'; +import { Authorized } from 'type-graphql'; + +import { Tokens } from '../config/Tokens'; +import { EmailTemplateDataSource } from '../datasources/EmailTemplateDataSource'; +import { EmailTemplate } from '../models/EmailTemplate'; +import { Rejection, rejection } from '../models/Rejection'; +import { Roles } from '../models/Role'; +import { UserWithRole } from '../models/User'; +import { CreateEmailTemplateInput } from '../resolvers/mutations/CreateEmailTemplateMutation'; +import { UpdateEmailTemplateInput } from '../resolvers/mutations/UpdateEmailTemplateMutation'; + +@injectable() +export default class EmailTemplateMutations { + constructor( + @inject(Tokens.EmailTemplateDataSource) + private dataSource: EmailTemplateDataSource + ) {} + + @Authorized([Roles.USER_OFFICER]) + async create( + agent: UserWithRole | null, + args: CreateEmailTemplateInput + ): Promise { + try { + const createdEmailTemplate = await this.dataSource.create( + args.createdByUserId, + args.name, + args.description, + args.subject, + args.body + ); + + return createdEmailTemplate; + } catch (error) { + return rejection('Could not create email template', { + agent, + name: args.name, + }); + } + } + + @Authorized([Roles.USER_OFFICER]) + async update( + agent: UserWithRole | null, + args: UpdateEmailTemplateInput + ): Promise { + try { + const updatedEmailTemplate = await this.dataSource.update( + args.id, + args.name, + args.description, + args.subject, + args.body + ); + + return updatedEmailTemplate; + } catch (error) { + return rejection('Could not update email template', { + agent, + name: args.name, + }); + } + } + + @Authorized([Roles.USER_OFFICER]) + async delete( + agent: UserWithRole | null, + { emailTemplateId }: { emailTemplateId: number } + ): Promise { + const emailTemplate = + await this.dataSource.getEmailTemplate(emailTemplateId); + + if (!emailTemplate) { + return rejection('Email template not found', { emailTemplateId }); + } + + try { + const result = await this.dataSource.delete(emailTemplateId); + + return result; + } catch (error) { + // NOTE: We are explicitly casting error to { code: string } type because it is the easiest solution for now and because it's type is a bit difficult to determine because of knexjs not returning typed error message. + if ((error as { code: string }).code === '23503') { + return rejection( + 'Failed to delete call, it has dependencies which need to be deleted first', + { emailTemplateId }, + error + ); + } + + return rejection( + 'Failed to delete email template', + { + agent, + emailTemplateId, + }, + error + ); + } + } +} diff --git a/apps/backend/src/queries/EmailTemplateQueries.ts b/apps/backend/src/queries/EmailTemplateQueries.ts new file mode 100644 index 0000000000..98fce5a9cb --- /dev/null +++ b/apps/backend/src/queries/EmailTemplateQueries.ts @@ -0,0 +1,44 @@ +import { inject, injectable } from 'tsyringe'; + +import { UserAuthorization } from '../auth/UserAuthorization'; +import { Tokens } from '../config/Tokens'; +import { EmailTemplateDataSource } from '../datasources/EmailTemplateDataSource'; +import { Authorized } from '../decorators'; +import { Roles } from '../models/Role'; +import { UserWithRole } from '../models/User'; +import { EmailTemplatesFilter } from '../resolvers/queries/EmailTemplatesQuery'; + +@injectable() +export default class EmailTemplateQueries { + constructor( + @inject(Tokens.EmailTemplateDataSource) + public dataSource: EmailTemplateDataSource, + @inject(Tokens.UserAuthorization) private userAuth: UserAuthorization + ) {} + + @Authorized([Roles.USER_OFFICER]) + async get(agent: UserWithRole | null, id: number) { + const emailTemplate = await this.dataSource.getEmailTemplate(id); + + if (!emailTemplate) { + return null; + } + + if (this.userAuth.isApiToken(agent) || this.userAuth.isUserOfficer(agent)) { + return emailTemplate; + } else { + return null; + } + } + + @Authorized([Roles.USER_OFFICER]) + async getAll(agent: UserWithRole | null, filter: EmailTemplatesFilter) { + const emailTemplates = this.dataSource.getEmailTemplates(filter); + + if (this.userAuth.isApiToken(agent) || this.userAuth.isUserOfficer(agent)) { + return emailTemplates; + } else { + return null; + } + } +} diff --git a/apps/backend/src/resolvers/mutations/CreateEmailTemplateMutation.ts b/apps/backend/src/resolvers/mutations/CreateEmailTemplateMutation.ts new file mode 100644 index 0000000000..07a2bb8ca0 --- /dev/null +++ b/apps/backend/src/resolvers/mutations/CreateEmailTemplateMutation.ts @@ -0,0 +1,45 @@ +import { + Arg, + Ctx, + Field, + InputType, + Int, + Mutation, + Resolver, +} from 'type-graphql'; + +import { ResolverContext } from '../../context'; +import { EmailTemplate } from '../types/EmailTemplate'; + +@InputType() +export class CreateEmailTemplateInput { + @Field(() => Int) + public createdByUserId: number; + + @Field() + public name: string; + + @Field({ nullable: true }) + public description: string; + + @Field() + public subject: string; + + @Field() + public body: string; +} + +@Resolver() +export class CreateEmailTemplateMutation { + @Mutation(() => EmailTemplate) + createEmailTemplate( + @Arg('createEmailTemplateInput') + createEmailTemplateInput: CreateEmailTemplateInput, + @Ctx() context: ResolverContext + ) { + return context.mutations.emailTemplate.create( + context.user, + createEmailTemplateInput + ); + } +} diff --git a/apps/backend/src/resolvers/mutations/DeleteEmailTemplateMutation.ts b/apps/backend/src/resolvers/mutations/DeleteEmailTemplateMutation.ts new file mode 100644 index 0000000000..aa6ce21328 --- /dev/null +++ b/apps/backend/src/resolvers/mutations/DeleteEmailTemplateMutation.ts @@ -0,0 +1,17 @@ +import { Arg, Ctx, Int, Mutation, Resolver } from 'type-graphql'; + +import { ResolverContext } from '../../context'; +import { EmailTemplate } from '../types/EmailTemplate'; + +@Resolver() +export class DeleteEmailTemplateMutation { + @Mutation(() => EmailTemplate) + deleteEmailTemplate( + @Arg('id', () => Int) id: number, + @Ctx() context: ResolverContext + ) { + return context.mutations.emailTemplate.delete(context.user, { + emailTemplateId: id, + }); + } +} diff --git a/apps/backend/src/resolvers/mutations/UpdateEmailTemplateMutation.ts b/apps/backend/src/resolvers/mutations/UpdateEmailTemplateMutation.ts new file mode 100644 index 0000000000..9ea1b2d915 --- /dev/null +++ b/apps/backend/src/resolvers/mutations/UpdateEmailTemplateMutation.ts @@ -0,0 +1,45 @@ +import { + Arg, + Ctx, + Field, + InputType, + Int, + Mutation, + Resolver, +} from 'type-graphql'; + +import { ResolverContext } from '../../context'; +import { EmailTemplate } from '../types/EmailTemplate'; + +@InputType() +export class UpdateEmailTemplateInput { + @Field(() => Int) + public id: number; + + @Field() + public name: string; + + @Field({ nullable: true }) + public description: string; + + @Field() + public subject: string; + + @Field() + public body: string; +} + +@Resolver() +export class UpdateEmailTemplateMutation { + @Mutation(() => EmailTemplate) + updateEmailTemplate( + @Arg('updateEmailTemplateInput') + updateEmailTemplateInput: UpdateEmailTemplateInput, + @Ctx() context: ResolverContext + ) { + return context.mutations.emailTemplate.update( + context.user, + updateEmailTemplateInput + ); + } +} diff --git a/apps/backend/src/resolvers/queries/EmailTemplateQuery.ts b/apps/backend/src/resolvers/queries/EmailTemplateQuery.ts new file mode 100644 index 0000000000..2d4fff7738 --- /dev/null +++ b/apps/backend/src/resolvers/queries/EmailTemplateQuery.ts @@ -0,0 +1,15 @@ +import { Arg, Ctx, Int, Query, Resolver } from 'type-graphql'; + +import { ResolverContext } from '../../context'; +import { EmailTemplate } from '../types/EmailTemplate'; + +@Resolver() +export class EmailTemplateQuery { + @Query(() => EmailTemplate, { nullable: true }) + emailTemplate( + @Arg('emailTemplateId', () => Int) emailTemplateId: number, + @Ctx() context: ResolverContext + ) { + return context.queries.emailTemplate.get(context.user, emailTemplateId); + } +} diff --git a/apps/backend/src/resolvers/queries/EmailTemplatesQuery.ts b/apps/backend/src/resolvers/queries/EmailTemplatesQuery.ts new file mode 100644 index 0000000000..28e39af79b --- /dev/null +++ b/apps/backend/src/resolvers/queries/EmailTemplatesQuery.ts @@ -0,0 +1,49 @@ +import { + Arg, + Ctx, + Field, + InputType, + Int, + ObjectType, + Query, + Resolver, +} from 'type-graphql'; + +import { ResolverContext } from '../../context'; +import { EmailTemplate } from '../types/EmailTemplate'; + +@InputType() +export class EmailTemplatesFilter { + @Field(() => String, { nullable: true }) + filter?: string; + + @Field(() => Int, { nullable: true }) + first?: number; + + @Field(() => Int, { nullable: true }) + offset?: number; + + @Field(() => [Int], { nullable: true }) + public emailTemplateIds?: number[]; +} + +@ObjectType() +class EmailTemplatesQueryResult { + @Field(() => Int) + public totalCount: number; + + @Field(() => [EmailTemplate]) + public emailTemplates: EmailTemplate[]; +} + +@Resolver() +export class EmailTemplatesQuery { + @Query(() => EmailTemplatesQueryResult, { nullable: true }) + emailTemplates( + @Ctx() context: ResolverContext, + @Arg('filter', () => EmailTemplatesFilter, { nullable: true }) + filter: EmailTemplatesFilter + ) { + return context.queries.emailTemplate.getAll(context.user, filter); + } +} diff --git a/apps/backend/src/resolvers/types/EmailTemplate.ts b/apps/backend/src/resolvers/types/EmailTemplate.ts new file mode 100644 index 0000000000..f7c2772eab --- /dev/null +++ b/apps/backend/src/resolvers/types/EmailTemplate.ts @@ -0,0 +1,24 @@ +import { Field, Int, ObjectType } from 'type-graphql'; + +import { EmailTemplate as EmailTemplateOrigin } from '../../models/EmailTemplate'; + +@ObjectType() +export class EmailTemplate implements Partial { + @Field(() => Int) + public id: number; + + @Field() + public createdByUserId: number; + + @Field() + public name: string; + + @Field({ nullable: true }) + public description: string; + + @Field() + public subject: string; + + @Field() + public body: string; +} diff --git a/apps/frontend/src/graphql/emailTemplate/createEmailTemplate.graphql b/apps/frontend/src/graphql/emailTemplate/createEmailTemplate.graphql new file mode 100644 index 0000000000..94517b7731 --- /dev/null +++ b/apps/frontend/src/graphql/emailTemplate/createEmailTemplate.graphql @@ -0,0 +1,19 @@ +mutation createEmailTemplate( + $createdByUserId: Int! + $name: String! + $description: String + $subject: String! + $body: String! +) { + createEmailTemplate( + createEmailTemplateInput: { + createdByUserId: $createdByUserId + name: $name + description: $description + subject: $subject + body: $body + } + ) { + ...emailTemplate + } +} diff --git a/apps/frontend/src/graphql/emailTemplate/deleteEmailTemplate.graphql b/apps/frontend/src/graphql/emailTemplate/deleteEmailTemplate.graphql new file mode 100644 index 0000000000..a37b19f714 --- /dev/null +++ b/apps/frontend/src/graphql/emailTemplate/deleteEmailTemplate.graphql @@ -0,0 +1,5 @@ +mutation deleteEmailTemplate($id: Int!) { + deleteEmailTemplate(id: $id) { + id + } +} diff --git a/apps/frontend/src/graphql/emailTemplate/fragment.emailTemplate.graphql b/apps/frontend/src/graphql/emailTemplate/fragment.emailTemplate.graphql new file mode 100644 index 0000000000..3fa38ffb03 --- /dev/null +++ b/apps/frontend/src/graphql/emailTemplate/fragment.emailTemplate.graphql @@ -0,0 +1,8 @@ +fragment emailTemplate on EmailTemplate { + id + createdByUserId + name + description + subject + body +} diff --git a/apps/frontend/src/graphql/emailTemplate/getEmailTemplate.graphql b/apps/frontend/src/graphql/emailTemplate/getEmailTemplate.graphql new file mode 100644 index 0000000000..3de666ac7b --- /dev/null +++ b/apps/frontend/src/graphql/emailTemplate/getEmailTemplate.graphql @@ -0,0 +1,5 @@ +query getEmailTemplate($emailTemplateId: Int!) { + emailTemplate(emailTemplateId: $emailTemplateId) { + ...emailTemplate + } +} diff --git a/apps/frontend/src/graphql/emailTemplate/getEmailTemplates.graphql b/apps/frontend/src/graphql/emailTemplate/getEmailTemplates.graphql new file mode 100644 index 0000000000..b6c007ad6d --- /dev/null +++ b/apps/frontend/src/graphql/emailTemplate/getEmailTemplates.graphql @@ -0,0 +1,8 @@ +query getEmailTemplates($filter: EmailTemplatesFilter) { + emailTemplates(filter: $filter) { + emailTemplates { + ...emailTemplate + } + totalCount + } +} diff --git a/apps/frontend/src/graphql/emailTemplate/updateEmailTemplate.graphql b/apps/frontend/src/graphql/emailTemplate/updateEmailTemplate.graphql new file mode 100644 index 0000000000..90065b7e6e --- /dev/null +++ b/apps/frontend/src/graphql/emailTemplate/updateEmailTemplate.graphql @@ -0,0 +1,19 @@ +mutation updateEmailTemplate( + $id: Int! + $name: String! + $description: String + $subject: String! + $body: String! +) { + updateEmailTemplate( + updateEmailTemplateInput: { + id: $id + name: $name + description: $description + subject: $subject + body: $body + } + ) { + id + } +} From bbf6b81434b45bfb1cacb6f69125d19d06ab8dcc Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Thu, 17 Jul 2025 09:10:28 +0200 Subject: [PATCH 02/62] feat: add skeleton for frontend implementation of configurable email templates --- .../src/config/dependencyConfigDefault.ts | 3 + .../backend/src/config/dependencyConfigE2E.ts | 3 + .../backend/src/config/dependencyConfigELI.ts | 3 + .../backend/src/config/dependencyConfigESS.ts | 3 + .../src/config/dependencyConfigSTFC.ts | 3 + .../src/config/dependencyConfigTest.ts | 3 + .../postgres/EmailTemplateDataSource.ts | 8 +- .../mutations/UpdateEmailTemplateMutation.ts | 3 + .../CreateUpdateEmailTemplate.tsx | 130 ++++++++++++++++++ .../emailTemplate/EmailTemplatePage.tsx | 17 +++ .../emailTemplate/EmailTemplateTable.tsx | 98 +++++++++++++ .../components/menu/TemplateMenuListItem.tsx | 11 ++ .../emailTemplate/updateEmailTemplate.graphql | 4 +- .../emailTemplate/useEmailTemplatesData.ts | 69 ++++++++++ apps/frontend/vite.config.mts | 1 + 15 files changed, 351 insertions(+), 8 deletions(-) create mode 100644 apps/frontend/src/components/emailTemplate/CreateUpdateEmailTemplate.tsx create mode 100644 apps/frontend/src/components/emailTemplate/EmailTemplatePage.tsx create mode 100644 apps/frontend/src/components/emailTemplate/EmailTemplateTable.tsx create mode 100644 apps/frontend/src/hooks/emailTemplate/useEmailTemplatesData.ts diff --git a/apps/backend/src/config/dependencyConfigDefault.ts b/apps/backend/src/config/dependencyConfigDefault.ts index b1132e48bb..a9f399ef9f 100644 --- a/apps/backend/src/config/dependencyConfigDefault.ts +++ b/apps/backend/src/config/dependencyConfigDefault.ts @@ -11,6 +11,7 @@ import { VisitRegistrationAuthorization } from '../auth/VisitRegistrationAuthori import { PostgresAdminDataSourceWithAutoUpgrade } from '../datasources/postgres/AdminDataSource'; import PostgresCallDataSource from '../datasources/postgres/CallDataSource'; import PostgresCoProposerClaimDataSource from '../datasources/postgres/CoProposerClaimDataSource'; +import PostgresEmailTemplateDataSource from '../datasources/postgres/EmailTemplateDataSource'; import PostgresEventLogsDataSource from '../datasources/postgres/EventLogsDataSource'; import PostgresExperimentDataSource from '../datasources/postgres/ExperimentDataSource'; import PostgresFapDataSource from '../datasources/postgres/FapDataSource'; @@ -132,3 +133,5 @@ mapValue(Tokens.ConfigureLogger, () => setLogger(new ConsoleLogger())); mapClass(Tokens.DownloadService, DefaultDownloadService); mapClass(Tokens.BasicUserDetailsLoader, BasicUserDetailsLoader); + +mapClass(Tokens.EmailTemplateDataSource, PostgresEmailTemplateDataSource); diff --git a/apps/backend/src/config/dependencyConfigE2E.ts b/apps/backend/src/config/dependencyConfigE2E.ts index ed2e58d085..0954451a1b 100644 --- a/apps/backend/src/config/dependencyConfigE2E.ts +++ b/apps/backend/src/config/dependencyConfigE2E.ts @@ -7,6 +7,7 @@ import { VisitRegistrationAuthorization } from '../auth/VisitRegistrationAuthori import PostgresAdminDataSource from '../datasources/postgres/AdminDataSource'; import PostgresCallDataSource from '../datasources/postgres/CallDataSource'; import PostgresCoProposerClaimDataSource from '../datasources/postgres/CoProposerClaimDataSource'; +import PostgresEmailTemplateDataSource from '../datasources/postgres/EmailTemplateDataSource'; import PostgresEventLogsDataSource from '../datasources/postgres/EventLogsDataSource'; import PostgresExperimentDataSource from '../datasources/postgres/ExperimentDataSource'; import PostgresFapDataSource from '../datasources/postgres/FapDataSource'; @@ -124,3 +125,5 @@ mapValue(Tokens.ConfigureLogger, () => setLogger(new ConsoleLogger())); mapClass(Tokens.DownloadService, DefaultDownloadService); mapClass(Tokens.BasicUserDetailsLoader, BasicUserDetailsLoader); + +mapClass(Tokens.EmailTemplateDataSource, PostgresEmailTemplateDataSource); diff --git a/apps/backend/src/config/dependencyConfigELI.ts b/apps/backend/src/config/dependencyConfigELI.ts index abafbe79e8..2c97cb8cbc 100644 --- a/apps/backend/src/config/dependencyConfigELI.ts +++ b/apps/backend/src/config/dependencyConfigELI.ts @@ -6,6 +6,7 @@ import { VisitRegistrationAuthorization } from '../auth/VisitRegistrationAuthori import { PostgresAdminDataSourceWithAutoUpgrade } from '../datasources/postgres/AdminDataSource'; import PostgresCallDataSource from '../datasources/postgres/CallDataSource'; import PostgresCoProposerClaimDataSource from '../datasources/postgres/CoProposerClaimDataSource'; +import PostgresEmailTemplateDataSource from '../datasources/postgres/EmailTemplateDataSource'; import PostgresEventLogsDataSource from '../datasources/postgres/EventLogsDataSource'; import PostgresExperimentDataSource from '../datasources/postgres/ExperimentDataSource'; import PostgresFapDataSource from '../datasources/postgres/FapDataSource'; @@ -130,3 +131,5 @@ mapValue(Tokens.ConfigureLogger, configureGraylogLogger); mapClass(Tokens.DownloadService, DefaultDownloadService); mapClass(Tokens.BasicUserDetailsLoader, BasicUserDetailsLoader); + +mapClass(Tokens.EmailTemplateDataSource, PostgresEmailTemplateDataSource); diff --git a/apps/backend/src/config/dependencyConfigESS.ts b/apps/backend/src/config/dependencyConfigESS.ts index d53f84595d..fbe22a5d6a 100644 --- a/apps/backend/src/config/dependencyConfigESS.ts +++ b/apps/backend/src/config/dependencyConfigESS.ts @@ -7,6 +7,7 @@ import { VisitRegistrationAuthorization } from '../auth/VisitRegistrationAuthori import { PostgresAdminDataSourceWithAutoUpgrade } from '../datasources/postgres/AdminDataSource'; import PostgresCallDataSource from '../datasources/postgres/CallDataSource'; import PostgresCoProposerClaimDataSource from '../datasources/postgres/CoProposerClaimDataSource'; +import PostgresEmailTemplateDataSource from '../datasources/postgres/EmailTemplateDataSource'; import PostgresEventLogsDataSource from '../datasources/postgres/EventLogsDataSource'; import PostgresExperimentDataSource from '../datasources/postgres/ExperimentDataSource'; import PostgresFapDataSource from '../datasources/postgres/FapDataSource'; @@ -130,3 +131,5 @@ mapValue(Tokens.ConfigureLogger, configureGraylogLogger); mapClass(Tokens.DownloadService, DefaultDownloadService); mapClass(Tokens.BasicUserDetailsLoader, BasicUserDetailsLoader); + +mapClass(Tokens.EmailTemplateDataSource, PostgresEmailTemplateDataSource); diff --git a/apps/backend/src/config/dependencyConfigSTFC.ts b/apps/backend/src/config/dependencyConfigSTFC.ts index 518ed5c135..bad3e71aeb 100644 --- a/apps/backend/src/config/dependencyConfigSTFC.ts +++ b/apps/backend/src/config/dependencyConfigSTFC.ts @@ -6,6 +6,7 @@ import { VisitRegistrationAuthorization } from '../auth/VisitRegistrationAuthori import { PostgresAdminDataSourceWithAutoUpgrade } from '../datasources/postgres/AdminDataSource'; import PostgresCallDataSource from '../datasources/postgres/CallDataSource'; import PostgresCoProposerClaimDataSource from '../datasources/postgres/CoProposerClaimDataSource'; +import PostgresEmailTemplateDataSource from '../datasources/postgres/EmailTemplateDataSource'; import PostgresEventLogsDataSource from '../datasources/postgres/EventLogsDataSource'; import PostgresExperimentDataSource from '../datasources/postgres/ExperimentDataSource'; import PostgresFeedbackDataSource from '../datasources/postgres/FeedbackDataSource'; @@ -124,3 +125,5 @@ mapValue(Tokens.ConfigureLogger, configureSTFCWinstonLogger); mapClass(Tokens.DownloadService, StfcDownloadService); mapClass(Tokens.BasicUserDetailsLoader, BasicUserDetailsLoader); + +mapClass(Tokens.EmailTemplateDataSource, PostgresEmailTemplateDataSource); diff --git a/apps/backend/src/config/dependencyConfigTest.ts b/apps/backend/src/config/dependencyConfigTest.ts index ab1ac37ce9..4bff41be27 100644 --- a/apps/backend/src/config/dependencyConfigTest.ts +++ b/apps/backend/src/config/dependencyConfigTest.ts @@ -8,6 +8,7 @@ import { VisitRegistrationAuthorization } from '../auth/VisitRegistrationAuthori import { AdminDataSourceMock } from '../datasources/mockups/AdminDataSource'; import { CallDataSourceMock } from '../datasources/mockups/CallDataSource'; import { CoProposerClaimDataSourceMock } from '../datasources/mockups/CoProposerClaimDataSource'; +import { EmailTemplateDataSourceMock } from '../datasources/mockups/EmailTemplateDataSource'; import { EventLogsDataSourceMock } from '../datasources/mockups/EventLogsDataSource'; import { ExperimentDataSourceMock } from '../datasources/mockups/ExperimentDataSource'; import { FapDataSourceMock } from '../datasources/mockups/FapDataSource'; @@ -115,3 +116,5 @@ mapClass(Tokens.DownloadService, DefaultDownloadService); mapClass(Tokens.BasicUserDetailsLoader, BasicUserDetailsLoader); mapValue(Tokens.EventBus, createApplicationEventBus()); + +mapClass(Tokens.EmailTemplateDataSource, EmailTemplateDataSourceMock); diff --git a/apps/backend/src/datasources/postgres/EmailTemplateDataSource.ts b/apps/backend/src/datasources/postgres/EmailTemplateDataSource.ts index d3b7d5fb6d..1d7af05c79 100644 --- a/apps/backend/src/datasources/postgres/EmailTemplateDataSource.ts +++ b/apps/backend/src/datasources/postgres/EmailTemplateDataSource.ts @@ -1,7 +1,6 @@ import { GraphQLError } from 'graphql'; -import { inject, injectable } from 'tsyringe'; +import { injectable } from 'tsyringe'; -import { Tokens } from '../../config/Tokens'; import { EmailTemplate } from '../../models/EmailTemplate'; import { EmailTemplatesFilter } from '../../resolvers/queries/EmailTemplatesQuery'; import { EmailTemplateDataSource } from '../EmailTemplateDataSource'; @@ -12,11 +11,6 @@ import { createEmailTemplateObject, EmailTemplateRecord } from './records'; export default class PostgresEmailTemplateDataSource implements EmailTemplateDataSource { - constructor( - @inject(Tokens.EmailTemplateDataSource) - private emailTemplateDataSource: EmailTemplateDataSource - ) {} - async getEmailTemplate(id: number): Promise { return database .select() diff --git a/apps/backend/src/resolvers/mutations/UpdateEmailTemplateMutation.ts b/apps/backend/src/resolvers/mutations/UpdateEmailTemplateMutation.ts index 9ea1b2d915..9de4156753 100644 --- a/apps/backend/src/resolvers/mutations/UpdateEmailTemplateMutation.ts +++ b/apps/backend/src/resolvers/mutations/UpdateEmailTemplateMutation.ts @@ -16,6 +16,9 @@ export class UpdateEmailTemplateInput { @Field(() => Int) public id: number; + @Field(() => Int) + public createdByUserId: number; + @Field() public name: string; diff --git a/apps/frontend/src/components/emailTemplate/CreateUpdateEmailTemplate.tsx b/apps/frontend/src/components/emailTemplate/CreateUpdateEmailTemplate.tsx new file mode 100644 index 0000000000..9a10a4baf4 --- /dev/null +++ b/apps/frontend/src/components/emailTemplate/CreateUpdateEmailTemplate.tsx @@ -0,0 +1,130 @@ +import Button from '@mui/material/Button'; +import Typography from '@mui/material/Typography'; +import { Field, Form, Formik } from 'formik'; +import i18n from 'i18n'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import TextField from 'components/common/FormikUITextField'; +import UOLoader from 'components/common/UOLoader'; +import { EmailTemplateFragment } from 'generated/sdk'; +import useDataApiWithFeedback from 'utils/useDataApiWithFeedback'; + +type CreateUpdateEmailTemplateProps = { + close: (emailTemplateAdded: EmailTemplateFragment | null) => void; + emailTemplate: EmailTemplateFragment | null; +}; + +const CreateUpdateEmailTemplate = ({ + close, + emailTemplate, +}: CreateUpdateEmailTemplateProps) => { + const { t } = useTranslation(); + const { api, isExecutingCall } = useDataApiWithFeedback(); + + const initialValues = { + id: emailTemplate?.id || 0, + name: emailTemplate?.name || '', + description: emailTemplate?.description || '', + subject: emailTemplate?.subject || '', + body: emailTemplate?.body || '', + createdByUserId: emailTemplate?.createdByUserId || 0, + }; + + return ( + => { + if (emailTemplate) { + try { + const { updateEmailTemplate } = await api({ + toastSuccessMessage: 'Email template updated successfully!', + }).updateEmailTemplate(values); + + close(updateEmailTemplate); + } catch (error) { + close(null); + } + } else { + try { + const { createEmailTemplate } = await api({ + toastSuccessMessage: 'Email template created successfully!', + }).createEmailTemplate(values); + + close(createEmailTemplate); + } catch (error) { + close(null); + } + } + }} + > + {({ isValid }) => ( +
+ + {(emailTemplate ? 'Update ' : 'Create new ') + + i18n.format(t('Email template'), 'lowercase')} + + + + + + + + )} +
+ ); +}; + +export default CreateUpdateEmailTemplate; diff --git a/apps/frontend/src/components/emailTemplate/EmailTemplatePage.tsx b/apps/frontend/src/components/emailTemplate/EmailTemplatePage.tsx new file mode 100644 index 0000000000..9f7bc50c51 --- /dev/null +++ b/apps/frontend/src/components/emailTemplate/EmailTemplatePage.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { StyledContainer, StyledPaper } from 'styles/StyledComponents'; + +import EmailTemplatesTable from './EmailTemplateTable'; + +const EmailTemplatesPage = () => { + return ( + + + + + + ); +}; + +export default EmailTemplatesPage; diff --git a/apps/frontend/src/components/emailTemplate/EmailTemplateTable.tsx b/apps/frontend/src/components/emailTemplate/EmailTemplateTable.tsx new file mode 100644 index 0000000000..ec0a71aae3 --- /dev/null +++ b/apps/frontend/src/components/emailTemplate/EmailTemplateTable.tsx @@ -0,0 +1,98 @@ +import { Column } from '@material-table/core'; +import { Typography } from '@mui/material'; +import React from 'react'; + +import SuperMaterialTable from 'components/common/SuperMaterialTable'; +import { EmailTemplateFragment, UserRole } from 'generated/sdk'; +import { useCheckAccess } from 'hooks/common/useCheckAccess'; +import { useEmailTemplatesData } from 'hooks/emailTemplate/useEmailTemplatesData'; +import useDataApiWithFeedback from 'utils/useDataApiWithFeedback'; +import { FunctionType } from 'utils/utilTypes'; + +import CreateUpdateEmailTemplate from './CreateUpdateEmailTemplate'; + +const EmailTemplatesTable = () => { + const { + loadingEmailTemplates, + emailTemplates, + setEmailTemplatesWithLoading: setEmailTemplates, + } = useEmailTemplatesData(); + + const isUserOfficer = useCheckAccess([UserRole.USER_OFFICER]); + const { api } = useDataApiWithFeedback(); + const columns: Column[] = [ + { + title: 'Name', + field: 'name', + }, + { + title: 'Description', + field: 'description', + }, + { + title: 'Subject', + field: 'subject', + }, + { + title: 'Body', + field: 'body', + }, + ]; + + const onEmailTemplateDelete = async (emailTemplateId: number | string) => { + try { + await api({ + toastSuccessMessage: 'Email template deleted successfully', + }).deleteEmailTemplate({ id: emailTemplateId as number }); + + return true; + } catch (error) { + return false; + } + }; + + const createModal = ( + onUpdate: FunctionType, + onCreate: FunctionType, + editEmailTemplate: EmailTemplateFragment | null + ) => ( + + !!editEmailTemplate ? onUpdate(emailTemplate) : onCreate(emailTemplate) + } + /> + ); + + return ( + <> +
+ + Email Templates + + } + hasAccess={{ + create: isUserOfficer, + update: isUserOfficer, + remove: isUserOfficer, + }} + columns={columns} + data={emailTemplates} + isLoading={loadingEmailTemplates} + options={{ + search: true, + debounceInterval: 400, + }} + createModal={createModal} + persistUrlQueryParams={true} + > +
+ + ); +}; + +export default EmailTemplatesTable; diff --git a/apps/frontend/src/components/menu/TemplateMenuListItem.tsx b/apps/frontend/src/components/menu/TemplateMenuListItem.tsx index 78a57aba2f..4c7361b427 100644 --- a/apps/frontend/src/components/menu/TemplateMenuListItem.tsx +++ b/apps/frontend/src/components/menu/TemplateMenuListItem.tsx @@ -1,5 +1,6 @@ import DescriptionIcon from '@mui/icons-material/Description'; import DynamicFeedIcon from '@mui/icons-material/DynamicFeed'; +import EmailIcon from '@mui/icons-material/Email'; import ExpandLess from '@mui/icons-material/ExpandLess'; import ExpandMore from '@mui/icons-material/ExpandMore'; import FeedbackIcon from '@mui/icons-material/Feedback'; @@ -33,6 +34,7 @@ const menuMap = { FeedbackTemplates: '/FeedbackTemplates', EsiTemplates: '/EsiTemplates', SampleEsiTemplates: '/SampleEsiTemplates', + EmailTemplates: '/EmailTemplates', }; function EsiTemplatesMenuListItem() { @@ -179,6 +181,15 @@ export function TemplateMenuListItem() { + + + + + + + + + diff --git a/apps/frontend/src/graphql/emailTemplate/updateEmailTemplate.graphql b/apps/frontend/src/graphql/emailTemplate/updateEmailTemplate.graphql index 90065b7e6e..59bc50efcf 100644 --- a/apps/frontend/src/graphql/emailTemplate/updateEmailTemplate.graphql +++ b/apps/frontend/src/graphql/emailTemplate/updateEmailTemplate.graphql @@ -1,5 +1,6 @@ mutation updateEmailTemplate( $id: Int! + $createdByUserId: Int! $name: String! $description: String $subject: String! @@ -8,12 +9,13 @@ mutation updateEmailTemplate( updateEmailTemplate( updateEmailTemplateInput: { id: $id + createdByUserId: $createdByUserId name: $name description: $description subject: $subject body: $body } ) { - id + ...emailTemplate } } diff --git a/apps/frontend/src/hooks/emailTemplate/useEmailTemplatesData.ts b/apps/frontend/src/hooks/emailTemplate/useEmailTemplatesData.ts new file mode 100644 index 0000000000..691cc65c58 --- /dev/null +++ b/apps/frontend/src/hooks/emailTemplate/useEmailTemplatesData.ts @@ -0,0 +1,69 @@ +import { + Dispatch, + SetStateAction, + useContext, + useEffect, + useState, +} from 'react'; + +import { UserContext } from 'context/UserContextProvider'; +import { EmailTemplateFragment, UserRole } from 'generated/sdk'; +import { useDataApi } from 'hooks/common/useDataApi'; + +export function useEmailTemplatesData(): { + loadingEmailTemplates: boolean; + emailTemplates: EmailTemplateFragment[]; + setEmailTemplatesWithLoading: Dispatch< + SetStateAction + >; +} { + const [emailTemplates, setEmailTemplates] = useState( + [] + ); + + const [loadingEmailTemplates, setLoadingEmailTemplates] = useState(true); + + const { currentRole } = useContext(UserContext); + + const api = useDataApi(); + + const setEmailTemplatesWithLoading = ( + data: SetStateAction + ) => { + setLoadingEmailTemplates(true); + setEmailTemplates(data); + setLoadingEmailTemplates(false); + }; + + useEffect(() => { + let unmounted = false; + + setLoadingEmailTemplates(true); + + if (currentRole && currentRole === UserRole.USER_OFFICER) { + api() + .getEmailTemplates() + .then((data) => { + if (unmounted) { + return; + } + + if (data.emailTemplates) { + setEmailTemplates(data.emailTemplates.emailTemplates); + } + setLoadingEmailTemplates(false); + }); + } + + return () => { + // used to avoid unmounted component state update error + unmounted = true; + }; + }, [api, currentRole]); + + return { + loadingEmailTemplates, + emailTemplates, + setEmailTemplatesWithLoading, + }; +} diff --git a/apps/frontend/vite.config.mts b/apps/frontend/vite.config.mts index 3a748059d6..251df849d8 100644 --- a/apps/frontend/vite.config.mts +++ b/apps/frontend/vite.config.mts @@ -34,6 +34,7 @@ export default ({ mode }): UserConfig => { return acc; }, {}), }, + allowedHosts: ['localhost', 'sajat.eu'], }, resolve: { alias: { From c032423586893867a047ca67ec7ccd2483efe76e Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Mon, 21 Jul 2025 09:57:40 +0200 Subject: [PATCH 03/62] feat: update backed for email templates --- .../db_patches/0813_CreateEmailTemplatesTable.sql | 6 +++++- .../datasources/postgres/EmailTemplateDataSource.ts | 2 +- apps/backend/src/datasources/postgres/records.ts | 4 ++-- apps/backend/src/queries/StatusActionQueries.ts | 1 + .../src/resolvers/types/StatusActionConfig.ts | 7 +++++-- apps/frontend/src/components/AppRoutes.tsx | 12 +++++++++++- .../CreateUpdateEmailTemplate.tsx | 6 +++++- .../EmailTemplatePage.tsx | 0 .../EmailTemplateTable.tsx | 0 .../fragment.connectionStatusActionConfig.graphql | 1 + .../fragment.statusActionConfig.graphql | 1 + 11 files changed, 32 insertions(+), 8 deletions(-) rename apps/frontend/src/components/{emailTemplate => template}/CreateUpdateEmailTemplate.tsx (95%) rename apps/frontend/src/components/{emailTemplate => template}/EmailTemplatePage.tsx (100%) rename apps/frontend/src/components/{emailTemplate => template}/EmailTemplateTable.tsx (100%) diff --git a/apps/backend/db_patches/0813_CreateEmailTemplatesTable.sql b/apps/backend/db_patches/0813_CreateEmailTemplatesTable.sql index e123e1268d..6244d2ec7d 100644 --- a/apps/backend/db_patches/0813_CreateEmailTemplatesTable.sql +++ b/apps/backend/db_patches/0813_CreateEmailTemplatesTable.sql @@ -14,7 +14,11 @@ BEGIN body text ); - + -- ALTER TABLE workflow_connection_has_actions ADD COLUMN email_template_id INT; + -- ALTER TABLE workflow_connection_has_actions ADD CONSTRAINT workflow_connection_has_actions_email_template_id_fk + -- FOREIGN KEY (email_template_id) REFERENCES email_templates(email_template_id) + -- ON DELETE CASCADE; + END; END IF; END; diff --git a/apps/backend/src/datasources/postgres/EmailTemplateDataSource.ts b/apps/backend/src/datasources/postgres/EmailTemplateDataSource.ts index 1d7af05c79..8f7c7d6320 100644 --- a/apps/backend/src/datasources/postgres/EmailTemplateDataSource.ts +++ b/apps/backend/src/datasources/postgres/EmailTemplateDataSource.ts @@ -15,7 +15,7 @@ export default class PostgresEmailTemplateDataSource return database .select() .from('email_templates') - .where('email_templaet_id', id) + .where('email_template_id', id) .first() .then((emailTemplate: EmailTemplateRecord) => { return emailTemplate ? createEmailTemplateObject(emailTemplate) : null; diff --git a/apps/backend/src/datasources/postgres/records.ts b/apps/backend/src/datasources/postgres/records.ts index 6146c8e6e7..986e787f7c 100644 --- a/apps/backend/src/datasources/postgres/records.ts +++ b/apps/backend/src/datasources/postgres/records.ts @@ -344,7 +344,7 @@ export interface CallRecord { export interface EmailTemplateRecord { readonly email_template_id: number; - readonly created_by_user_id: number; + readonly created_by: number; readonly name: string; readonly description: string; readonly subject: string; @@ -1039,7 +1039,7 @@ export const createEmailTemplateObject = ( ) => { return new EmailTemplate( emailTemplate.email_template_id, - emailTemplate.created_by_user_id, + emailTemplate.created_by, emailTemplate.name, emailTemplate.description, emailTemplate.subject, diff --git a/apps/backend/src/queries/StatusActionQueries.ts b/apps/backend/src/queries/StatusActionQueries.ts index 7bf1463966..b1aa2f67ec 100644 --- a/apps/backend/src/queries/StatusActionQueries.ts +++ b/apps/backend/src/queries/StatusActionQueries.ts @@ -65,6 +65,7 @@ export default class StatusActionQueries { const emailTemplates = sparkPostEmailTemplates.results.map((item) => ({ id: item.id, name: item.name, + emailTemplateId: 0, })); return new EmailActionDefaultConfig(allEmailRecipients, emailTemplates); diff --git a/apps/backend/src/resolvers/types/StatusActionConfig.ts b/apps/backend/src/resolvers/types/StatusActionConfig.ts index 4e71445491..9afb9e378a 100644 --- a/apps/backend/src/resolvers/types/StatusActionConfig.ts +++ b/apps/backend/src/resolvers/types/StatusActionConfig.ts @@ -1,4 +1,4 @@ -import { Field, ObjectType, createUnionType } from 'type-graphql'; +import { Field, Int, ObjectType, createUnionType } from 'type-graphql'; export enum EmailStatusActionRecipients { PI = 'PI', @@ -55,9 +55,12 @@ export class EmailStatusActionRecipient { } @ObjectType() export class EmailStatusActionEmailTemplate { - @Field(() => String) + @Field(() => String, { nullable: true }) public id: string; + @Field(() => Int) + public emailTemplateId: number; + @Field(() => String) public name?: string; } diff --git a/apps/frontend/src/components/AppRoutes.tsx b/apps/frontend/src/components/AppRoutes.tsx index 553cfd465e..a1f778538c 100644 --- a/apps/frontend/src/components/AppRoutes.tsx +++ b/apps/frontend/src/components/AppRoutes.tsx @@ -1,7 +1,7 @@ import i18n from 'i18n'; import React, { lazy, useContext } from 'react'; import { useTranslation } from 'react-i18next'; -import { Routes, Route, Navigate, Outlet } from 'react-router-dom'; +import { Navigate, Outlet, Route, Routes } from 'react-router-dom'; import { FeatureContext } from 'context/FeatureContextProvider'; import { UserContext } from 'context/UserContextProvider'; @@ -103,6 +103,7 @@ const ImportUnitsPage = lazy(() => import('./unit/ImportUnitsPage')); const PeoplePage = lazy(() => import('./user/PeoplePage')); const ProfilePage = lazy(() => import('./user/ProfilePage')); const UserPage = lazy(() => import('./user/UserPage')); +const EmailTemplatePage = lazy(() => import('./template/EmailTemplatePage')); const PrivateOutlet = () => ( @@ -411,6 +412,15 @@ const AppRoutes = () => { /> } /> + } + /> + } + /> {isVisitManagementEnabled && ( Date: Tue, 22 Jul 2025 15:08:32 +0200 Subject: [PATCH 04/62] feat: update migration script --- .../0185_CreateEmailTemplatesTable.sql | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 apps/backend/db_patches/0185_CreateEmailTemplatesTable.sql diff --git a/apps/backend/db_patches/0185_CreateEmailTemplatesTable.sql b/apps/backend/db_patches/0185_CreateEmailTemplatesTable.sql new file mode 100644 index 0000000000..86267170f7 --- /dev/null +++ b/apps/backend/db_patches/0185_CreateEmailTemplatesTable.sql @@ -0,0 +1,96 @@ +DO +$$ +DECLARE + email_template_id_var int; + connection_loop_var record; + array_size_var int; + array_loop_var int; + template_name varchar(200); +BEGIN + IF register_patch('CreateEmailTemplatesTable.sql', 'Gergely Nyiri', 'Create emails table', '2025-07-15') THEN + + CREATE TABLE IF NOT EXISTS email_templates ( + email_template_id serial PRIMARY KEY, + created_by INT NOT NULL REFERENCES users(user_id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + name varchar(100) NOT NULL UNIQUE, + description varchar(255) NOT NULL, + subject varchar(160) NOT NULL, + body text + ); + + INSERT INTO email_templates (created_by, name, description, subject, body) + VALUES + (0, 'clf-proposal-submitted-pi', 'CLF PI Co-I Submission Email', 'Proposal submitted', 'Proposal submitted'); + + INSERT INTO email_templates (created_by, name, description, subject, body) + VALUES + (0, 'isis-proposal-submitted-pi', 'ISIS PI Co-I Submission Email', 'Proposal submitted', 'Proposal submitted'); + + INSERT INTO email_templates (created_by, name, description, subject, body) + VALUES + (0, 'isis-rapid-proposal-submitted-pi', 'ISIS Rapid PI Co-I Submission Email', 'Proposal submitted', 'Proposal submitted'); + + INSERT INTO email_templates (created_by, name, description, subject, body) + VALUES + (0, 'isis-rapid-proposal-submitted-uo', 'ISIS Rapid User Office Submission Email', 'Proposal submitted', 'Proposal submitted'); + + INSERT INTO email_templates (created_by, name, description, subject, body) + VALUES + (0, 'xpress-proposal-submitted-pi', 'ISIS Xpress PI Co-I Submission Email', 'Proposal submitted', 'Proposal submitted'); + + INSERT INTO email_templates (created_by, name, description, subject, body) + VALUES + (0, 'xpress-proposal-submitted-sc', 'ISIS Xpress Scientist Submission Email', 'Proposal submitted', 'Proposal submitted'); + + INSERT INTO email_templates (created_by, name, description, subject, body) + VALUES + (0, 'xpress-proposal-under-review', 'ISIS Xpress PI Co-I Under Review Email', 'Proposal under review', 'Proposal under review'); + + INSERT INTO email_templates (created_by, name, description, subject, body) + VALUES + (0, 'xpress-proposal-approved', 'ISIS Xpress PI Co-I Approval Email', 'Proposal approved', 'Proposal approved'); + + INSERT INTO email_templates (created_by, name, description, subject, body) + VALUES + (0, 'xpress-proposal-sra', 'ISIS Xpress SRA Request Email', 'Proposal SRA request', 'Proposal SRA request'); + + INSERT INTO email_templates (created_by, name, description, subject, body) + VALUES + (0, 'xpress-proposal-unsuccessful', 'ISIS Xpress PI Co-I Reject Email', 'Proposal unsuccessful', 'Proposal unsuccessful'); + + INSERT INTO email_templates (created_by, name, description, subject, body) + VALUES + (0, 'xpress-proposal-finished', 'ISIS Xpress PI Co-I Finish Email', 'Proposal finished', 'Proposal finished'); + + FOR connection_loop_var IN + SELECT * FROM workflow_connection_has_actions + LOOP + IF connection_loop_var.config IS NOT NULL THEN + SELECT jsonb_array_length(connection_loop_var.config->'recipientsWithEmailTemplate') INTO array_size_var; + + FOR array_loop_var IN 0..array_size_var-1 LOOP + SELECT connection_loop_var.config->'recipientsWithEmailTemplate'->array_loop_var->'emailTemplate'->>'id' INTO template_name; + + IF template_name IS NOT NULL THEN + -- RAISE NOTICE 'Processing connection % % with template % to %', array_loop_var, connection_loop_var.connection_id, template_name, update_config; + + SELECT email_templates.email_template_id INTO email_template_id_var FROM email_templates WHERE name = template_name; + + IF email_template_id_var IS NOT NULL THEN + UPDATE workflow_connection_has_actions + SET config = jsonb_set(config::jsonb, ('{recipientsWithEmailTemplate, ' || array_loop_var || ', emailTemplate, id}')::text[], to_jsonb(email_template_id_var), false) + WHERE connection_id = connection_loop_var.connection_id; + -- ELSE + -- RAISE WARNING 'No email template found for name %', template_name; + END IF; + -- ELSE + -- RAISE WARNING 'No email template found for connection %', template_name; + END IF; + END LOOP; + END IF; + END LOOP; + END IF; +END; +$$ +LANGUAGE plpgsql; \ No newline at end of file From 537f4458c36098d6b48463774f6c3b690b8e3adf Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Tue, 22 Jul 2025 15:11:27 +0200 Subject: [PATCH 05/62] --amend --- .../0813_CreateEmailTemplatesTable.sql | 26 ------------------- .../src/queries/StatusActionQueries.ts | 20 +++++++------- .../src/resolvers/types/StatusActionConfig.ts | 12 +++++---- .../statusActionEngine/emailActionHandler.ts | 18 ++++++------- .../statusActionEngine/statusActionUtils.ts | 2 +- .../settings/workflow/EmailActionConfig.tsx | 7 ++--- ...gment.connectionStatusActionConfig.graphql | 4 +-- .../fragment.statusActionConfig.graphql | 4 +-- 8 files changed, 33 insertions(+), 60 deletions(-) delete mode 100644 apps/backend/db_patches/0813_CreateEmailTemplatesTable.sql diff --git a/apps/backend/db_patches/0813_CreateEmailTemplatesTable.sql b/apps/backend/db_patches/0813_CreateEmailTemplatesTable.sql deleted file mode 100644 index 6244d2ec7d..0000000000 --- a/apps/backend/db_patches/0813_CreateEmailTemplatesTable.sql +++ /dev/null @@ -1,26 +0,0 @@ -DO -$$ -BEGIN - IF register_patch('CreateEmailTemplatesTable.sql', 'Gergely Nyiri', 'Create emails table', '2025-07-15') THEN - BEGIN - - CREATE TABLE IF NOT EXISTS email_templates ( - email_template_id serial PRIMARY KEY, - created_by INT NOT NULL REFERENCES users(user_id), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - name varchar(100) NOT NULL UNIQUE, - description varchar(255) NOT NULL, - subject varchar(160) NOT NULL, - body text - ); - - -- ALTER TABLE workflow_connection_has_actions ADD COLUMN email_template_id INT; - -- ALTER TABLE workflow_connection_has_actions ADD CONSTRAINT workflow_connection_has_actions_email_template_id_fk - -- FOREIGN KEY (email_template_id) REFERENCES email_templates(email_template_id) - -- ON DELETE CASCADE; - - END; - END IF; -END; -$$ -LANGUAGE plpgsql; \ No newline at end of file diff --git a/apps/backend/src/queries/StatusActionQueries.ts b/apps/backend/src/queries/StatusActionQueries.ts index b1aa2f67ec..4ba3a4c70d 100644 --- a/apps/backend/src/queries/StatusActionQueries.ts +++ b/apps/backend/src/queries/StatusActionQueries.ts @@ -1,6 +1,7 @@ import { inject, injectable } from 'tsyringe'; import { Tokens } from '../config/Tokens'; +import { EmailTemplateDataSource } from '../datasources/EmailTemplateDataSource'; import { StatusActionsDataSource } from '../datasources/StatusActionsDataSource'; import { Authorized } from '../decorators'; import { MailService } from '../eventHandlers/MailService/MailService'; @@ -21,7 +22,9 @@ export default class StatusActionQueries { @inject(Tokens.StatusActionsDataSource) public dataSource: StatusActionsDataSource, @inject(Tokens.MailService) - public emailService: MailService + public emailService: MailService, + @inject(Tokens.EmailTemplateDataSource) + public emailTemplateDataSource: EmailTemplateDataSource ) {} @Authorized([Roles.USER_OFFICER]) @@ -59,16 +62,13 @@ export default class StatusActionQueries { description: EmailStatusActionRecipientsWithDescription.get(item), })); - const sparkPostEmailTemplates = - await this.emailService.getEmailTemplates(); + const emailTemplatesResult = + await this.emailTemplateDataSource.getEmailTemplates(); - const emailTemplates = sparkPostEmailTemplates.results.map((item) => ({ - id: item.id, - name: item.name, - emailTemplateId: 0, - })); - - return new EmailActionDefaultConfig(allEmailRecipients, emailTemplates); + return new EmailActionDefaultConfig( + allEmailRecipients, + emailTemplatesResult.emailTemplates + ); case StatusActionType.RABBITMQ: // NOTE: For now we return just the default exchange. diff --git a/apps/backend/src/resolvers/types/StatusActionConfig.ts b/apps/backend/src/resolvers/types/StatusActionConfig.ts index 9afb9e378a..a388402c50 100644 --- a/apps/backend/src/resolvers/types/StatusActionConfig.ts +++ b/apps/backend/src/resolvers/types/StatusActionConfig.ts @@ -1,5 +1,7 @@ import { Field, Int, ObjectType, createUnionType } from 'type-graphql'; +import { EmailTemplate } from './EmailTemplate'; + export enum EmailStatusActionRecipients { PI = 'PI', CO_PROPOSERS = 'CO_PROPOSERS', @@ -70,8 +72,8 @@ export class EmailStatusActionRecipientsWithTemplate { @Field(() => EmailStatusActionRecipient) recipient: EmailStatusActionRecipient; - @Field(() => EmailStatusActionEmailTemplate) - emailTemplate: EmailStatusActionEmailTemplate; + @Field(() => EmailTemplate) + emailTemplate: EmailTemplate; @Field(() => [String], { nullable: true }) otherRecipientEmails?: string[]; @@ -105,7 +107,7 @@ export const StatusActionConfig = createUnionType({ export class EmailActionDefaultConfig extends StatusActionConfigBase { constructor( recipients: EmailStatusActionRecipient[], - emailTemplates: EmailStatusActionEmailTemplate[] + emailTemplates: EmailTemplate[] ) { super(); this.recipients = recipients; @@ -115,8 +117,8 @@ export class EmailActionDefaultConfig extends StatusActionConfigBase { @Field(() => [EmailStatusActionRecipient]) recipients: EmailStatusActionRecipient[]; - @Field(() => [EmailStatusActionEmailTemplate]) - emailTemplates: EmailStatusActionEmailTemplate[]; + @Field(() => [EmailTemplate]) + emailTemplates: EmailTemplate[]; } @ObjectType() diff --git a/apps/backend/src/statusActionEngine/emailActionHandler.ts b/apps/backend/src/statusActionEngine/emailActionHandler.ts index 05c585a159..26084d2dfe 100644 --- a/apps/backend/src/statusActionEngine/emailActionHandler.ts +++ b/apps/backend/src/statusActionEngine/emailActionHandler.ts @@ -16,16 +16,16 @@ import { } from '../resolvers/types/StatusActionConfig'; import { WorkflowEngineProposalType } from '../workflowEngine'; import { + constructProposalStatusChangeEvent, EmailReadyType, getCoProposersAndFormatOutputForEmailSending, - getInstrumentScientistsAndFormatOutputForEmailSending, - getPIAndFormatOutputForEmailSending, - getFapReviewersAndFormatOutputForEmailSending, getFapChairSecretariesAndFormatOutputForEmailSending, - statusActionLogger, + getFapReviewersAndFormatOutputForEmailSending, + getInstrumentScientistsAndFormatOutputForEmailSending, getOtherAndFormatOutputForEmailSending, + getPIAndFormatOutputForEmailSending, getTechniqueScientistsAndFormatOutputForEmailSending, - constructProposalStatusChangeEvent, + statusActionLogger, } from './statusActionUtils'; export const emailActionHandler = async ( @@ -90,7 +90,7 @@ export const emailStatusActionRecipient = async ( loggedInUserId?: number | null ) => { const proposalPks = proposals.map((proposal) => proposal.primaryKey); - const templateMessage = recipientWithTemplate.emailTemplate.id; + const templateMessage = recipientWithTemplate.emailTemplate.name; const successfulMessage = !!statusActionsLogId ? 'Email successfully sent on status action replay' : 'Email successfully sent'; @@ -248,7 +248,7 @@ export const emailStatusActionRecipient = async ( id: recipientWithTemplate.recipient.name, email: userOfficeEmail, proposals: proposals, - template: recipientWithTemplate.emailTemplate.id, + template: recipientWithTemplate.emailTemplate.name, }, ]; } else { @@ -260,7 +260,7 @@ export const emailStatusActionRecipient = async ( id: recipientWithTemplate.recipient.name, email: userOfficeEmail, proposals: [proposal], - template: recipientWithTemplate.emailTemplate.id, + template: recipientWithTemplate.emailTemplate.name, instruments: await instrumentDataSource.getInstrumentsByProposalPk( proposal.primaryKey ), @@ -346,7 +346,7 @@ export const emailStatusActionRecipient = async ( id: recipientWithTemplate.recipient.name, email: experimentSafetyEmail, proposals: proposals, - template: recipientWithTemplate.emailTemplate.id, + template: recipientWithTemplate.emailTemplate.name, }, ]; } else { diff --git a/apps/backend/src/statusActionEngine/statusActionUtils.ts b/apps/backend/src/statusActionEngine/statusActionUtils.ts index 4a2f3d5d66..2718a05dd7 100644 --- a/apps/backend/src/statusActionEngine/statusActionUtils.ts +++ b/apps/backend/src/statusActionEngine/statusActionUtils.ts @@ -214,7 +214,7 @@ export const getEmailReadyArrayOfUsersAndProposals = async ( emailReadyUsersWithProposals.push({ id: recipientsWithEmailTemplate.recipient.name, proposals: [proposal], - template: recipientsWithEmailTemplate.emailTemplate.id, + template: recipientsWithEmailTemplate.emailTemplate.name, email: recipient.email, firstName: recipient.firstname, lastName: recipient.lastname, diff --git a/apps/frontend/src/components/settings/workflow/EmailActionConfig.tsx b/apps/frontend/src/components/settings/workflow/EmailActionConfig.tsx index 62cc91b619..e03862aa88 100644 --- a/apps/frontend/src/components/settings/workflow/EmailActionConfig.tsx +++ b/apps/frontend/src/components/settings/workflow/EmailActionConfig.tsx @@ -9,20 +9,21 @@ import TextField from '@mui/material/TextField'; import Tooltip from '@mui/material/Tooltip'; import Typography from '@mui/material/Typography'; import { FieldArray, FieldArrayRenderProps } from 'formik'; -import React, { useState, KeyboardEvent } from 'react'; +import { KeyboardEvent, useState } from 'react'; +import React from 'react'; import * as Yup from 'yup'; import { EmailActionConfig as EmailActionConfigType, - EmailStatusActionEmailTemplate, EmailStatusActionRecipient, EmailStatusActionRecipients, + EmailTemplate, } from 'generated/sdk'; type EmailActionConfigProps = { emailStatusActionConfig: EmailActionConfigType; recipients: EmailStatusActionRecipient[]; - emailTemplates: EmailStatusActionEmailTemplate[]; + emailTemplates: EmailTemplate[]; isRecipientRequired?: boolean; isEmailTemplateRequired?: boolean; }; diff --git a/apps/frontend/src/graphql/settings/statusActions/fragment.connectionStatusActionConfig.graphql b/apps/frontend/src/graphql/settings/statusActions/fragment.connectionStatusActionConfig.graphql index 0685d4a0b1..524dfc0081 100644 --- a/apps/frontend/src/graphql/settings/statusActions/fragment.connectionStatusActionConfig.graphql +++ b/apps/frontend/src/graphql/settings/statusActions/fragment.connectionStatusActionConfig.graphql @@ -6,9 +6,7 @@ fragment connectionStatusActionConfig on StatusActionConfig { description } emailTemplate { - id - name - emailTemplateId + ...emailTemplate } otherRecipientEmails combineEmails diff --git a/apps/frontend/src/graphql/settings/statusActions/fragment.statusActionConfig.graphql b/apps/frontend/src/graphql/settings/statusActions/fragment.statusActionConfig.graphql index a53601d8ea..df964a98e5 100644 --- a/apps/frontend/src/graphql/settings/statusActions/fragment.statusActionConfig.graphql +++ b/apps/frontend/src/graphql/settings/statusActions/fragment.statusActionConfig.graphql @@ -5,9 +5,7 @@ fragment statusActionDefaultConfig on StatusActionDefaultConfig { description } emailTemplates { - id - name - emailTemplateId + ...emailTemplate } } ... on RabbitMQActionDefaultConfig { From ac9919e80b15924f820e8626c5944aef194527ed Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Mon, 18 Aug 2025 09:52:35 +0200 Subject: [PATCH 06/62] feat: update email templates backend --- .../0185_CreateEmailTemplatesTable.sql | 24 ++++++++--------- .../MailService/EmailSettings.ts | 18 +++++++++---- .../src/resolvers/types/EmailTemplate.ts | 2 +- .../src/resolvers/types/StatusActionConfig.ts | 19 +++++--------- .../statusActionEngine/emailActionHandler.ts | 26 +++++++++++++++++++ ...gment.connectionStatusActionConfig.graphql | 3 ++- .../fragment.statusActionConfig.graphql | 4 ++- 7 files changed, 64 insertions(+), 32 deletions(-) diff --git a/apps/backend/db_patches/0185_CreateEmailTemplatesTable.sql b/apps/backend/db_patches/0185_CreateEmailTemplatesTable.sql index 86267170f7..ec782c75c5 100644 --- a/apps/backend/db_patches/0185_CreateEmailTemplatesTable.sql +++ b/apps/backend/db_patches/0185_CreateEmailTemplatesTable.sql @@ -21,47 +21,47 @@ BEGIN INSERT INTO email_templates (created_by, name, description, subject, body) VALUES - (0, 'clf-proposal-submitted-pi', 'CLF PI Co-I Submission Email', 'Proposal submitted', 'Proposal submitted'); + (0, 'CLF PI Co-I Submission Email', 'CLF PI Co-I Submission Email', 'Proposal submitted', 'Proposal submitted'); INSERT INTO email_templates (created_by, name, description, subject, body) VALUES - (0, 'isis-proposal-submitted-pi', 'ISIS PI Co-I Submission Email', 'Proposal submitted', 'Proposal submitted'); + (0, 'ISIS PI Co-I Submission Email', 'ISIS PI Co-I Submission Email', 'Proposal submitted', 'Proposal submitted'); INSERT INTO email_templates (created_by, name, description, subject, body) VALUES - (0, 'isis-rapid-proposal-submitted-pi', 'ISIS Rapid PI Co-I Submission Email', 'Proposal submitted', 'Proposal submitted'); + (0, 'ISIS Rapid PI Co-I Submission Email', 'ISIS Rapid PI Co-I Submission Email', 'Proposal submitted', 'Proposal submitted'); INSERT INTO email_templates (created_by, name, description, subject, body) VALUES - (0, 'isis-rapid-proposal-submitted-uo', 'ISIS Rapid User Office Submission Email', 'Proposal submitted', 'Proposal submitted'); + (0, 'ISIS Rapid User Office Submission Email', 'ISIS Rapid User Office Submission Email', 'Proposal submitted', 'Proposal submitted'); INSERT INTO email_templates (created_by, name, description, subject, body) VALUES - (0, 'xpress-proposal-submitted-pi', 'ISIS Xpress PI Co-I Submission Email', 'Proposal submitted', 'Proposal submitted'); + (0, 'ISIS Xpress PI Co-I Submission Email', 'ISIS Xpress PI Co-I Submission Email', 'Proposal submitted', 'Proposal submitted'); INSERT INTO email_templates (created_by, name, description, subject, body) VALUES - (0, 'xpress-proposal-submitted-sc', 'ISIS Xpress Scientist Submission Email', 'Proposal submitted', 'Proposal submitted'); + (0, 'ISIS Xpress Scientist Submission Email', 'ISIS Xpress Scientist Submission Email', 'Proposal submitted', 'Proposal submitted'); INSERT INTO email_templates (created_by, name, description, subject, body) VALUES - (0, 'xpress-proposal-under-review', 'ISIS Xpress PI Co-I Under Review Email', 'Proposal under review', 'Proposal under review'); + (0, 'ISIS Xpress PI Co-I Under Review Email', 'ISIS Xpress PI Co-I Under Review Email', 'Proposal under review', 'Proposal under review'); INSERT INTO email_templates (created_by, name, description, subject, body) VALUES - (0, 'xpress-proposal-approved', 'ISIS Xpress PI Co-I Approval Email', 'Proposal approved', 'Proposal approved'); + (0, 'ISIS Xpress PI Co-I Approval Email', 'ISIS Xpress PI Co-I Approval Email', 'Proposal approved', 'Proposal approved'); INSERT INTO email_templates (created_by, name, description, subject, body) VALUES - (0, 'xpress-proposal-sra', 'ISIS Xpress SRA Request Email', 'Proposal SRA request', 'Proposal SRA request'); + (0, 'ISIS Xpress SRA Request Email', 'ISIS Xpress SRA Request Email', 'Proposal SRA request', 'Proposal SRA request'); INSERT INTO email_templates (created_by, name, description, subject, body) VALUES - (0, 'xpress-proposal-unsuccessful', 'ISIS Xpress PI Co-I Reject Email', 'Proposal unsuccessful', 'Proposal unsuccessful'); + (0, 'ISIS Xpress PI Co-I Reject Email', 'ISIS Xpress PI Co-I Reject Email', 'Proposal unsuccessful', 'Proposal unsuccessful'); INSERT INTO email_templates (created_by, name, description, subject, body) VALUES - (0, 'xpress-proposal-finished', 'ISIS Xpress PI Co-I Finish Email', 'Proposal finished', 'Proposal finished'); + (0, 'ISIS Xpress PI Co-I Finish Email', 'ISIS Xpress PI Co-I Finish Email', 'Proposal finished', 'Proposal finished'); FOR connection_loop_var IN SELECT * FROM workflow_connection_has_actions @@ -70,7 +70,7 @@ BEGIN SELECT jsonb_array_length(connection_loop_var.config->'recipientsWithEmailTemplate') INTO array_size_var; FOR array_loop_var IN 0..array_size_var-1 LOOP - SELECT connection_loop_var.config->'recipientsWithEmailTemplate'->array_loop_var->'emailTemplate'->>'id' INTO template_name; + SELECT connection_loop_var.config->'recipientsWithEmailTemplate'->array_loop_var->'emailTemplate'->>'name' INTO template_name; IF template_name IS NOT NULL THEN -- RAISE NOTICE 'Processing connection % % with template % to %', array_loop_var, connection_loop_var.connection_id, template_name, update_config; diff --git a/apps/backend/src/eventHandlers/MailService/EmailSettings.ts b/apps/backend/src/eventHandlers/MailService/EmailSettings.ts index c470772d92..eb57fde774 100644 --- a/apps/backend/src/eventHandlers/MailService/EmailSettings.ts +++ b/apps/backend/src/eventHandlers/MailService/EmailSettings.ts @@ -148,14 +148,22 @@ export interface CreateTransmission { /** Content that will be used to construct a message */ content: | InlineContent - | { template_id: string; use_draft_template?: boolean } - | { email_rfc822: string }; + | { + template_id: string; + email_rfc822?: string; + use_draft_template?: boolean; + }; } export default interface EmailSettings extends CreateTransmission { - content: { - template_id: string; - }; + content: + | { + template_id: string; + } + | { + template_id: string; + email_rfc822: string; + }; recipients: ( | { address: string; diff --git a/apps/backend/src/resolvers/types/EmailTemplate.ts b/apps/backend/src/resolvers/types/EmailTemplate.ts index f7c2772eab..afeb08b998 100644 --- a/apps/backend/src/resolvers/types/EmailTemplate.ts +++ b/apps/backend/src/resolvers/types/EmailTemplate.ts @@ -7,7 +7,7 @@ export class EmailTemplate implements Partial { @Field(() => Int) public id: number; - @Field() + @Field(() => Int) public createdByUserId: number; @Field() diff --git a/apps/backend/src/resolvers/types/StatusActionConfig.ts b/apps/backend/src/resolvers/types/StatusActionConfig.ts index a388402c50..644d81d494 100644 --- a/apps/backend/src/resolvers/types/StatusActionConfig.ts +++ b/apps/backend/src/resolvers/types/StatusActionConfig.ts @@ -1,7 +1,5 @@ import { Field, Int, ObjectType, createUnionType } from 'type-graphql'; -import { EmailTemplate } from './EmailTemplate'; - export enum EmailStatusActionRecipients { PI = 'PI', CO_PROPOSERS = 'CO_PROPOSERS', @@ -57,14 +55,11 @@ export class EmailStatusActionRecipient { } @ObjectType() export class EmailStatusActionEmailTemplate { - @Field(() => String, { nullable: true }) - public id: string; - @Field(() => Int) - public emailTemplateId: number; + public id: number; @Field(() => String) - public name?: string; + public name: string; } @ObjectType() @@ -72,8 +67,8 @@ export class EmailStatusActionRecipientsWithTemplate { @Field(() => EmailStatusActionRecipient) recipient: EmailStatusActionRecipient; - @Field(() => EmailTemplate) - emailTemplate: EmailTemplate; + @Field(() => EmailStatusActionEmailTemplate) + emailTemplate: EmailStatusActionEmailTemplate; @Field(() => [String], { nullable: true }) otherRecipientEmails?: string[]; @@ -107,7 +102,7 @@ export const StatusActionConfig = createUnionType({ export class EmailActionDefaultConfig extends StatusActionConfigBase { constructor( recipients: EmailStatusActionRecipient[], - emailTemplates: EmailTemplate[] + emailTemplates: EmailStatusActionEmailTemplate[] ) { super(); this.recipients = recipients; @@ -117,8 +112,8 @@ export class EmailActionDefaultConfig extends StatusActionConfigBase { @Field(() => [EmailStatusActionRecipient]) recipients: EmailStatusActionRecipient[]; - @Field(() => [EmailTemplate]) - emailTemplates: EmailTemplate[]; + @Field(() => [EmailStatusActionEmailTemplate]) + emailTemplates: EmailStatusActionEmailTemplate[]; } @ObjectType() diff --git a/apps/backend/src/statusActionEngine/emailActionHandler.ts b/apps/backend/src/statusActionEngine/emailActionHandler.ts index 26084d2dfe..7b28226b78 100644 --- a/apps/backend/src/statusActionEngine/emailActionHandler.ts +++ b/apps/backend/src/statusActionEngine/emailActionHandler.ts @@ -3,6 +3,7 @@ import { container } from 'tsyringe'; import { Tokens } from '../config/Tokens'; import { AdminDataSource } from '../datasources/AdminDataSource'; +import { EmailTemplateDataSource } from '../datasources/EmailTemplateDataSource'; import { InstrumentDataSource } from '../datasources/InstrumentDataSource'; import { UserDataSource } from '../datasources/UserDataSource'; import { MailService } from '../eventHandlers/MailService/MailService'; @@ -90,6 +91,7 @@ export const emailStatusActionRecipient = async ( loggedInUserId?: number | null ) => { const proposalPks = proposals.map((proposal) => proposal.primaryKey); + const templateMessageId = recipientWithTemplate.emailTemplate.id; const templateMessage = recipientWithTemplate.emailTemplate.name; const successfulMessage = !!statusActionsLogId ? 'Email successfully sent on status action replay' @@ -116,6 +118,7 @@ export const emailStatusActionRecipient = async ( successfulMessage, failMessage, templateMessage, + templateMessageId, loggedInUserId ); @@ -139,6 +142,7 @@ export const emailStatusActionRecipient = async ( successfulMessage, failMessage, templateMessage, + templateMessageId, loggedInUserId ); @@ -163,6 +167,7 @@ export const emailStatusActionRecipient = async ( successfulMessage, failMessage, templateMessage, + templateMessageId, loggedInUserId ); @@ -187,6 +192,7 @@ export const emailStatusActionRecipient = async ( successfulMessage, failMessage, templateMessage, + templateMessageId, loggedInUserId ); @@ -212,6 +218,7 @@ export const emailStatusActionRecipient = async ( successfulMessage, failMessage, templateMessage, + templateMessageId, loggedInUserId ); @@ -287,6 +294,7 @@ export const emailStatusActionRecipient = async ( successfulMessage, failMessage, templateMessage, + templateMessageId, loggedInUserId ); @@ -312,6 +320,7 @@ export const emailStatusActionRecipient = async ( successfulMessage, failMessage, templateMessage, + templateMessageId, loggedInUserId ); @@ -371,6 +380,7 @@ export const emailStatusActionRecipient = async ( successfulMessage, failMessage, templateMessage, + templateMessageId, loggedInUserId ); @@ -404,6 +414,7 @@ export const emailStatusActionRecipient = async ( successfulMessage, failMessage, templateMessage, + templateMessageId, loggedInUserId ); } @@ -424,9 +435,13 @@ const sendMail = async ( successfulMessage: string, failMessage: string, templateMessage: string, + templateMessageId: number, loggedInUserId?: number | null ) => { const mailService = container.resolve(Tokens.MailService); + const emailTemplateDataSource = container.resolve( + Tokens.EmailTemplateDataSource + ); const loggingHandler = container.resolve< (event: ApplicationEvent) => Promise >(Tokens.LoggingHandler); @@ -441,6 +456,16 @@ const sendMail = async ( return; } + + const emailTemplate = + await emailTemplateDataSource.getEmailTemplate(templateMessageId); + + if (!emailTemplate) { + logger.logInfo( + `Could not send email(s) because the email template with id ${templateMessageId} does not exist.`, + { recipientsWithData } + ); + } try { const mailServiceResponse = await Promise.all( recipientsWithData.map(async (recipientWithData) => { @@ -448,6 +473,7 @@ const sendMail = async ( const res = await mailService.sendMail({ content: { template_id: recipientWithData.template, + email_rfc822: '', }, substitution_data: { proposals: recipientWithData.proposals, diff --git a/apps/frontend/src/graphql/settings/statusActions/fragment.connectionStatusActionConfig.graphql b/apps/frontend/src/graphql/settings/statusActions/fragment.connectionStatusActionConfig.graphql index 524dfc0081..e2cb0e9669 100644 --- a/apps/frontend/src/graphql/settings/statusActions/fragment.connectionStatusActionConfig.graphql +++ b/apps/frontend/src/graphql/settings/statusActions/fragment.connectionStatusActionConfig.graphql @@ -6,7 +6,8 @@ fragment connectionStatusActionConfig on StatusActionConfig { description } emailTemplate { - ...emailTemplate + id + name } otherRecipientEmails combineEmails diff --git a/apps/frontend/src/graphql/settings/statusActions/fragment.statusActionConfig.graphql b/apps/frontend/src/graphql/settings/statusActions/fragment.statusActionConfig.graphql index df964a98e5..bc7f58679f 100644 --- a/apps/frontend/src/graphql/settings/statusActions/fragment.statusActionConfig.graphql +++ b/apps/frontend/src/graphql/settings/statusActions/fragment.statusActionConfig.graphql @@ -4,8 +4,10 @@ fragment statusActionDefaultConfig on StatusActionDefaultConfig { name description } + emailTemplates { - ...emailTemplate + id + name } } ... on RabbitMQActionDefaultConfig { From 2ed631c3408b334c19e690004e219160db2ec672 Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Thu, 21 Aug 2025 09:07:28 +0200 Subject: [PATCH 07/62] feat: update graphql --- .../src/resolvers/types/EmailTemplate.ts | 8 +++--- .../src/resolvers/types/StatusActionConfig.ts | 28 ++++++++++--------- ...gment.connectionStatusActionConfig.graphql | 4 +++ .../fragment.statusActionConfig.graphql | 4 +++ 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/apps/backend/src/resolvers/types/EmailTemplate.ts b/apps/backend/src/resolvers/types/EmailTemplate.ts index afeb08b998..f3dc0d7e99 100644 --- a/apps/backend/src/resolvers/types/EmailTemplate.ts +++ b/apps/backend/src/resolvers/types/EmailTemplate.ts @@ -10,15 +10,15 @@ export class EmailTemplate implements Partial { @Field(() => Int) public createdByUserId: number; - @Field() + @Field(() => String) public name: string; - @Field({ nullable: true }) + @Field(() => String, { nullable: true }) public description: string; - @Field() + @Field(() => String) public subject: string; - @Field() + @Field(() => String) public body: string; } diff --git a/apps/backend/src/resolvers/types/StatusActionConfig.ts b/apps/backend/src/resolvers/types/StatusActionConfig.ts index 644d81d494..b7c99f23ba 100644 --- a/apps/backend/src/resolvers/types/StatusActionConfig.ts +++ b/apps/backend/src/resolvers/types/StatusActionConfig.ts @@ -1,4 +1,6 @@ -import { Field, Int, ObjectType, createUnionType } from 'type-graphql'; +import { Field, ObjectType, createUnionType } from 'type-graphql'; + +import { EmailTemplate } from './EmailTemplate'; export enum EmailStatusActionRecipients { PI = 'PI', @@ -53,22 +55,22 @@ export class EmailStatusActionRecipient { @Field(() => String, { nullable: true }) public description?: string; } -@ObjectType() -export class EmailStatusActionEmailTemplate { - @Field(() => Int) - public id: number; +// @ObjectType() +// export class EmailStatusActionEmailTemplate { +// @Field(() => Int) +// public id: number; - @Field(() => String) - public name: string; -} +// @Field(() => String) +// public name: string; +// } @ObjectType() export class EmailStatusActionRecipientsWithTemplate { @Field(() => EmailStatusActionRecipient) recipient: EmailStatusActionRecipient; - @Field(() => EmailStatusActionEmailTemplate) - emailTemplate: EmailStatusActionEmailTemplate; + @Field(() => EmailTemplate) + emailTemplate: EmailTemplate; @Field(() => [String], { nullable: true }) otherRecipientEmails?: string[]; @@ -102,7 +104,7 @@ export const StatusActionConfig = createUnionType({ export class EmailActionDefaultConfig extends StatusActionConfigBase { constructor( recipients: EmailStatusActionRecipient[], - emailTemplates: EmailStatusActionEmailTemplate[] + emailTemplates: EmailTemplate[] ) { super(); this.recipients = recipients; @@ -112,8 +114,8 @@ export class EmailActionDefaultConfig extends StatusActionConfigBase { @Field(() => [EmailStatusActionRecipient]) recipients: EmailStatusActionRecipient[]; - @Field(() => [EmailStatusActionEmailTemplate]) - emailTemplates: EmailStatusActionEmailTemplate[]; + @Field(() => [EmailTemplate]) + emailTemplates: EmailTemplate[]; } @ObjectType() diff --git a/apps/frontend/src/graphql/settings/statusActions/fragment.connectionStatusActionConfig.graphql b/apps/frontend/src/graphql/settings/statusActions/fragment.connectionStatusActionConfig.graphql index e2cb0e9669..83eb849f48 100644 --- a/apps/frontend/src/graphql/settings/statusActions/fragment.connectionStatusActionConfig.graphql +++ b/apps/frontend/src/graphql/settings/statusActions/fragment.connectionStatusActionConfig.graphql @@ -8,6 +8,10 @@ fragment connectionStatusActionConfig on StatusActionConfig { emailTemplate { id name + createdByUserId + description + subject + body } otherRecipientEmails combineEmails diff --git a/apps/frontend/src/graphql/settings/statusActions/fragment.statusActionConfig.graphql b/apps/frontend/src/graphql/settings/statusActions/fragment.statusActionConfig.graphql index bc7f58679f..737a11dfb9 100644 --- a/apps/frontend/src/graphql/settings/statusActions/fragment.statusActionConfig.graphql +++ b/apps/frontend/src/graphql/settings/statusActions/fragment.statusActionConfig.graphql @@ -8,6 +8,10 @@ fragment statusActionDefaultConfig on StatusActionDefaultConfig { emailTemplates { id name + createdByUserId + description + subject + body } } ... on RabbitMQActionDefaultConfig { From 0301e943d840d88b239894c048e9bd92da9bed1d Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Mon, 25 Aug 2025 15:15:43 +0200 Subject: [PATCH 08/62] feat: add validation for email template delete --- .../datasources/StatusActionsDataSource.ts | 4 ++++ .../mockups/StatusActionsDataSource.ts | 6 ++++++ .../postgres/StatusActionsDataSource.ts | 15 +++++++++++++- .../src/mutations/EmailTemplateMutations.ts | 17 +++++++++++++++- .../src/resolvers/types/StatusActionConfig.ts | 20 +++++++++---------- 5 files changed, 50 insertions(+), 12 deletions(-) diff --git a/apps/backend/src/datasources/StatusActionsDataSource.ts b/apps/backend/src/datasources/StatusActionsDataSource.ts index 5419a86f18..c73a623bbd 100644 --- a/apps/backend/src/datasources/StatusActionsDataSource.ts +++ b/apps/backend/src/datasources/StatusActionsDataSource.ts @@ -13,6 +13,10 @@ export interface StatusActionsDataSource { workflowConnectionId: number, statusActionId: number ): Promise; + hasEmailTemplateIdConnectionStatusAction( + emailTemplateId: number + ): Promise; + updateConnectionStatusAction( data: ConnectionHasStatusAction ): Promise; diff --git a/apps/backend/src/datasources/mockups/StatusActionsDataSource.ts b/apps/backend/src/datasources/mockups/StatusActionsDataSource.ts index faa58b8c3e..7b78d90e92 100644 --- a/apps/backend/src/datasources/mockups/StatusActionsDataSource.ts +++ b/apps/backend/src/datasources/mockups/StatusActionsDataSource.ts @@ -47,6 +47,12 @@ export class StatusActionsDataSourceMock implements StatusActionsDataSource { return [dummyConnectionHasStatusAction]; } + async hasEmailTemplateIdConnectionStatusAction( + emailTemplateId: number + ): Promise { + return false; + } + async updateConnectionStatusAction( statusAction: ConnectionHasStatusAction ): Promise { diff --git a/apps/backend/src/datasources/postgres/StatusActionsDataSource.ts b/apps/backend/src/datasources/postgres/StatusActionsDataSource.ts index d210940fea..80b5b2a06a 100644 --- a/apps/backend/src/datasources/postgres/StatusActionsDataSource.ts +++ b/apps/backend/src/datasources/postgres/StatusActionsDataSource.ts @@ -1,3 +1,4 @@ +/* eslint-disable quotes */ import { logger } from '@user-office-software/duo-logger'; import { GraphQLError } from 'graphql'; import { inject, injectable } from 'tsyringe'; @@ -12,8 +13,8 @@ import { import { AddConnectionStatusActionsInput } from '../../resolvers/mutations/settings/AddConnectionStatusActionsMutation'; import { EmailActionConfig, - StatusActionConfig, RabbitMQActionConfig, + StatusActionConfig, } from '../../resolvers/types/StatusActionConfig'; import { StatusActionsDataSource } from '../StatusActionsDataSource'; import { WorkflowDataSource } from '../WorkflowDataSource'; @@ -130,6 +131,18 @@ export default class PostgresStatusActionsDataSource return this.createConnectionStatusActionObject(statusActionRecord); } + async hasEmailTemplateIdConnectionStatusAction( + emailTemplateId: number + ): Promise { + const fromClause = "config->'recipientsWithEmailTemplate'"; + const pattern = `\'[{"emailTemplate": {"id": ${emailTemplateId}}}]\'`; + const countResult = await database.raw( + `select count(*) from workflow_connection_has_actions where ${fromClause} @> ${pattern}` + ); + + return Number(countResult.rows[0].count) > 0; + } + async updateConnectionStatusAction( statusAction: ConnectionHasStatusAction ): Promise { diff --git a/apps/backend/src/mutations/EmailTemplateMutations.ts b/apps/backend/src/mutations/EmailTemplateMutations.ts index e31da9bf29..2999eba1a1 100644 --- a/apps/backend/src/mutations/EmailTemplateMutations.ts +++ b/apps/backend/src/mutations/EmailTemplateMutations.ts @@ -3,6 +3,7 @@ import { Authorized } from 'type-graphql'; import { Tokens } from '../config/Tokens'; import { EmailTemplateDataSource } from '../datasources/EmailTemplateDataSource'; +import StatusActionsDataSource from '../datasources/postgres/StatusActionsDataSource'; import { EmailTemplate } from '../models/EmailTemplate'; import { Rejection, rejection } from '../models/Rejection'; import { Roles } from '../models/Role'; @@ -14,7 +15,9 @@ import { UpdateEmailTemplateInput } from '../resolvers/mutations/UpdateEmailTemp export default class EmailTemplateMutations { constructor( @inject(Tokens.EmailTemplateDataSource) - private dataSource: EmailTemplateDataSource + private dataSource: EmailTemplateDataSource, + @inject(Tokens.StatusActionsDataSource) + private statusActionsDataSource: StatusActionsDataSource ) {} @Authorized([Roles.USER_OFFICER]) @@ -68,6 +71,18 @@ export default class EmailTemplateMutations { agent: UserWithRole | null, { emailTemplateId }: { emailTemplateId: number } ): Promise { + const has = + await this.statusActionsDataSource.hasEmailTemplateIdConnectionStatusAction( + emailTemplateId + ); + + if (has) { + return rejection( + 'Could not delete email template (used in status actions)', + { emailTemplateId } + ); + } + const emailTemplate = await this.dataSource.getEmailTemplate(emailTemplateId); diff --git a/apps/backend/src/resolvers/types/StatusActionConfig.ts b/apps/backend/src/resolvers/types/StatusActionConfig.ts index b7c99f23ba..9034c14762 100644 --- a/apps/backend/src/resolvers/types/StatusActionConfig.ts +++ b/apps/backend/src/resolvers/types/StatusActionConfig.ts @@ -1,4 +1,4 @@ -import { Field, ObjectType, createUnionType } from 'type-graphql'; +import { Field, Int, ObjectType, createUnionType } from 'type-graphql'; import { EmailTemplate } from './EmailTemplate'; @@ -55,22 +55,22 @@ export class EmailStatusActionRecipient { @Field(() => String, { nullable: true }) public description?: string; } -// @ObjectType() -// export class EmailStatusActionEmailTemplate { -// @Field(() => Int) -// public id: number; +@ObjectType() +export class EmailStatusActionEmailTemplate { + @Field(() => Int) + public id: number; -// @Field(() => String) -// public name: string; -// } + @Field(() => String) + public name: string; +} @ObjectType() export class EmailStatusActionRecipientsWithTemplate { @Field(() => EmailStatusActionRecipient) recipient: EmailStatusActionRecipient; - @Field(() => EmailTemplate) - emailTemplate: EmailTemplate; + @Field(() => EmailStatusActionEmailTemplate) + emailTemplate: EmailStatusActionEmailTemplate; @Field(() => [String], { nullable: true }) otherRecipientEmails?: string[]; From 9828a7e841b7ef4a651e6fbfdff28918112fdb72 Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Mon, 1 Sep 2025 09:04:30 +0200 Subject: [PATCH 09/62] feat: start backend test implementation --- .../0185_CreateEmailTemplatesTable.sql | 2 +- .../mockups/EmailTemplateDataSource.ts | 18 ++++++++++++ .../src/eventHandlers/messageBroker.ts | 12 ++++++++ apps/backend/src/events/applicationEvents.ts | 21 +++++++++++++- apps/backend/src/events/event.enum.ts | 6 ++++ .../mutations/EmailTemplateMutation.spec.ts | 28 +++++++++++++++++++ .../src/mutations/EmailTemplateMutations.ts | 3 ++ ...gment.connectionStatusActionConfig.graphql | 4 --- .../fragment.statusActionConfig.graphql | 7 +---- 9 files changed, 89 insertions(+), 12 deletions(-) create mode 100644 apps/backend/src/mutations/EmailTemplateMutation.spec.ts diff --git a/apps/backend/db_patches/0185_CreateEmailTemplatesTable.sql b/apps/backend/db_patches/0185_CreateEmailTemplatesTable.sql index ec782c75c5..91256d652b 100644 --- a/apps/backend/db_patches/0185_CreateEmailTemplatesTable.sql +++ b/apps/backend/db_patches/0185_CreateEmailTemplatesTable.sql @@ -7,7 +7,7 @@ DECLARE array_loop_var int; template_name varchar(200); BEGIN - IF register_patch('CreateEmailTemplatesTable.sql', 'Gergely Nyiri', 'Create emails table', '2025-07-15') THEN + IF register_patch('CreateEmailTemplatesTable.sql', 'Gergely Nyiri', 'Create email templates table', '2025-07-15') THEN CREATE TABLE IF NOT EXISTS email_templates ( email_template_id serial PRIMARY KEY, diff --git a/apps/backend/src/datasources/mockups/EmailTemplateDataSource.ts b/apps/backend/src/datasources/mockups/EmailTemplateDataSource.ts index 3daaba30d3..3a19a2d11e 100644 --- a/apps/backend/src/datasources/mockups/EmailTemplateDataSource.ts +++ b/apps/backend/src/datasources/mockups/EmailTemplateDataSource.ts @@ -13,6 +13,24 @@ export const dummyEmailTemplate = { }; export class EmailTemplateDataSourceMock implements EmailTemplateDataSource { + emailTemplates: EmailTemplate[]; + constructor() { + this.init(); + } + + public init() { + this.emailTemplates = [ + new EmailTemplate( + 1, + 1, + 'Dummy Email Template', + 'This is a dummy email template for testing purposes.', + 'Welcome to Our Service', + 'Hello, thank you for signing up for our service. We are excited to have you on board!' + ), + ]; + } + async delete(id: number): Promise { return dummyEmailTemplate; } diff --git a/apps/backend/src/eventHandlers/messageBroker.ts b/apps/backend/src/eventHandlers/messageBroker.ts index 33fc0888af..0929a95053 100644 --- a/apps/backend/src/eventHandlers/messageBroker.ts +++ b/apps/backend/src/eventHandlers/messageBroker.ts @@ -272,6 +272,18 @@ export async function createPostToRabbitMQHandler() { ); break; } + case Event.EMAIL_TEMPLATE_CREATED: + case Event.EMAIL_TEMPLATE_UPDATED: + case Event.EMAIL_TEMPLATE_DELETED: { + const jsonMessage = JSON.stringify(event.emailTemplate); + + await rabbitMQ.sendMessageToExchange( + EXCHANGE_NAME, + event.type, + jsonMessage + ); + break; + } case Event.TOPIC_ANSWERED: { const proposal = await proposalDataSource.getProposals({ questionaryIds: event.array.map((a) => a.questionaryId), diff --git a/apps/backend/src/events/applicationEvents.ts b/apps/backend/src/events/applicationEvents.ts index 3e77c84eda..d53ea465ad 100644 --- a/apps/backend/src/events/applicationEvents.ts +++ b/apps/backend/src/events/applicationEvents.ts @@ -1,4 +1,5 @@ import { Call } from '../models/Call'; +import { EmailTemplate } from '../models/EmailTemplate'; import { ExperimentSafety } from '../models/Experiment'; import { Fap, FapProposal } from '../models/Fap'; import { FapMeetingDecision } from '../models/FapMeetingDecision'; @@ -443,6 +444,21 @@ interface ExperimentSafetyStatusChangedByUserEvent extends GeneralEvent { experimentsafety: ExperimentSafety; } +interface EmailTemplateCreatedEvent extends GeneralEvent { + type: Event.EMAIL_TEMPLATE_CREATED; + emailTemplate: EmailTemplate; +} + +interface EmailTemplateUpdatedEvent extends GeneralEvent { + type: Event.EMAIL_TEMPLATE_UPDATED; + emailTemplate: EmailTemplate; +} + +interface EmailTemplateDeletedEvent extends GeneralEvent { + type: Event.EMAIL_TEMPLATE_DELETED; + emailTemplate: EmailTemplate; +} + export type ApplicationEvent = | ProposalAcceptedEvent | ProposalUpdatedEvent @@ -525,4 +541,7 @@ export type ApplicationEvent = | ExperimentSafetyManagementDecisionSubmittedByISEvent | ExperimentSafetyManagementDecisionSubmittedByESREvent | ExperimentSafetyStatusChangedByWorkflowEvent - | ExperimentSafetyStatusChangedByUserEvent; + | ExperimentSafetyStatusChangedByUserEvent + | EmailTemplateCreatedEvent + | EmailTemplateUpdatedEvent + | EmailTemplateDeletedEvent; diff --git a/apps/backend/src/events/event.enum.ts b/apps/backend/src/events/event.enum.ts index 9aa6a5b550..bb189b7e60 100644 --- a/apps/backend/src/events/event.enum.ts +++ b/apps/backend/src/events/event.enum.ts @@ -95,6 +95,9 @@ export enum Event { EXPERIMENT_SAFETY_MANAGEMENT_DECISION_SUBMITTED_BY_ESR = 'EXPERIMENT_SAFETY_MANAGEMENT_DECISION_SUBMITTED_BY_ESR', EXPERIMENT_SAFETY_STATUS_CHANGED_BY_USER = 'EXPERIMENT_SAFETY_STATUS_CHANGED_BY_USER', EXPERIMENT_SAFETY_STATUS_CHANGED_BY_WORKFLOW = 'EXPERIMENT_SAFETY_STATUS_CHANGED_BY_WORKFLOW', + EMAIL_TEMPLATE_CREATED = 'EMAIL_TEMPLATE_CREATED', + EMAIL_TEMPLATE_UPDATED = 'EMAIL_TEMPLATE_UPDATED', + EMAIL_TEMPLATE_DELETED = 'EMAIL_TEMPLATE_DELETED', } export const EventLabel = new Map([ @@ -394,4 +397,7 @@ export const EventLabel = new Map([ Event.EXPERIMENT_SAFETY_STATUS_CHANGED_BY_WORKFLOW, 'Event occurs when experiment safety status is changed by workflow', ], + [Event.EMAIL_TEMPLATE_CREATED, 'Event occurs when email template is created'], + [Event.EMAIL_TEMPLATE_UPDATED, 'Event occurs when email template is updated'], + [Event.EMAIL_TEMPLATE_DELETED, 'Event occurs when email template is deleted'], ]); diff --git a/apps/backend/src/mutations/EmailTemplateMutation.spec.ts b/apps/backend/src/mutations/EmailTemplateMutation.spec.ts new file mode 100644 index 0000000000..eee3d29de5 --- /dev/null +++ b/apps/backend/src/mutations/EmailTemplateMutation.spec.ts @@ -0,0 +1,28 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../config/Tokens'; +import { EmailTemplateDataSourceMock } from '../datasources/mockups/EmailTemplateDataSource'; +import { dummyUserWithRole } from '../datasources/mockups/UserDataSource'; +import EmailTemplateMutations from './EmailTemplateMutations'; + +const emailTemplateMutations = container.resolve(EmailTemplateMutations); + +beforeEach(() => { + container + .resolve(Tokens.EmailTemplateDataSource) + .init(); +}); + +describe('Test Email Template Mutations', () => { + test('A user can not create an email template', () => { + return expect( + emailTemplateMutations.create(dummyUserWithRole, { + name: 'Test Email Template', + description: 'Test email template description', + createdByUserId: 1, + subject: 'Test email template subject', + body: 'Test email template body', + }) + ).resolves.toHaveProperty('reason', 'INSUFFICIENT_PERMISSIONS'); + }); +}); diff --git a/apps/backend/src/mutations/EmailTemplateMutations.ts b/apps/backend/src/mutations/EmailTemplateMutations.ts index 2999eba1a1..30e47d53e2 100644 --- a/apps/backend/src/mutations/EmailTemplateMutations.ts +++ b/apps/backend/src/mutations/EmailTemplateMutations.ts @@ -4,6 +4,8 @@ import { Authorized } from 'type-graphql'; import { Tokens } from '../config/Tokens'; import { EmailTemplateDataSource } from '../datasources/EmailTemplateDataSource'; import StatusActionsDataSource from '../datasources/postgres/StatusActionsDataSource'; +import { EventBus } from '../decorators'; +import { Event } from '../events/event.enum'; import { EmailTemplate } from '../models/EmailTemplate'; import { Rejection, rejection } from '../models/Rejection'; import { Roles } from '../models/Role'; @@ -20,6 +22,7 @@ export default class EmailTemplateMutations { private statusActionsDataSource: StatusActionsDataSource ) {} + @EventBus(Event.EMAIL_TEMPLATE_CREATED) @Authorized([Roles.USER_OFFICER]) async create( agent: UserWithRole | null, diff --git a/apps/frontend/src/graphql/settings/statusActions/fragment.connectionStatusActionConfig.graphql b/apps/frontend/src/graphql/settings/statusActions/fragment.connectionStatusActionConfig.graphql index 83eb849f48..e2cb0e9669 100644 --- a/apps/frontend/src/graphql/settings/statusActions/fragment.connectionStatusActionConfig.graphql +++ b/apps/frontend/src/graphql/settings/statusActions/fragment.connectionStatusActionConfig.graphql @@ -8,10 +8,6 @@ fragment connectionStatusActionConfig on StatusActionConfig { emailTemplate { id name - createdByUserId - description - subject - body } otherRecipientEmails combineEmails diff --git a/apps/frontend/src/graphql/settings/statusActions/fragment.statusActionConfig.graphql b/apps/frontend/src/graphql/settings/statusActions/fragment.statusActionConfig.graphql index 737a11dfb9..3829517624 100644 --- a/apps/frontend/src/graphql/settings/statusActions/fragment.statusActionConfig.graphql +++ b/apps/frontend/src/graphql/settings/statusActions/fragment.statusActionConfig.graphql @@ -6,12 +6,7 @@ fragment statusActionDefaultConfig on StatusActionDefaultConfig { } emailTemplates { - id - name - createdByUserId - description - subject - body + ...emailTemplate } } ... on RabbitMQActionDefaultConfig { From 8ac3a812b47531511839c91aa5dec48e75db30b4 Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Tue, 2 Sep 2025 11:40:23 +0200 Subject: [PATCH 10/62] feat: add backend tests --- .../datasources/EmailTemplateDataSource.ts | 1 + .../mockups/EmailTemplateDataSource.ts | 6 +- .../postgres/EmailTemplateDataSource.ts | 11 + .../MailService/EmailSettings.ts | 2 +- .../email/eliEmailHandler.spec.ts | 20 +- .../eventHandlers/email/eliEmailHandler.ts | 536 +++++++++--------- .../eventHandlers/email/essEmailHandler.ts | 6 +- .../mutations/EmailTemplateMutation.spec.ts | 76 ++- .../src/mutations/EmailTemplateMutations.ts | 5 +- .../statusActionEngine/emailActionHandler.ts | 2 +- 10 files changed, 365 insertions(+), 300 deletions(-) diff --git a/apps/backend/src/datasources/EmailTemplateDataSource.ts b/apps/backend/src/datasources/EmailTemplateDataSource.ts index 05d73fdad1..96f5c58f4d 100644 --- a/apps/backend/src/datasources/EmailTemplateDataSource.ts +++ b/apps/backend/src/datasources/EmailTemplateDataSource.ts @@ -3,6 +3,7 @@ import { EmailTemplatesFilter } from '../resolvers/queries/EmailTemplatesQuery'; export interface EmailTemplateDataSource { getEmailTemplate(id: number): Promise; + getEmailTemplateByName(name: string): Promise; getEmailTemplates( filter?: EmailTemplatesFilter ): Promise<{ totalCount: number; emailTemplates: EmailTemplate[] }>; diff --git a/apps/backend/src/datasources/mockups/EmailTemplateDataSource.ts b/apps/backend/src/datasources/mockups/EmailTemplateDataSource.ts index 3a19a2d11e..140bebf0d7 100644 --- a/apps/backend/src/datasources/mockups/EmailTemplateDataSource.ts +++ b/apps/backend/src/datasources/mockups/EmailTemplateDataSource.ts @@ -9,7 +9,7 @@ export const dummyEmailTemplate = { description: 'This is a dummy email template for testing purposes.', subject: 'Welcome to Our Service', body: 'Hello, thank you for signing up for our service. We are excited to have you on board!', - createdAt: new Date().toISOString(), + createdAt: '', }; export class EmailTemplateDataSourceMock implements EmailTemplateDataSource { @@ -44,6 +44,10 @@ export class EmailTemplateDataSourceMock implements EmailTemplateDataSource { return dummyEmailTemplate; } + async getEmailTemplateByName(name: string): Promise { + return dummyEmailTemplate; + } + async create( createdByUserId: number, name: string, diff --git a/apps/backend/src/datasources/postgres/EmailTemplateDataSource.ts b/apps/backend/src/datasources/postgres/EmailTemplateDataSource.ts index 8f7c7d6320..a5c37dc4ff 100644 --- a/apps/backend/src/datasources/postgres/EmailTemplateDataSource.ts +++ b/apps/backend/src/datasources/postgres/EmailTemplateDataSource.ts @@ -22,6 +22,17 @@ export default class PostgresEmailTemplateDataSource }); } + async getEmailTemplateByName(name: string): Promise { + return database + .select() + .from('email_templates') + .where('name', name) + .first() + .then((emailTemplate: EmailTemplateRecord) => { + return emailTemplate ? createEmailTemplateObject(emailTemplate) : null; + }); + } + async getEmailTemplates( filter?: EmailTemplatesFilter ): Promise<{ totalCount: number; emailTemplates: EmailTemplate[] }> { diff --git a/apps/backend/src/eventHandlers/MailService/EmailSettings.ts b/apps/backend/src/eventHandlers/MailService/EmailSettings.ts index 2cd4b5676a..343e57e83a 100644 --- a/apps/backend/src/eventHandlers/MailService/EmailSettings.ts +++ b/apps/backend/src/eventHandlers/MailService/EmailSettings.ts @@ -149,7 +149,7 @@ export interface CreateTransmission { content: | InlineContent | { - template_id: string; + template_id?: string; db_template_id?: number; email_rfc822?: string; use_draft_template?: boolean; diff --git a/apps/backend/src/eventHandlers/email/eliEmailHandler.spec.ts b/apps/backend/src/eventHandlers/email/eliEmailHandler.spec.ts index b73da34b6e..6982b9fb38 100644 --- a/apps/backend/src/eventHandlers/email/eliEmailHandler.spec.ts +++ b/apps/backend/src/eventHandlers/email/eliEmailHandler.spec.ts @@ -1,5 +1,5 @@ -import 'reflect-metadata'; import { faker } from '@faker-js/faker'; +import 'reflect-metadata'; import { container } from 'tsyringe'; import { Tokens } from '../../config/Tokens'; @@ -131,6 +131,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { + db_template_id: 1, template_id: 'proposal-created', }, }) @@ -160,7 +161,8 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - template_id: 'Accepted-Proposal', + db_template_id: 1, + template_id: 'proposal-accepted', }, }) ); @@ -189,7 +191,8 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - template_id: 'Rejected-Proposal', + db_template_id: 1, + template_id: 'proposal-rejected', }, }) ); @@ -218,13 +221,14 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - template_id: 'Reserved-Proposal', + db_template_id: 1, + template_id: 'proposal-reserved', }, }) ); }); - test('should use template review-reminder', async () => { + test('should use template reviewer-reminder', async () => { // Create a mock event for FAP_REVIEWER_NOTIFIED const mockEvent: ApplicationEvent = { type: Event.FAP_REVIEWER_NOTIFIED, @@ -243,7 +247,8 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - template_id: 'review-reminder', + db_template_id: 1, + template_id: 'reviewer-reminder', }, }) ); @@ -276,6 +281,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { + db_template_id: 1, template_id: 'internal-review-created', }, }) @@ -309,6 +315,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { + db_template_id: 1, template_id: 'internal-review-updated', }, }) @@ -342,6 +349,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { + db_template_id: 1, template_id: 'internal-review-deleted', }, }) diff --git a/apps/backend/src/eventHandlers/email/eliEmailHandler.ts b/apps/backend/src/eventHandlers/email/eliEmailHandler.ts index a273413ff4..0f9e390131 100644 --- a/apps/backend/src/eventHandlers/email/eliEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/eliEmailHandler.ts @@ -3,6 +3,7 @@ import { container } from 'tsyringe'; import { Tokens } from '../../config/Tokens'; import { CallDataSource } from '../../datasources/CallDataSource'; +import { EmailTemplateDataSource } from '../../datasources/EmailTemplateDataSource'; import { FapDataSource } from '../../datasources/FapDataSource'; import { InviteDataSource } from '../../datasources/InviteDataSource'; import { ProposalDataSource } from '../../datasources/ProposalDataSource'; @@ -12,7 +13,9 @@ import { RoleClaimDataSource } from '../../datasources/RoleClaimDataSource'; import { UserDataSource } from '../../datasources/UserDataSource'; import { ApplicationEvent } from '../../events/applicationEvents'; import { Event } from '../../events/event.enum'; +import { ProposalEndStatus } from '../../models/Proposal'; import { UserRole } from '../../models/User'; +import EmailSettings from '../MailService/EmailSettings'; import { MailService } from '../MailService/MailService'; export async function eliEmailHandler(event: ApplicationEvent) { @@ -45,6 +48,10 @@ export async function eliEmailHandler(event: ApplicationEvent) { Tokens.ReviewDataSource ); + const emailTemplateDataSource = container.resolve( + Tokens.EmailTemplateDataSource + ); + if (event.isRejection) { return; } @@ -78,13 +85,19 @@ export async function eliEmailHandler(event: ApplicationEvent) { return; } + const templateId = + event.emailinviteresponse.role === UserRole.USER + ? 'user-office-registration-invitation' + : 'user-office-registration-invitation-reviewer'; + + const emailTemplate = + await emailTemplateDataSource.getEmailTemplateByName(templateId); + mailService .sendMail({ content: { - template_id: - event.emailinviteresponse.role === UserRole.USER - ? 'user-office-registration-invitation' - : 'user-office-registration-invitation-reviewer', + template_id: templateId, + db_template_id: emailTemplate ? emailTemplate.id : undefined, }, substitution_data: { firstname: user.preferredname, @@ -140,10 +153,14 @@ export async function eliEmailHandler(event: ApplicationEvent) { const templateId = getTemplateIdForRole(roleInviteClaim[0].roleId); + const emailTemplate = + await emailTemplateDataSource.getEmailTemplateByName(templateId); + mailService .sendMail({ content: { template_id: templateId, + db_template_id: emailTemplate ? emailTemplate.id : undefined, }, substitution_data: { email: invite.email, @@ -174,282 +191,241 @@ export async function eliEmailHandler(event: ApplicationEvent) { return; } - // case Event.PROPOSAL_CREATED: { - // const principalInvestigator = await userDataSource.getUser( - // event.proposal.proposerId - // ); - - // const call = await callDataSource.getCall(event.proposal.callId); - - // if (!principalInvestigator || !call) { - // return; - // } - - // const options: EmailSettings = { - // content: { - // template_id: 'proposal-created', - // }, - // substitution_data: { - // piPreferredname: principalInvestigator.preferredname, - // piLastname: principalInvestigator.lastname, - // proposalNumber: event.proposal.proposalId, - // proposalTitle: event.proposal.title, - // callShortCode: call.shortCode, - // }, - // recipients: [{ address: principalInvestigator.email }], - // }; - - // mailService - // .sendMail(options) - // .then((res: any) => { - // logger.logInfo('Emails sent on proposal submission:', { - // result: res, - // event, - // }); - // }) - // .catch((err: string) => { - // logger.logError('Could not send email(s) on proposal submission:', { - // error: err, - // event, - // }); - // }); - - // return; - // } - - // case Event.PROPOSAL_SUBMITTED: { - // const principalInvestigator = await userDataSource.getUser( - // event.proposal.proposerId - // ); - // const participants = await userDataSource.getProposalUsersFull( - // event.proposal.primaryKey - // ); - // if (!principalInvestigator) { - // return; - // } - - // const options: EmailSettings = { - // content: { - // template_id: 'proposal-submitted', - // }, - // substitution_data: { - // piPreferredname: principalInvestigator.preferredname, - // piLastname: principalInvestigator.lastname, - // proposalNumber: event.proposal.proposalId, - // proposalTitle: event.proposal.title, - // coProposers: participants.map( - // (partipant) => `${partipant.preferredname} ${partipant.lastname} ` - // ), - // call: '', - // }, - // recipients: [ - // { address: principalInvestigator.email }, - // ...participants.map((partipant) => { - // return { - // address: { - // email: partipant.email, - // header_to: principalInvestigator.email, - // }, - // }; - // }), - // ], - // }; - - // mailService - // .sendMail(options) - // .then((res: any) => { - // logger.logInfo('Emails sent on proposal submission:', { - // result: res, - // event, - // }); - // }) - // .catch((err: string) => { - // logger.logError('Could not send email(s) on proposal submission:', { - // error: err, - // event, - // }); - // }); - - // return; - // } - - // case Event.PROPOSAL_NOTIFIED: { - // const principalInvestigator = await userDataSource.getUser( - // event.proposal.proposerId - // ); - // if (!principalInvestigator) { - // return; - // } - // const { finalStatus } = event.proposal; - // let templateId = ''; - // if (finalStatus === ProposalEndStatus.ACCEPTED) { - // templateId = 'Accepted-Proposal'; - // } else if (finalStatus === ProposalEndStatus.REJECTED) { - // templateId = 'Rejected-Proposal'; - // } else if (finalStatus === ProposalEndStatus.RESERVED) { - // templateId = 'Reserved-Proposal'; - // } else { - // logger.logError('Failed email notification', { event }); - - // return; - // } - - // mailService - // .sendMail({ - // content: { - // template_id: templateId, - // }, - // substitution_data: { - // piPreferredname: principalInvestigator.preferredname, - // piLastname: principalInvestigator.lastname, - // proposalNumber: event.proposal.proposalId, - // proposalTitle: event.proposal.title, - // commentForUser: event.proposal.commentForUser, - // }, - // recipients: [{ address: principalInvestigator.email }], - // }) - // .then((res: any) => { - // logger.logInfo('Email sent on proposal notify:', { - // result: res, - // event, - // }); - // }) - // .catch((err: string) => { - // logger.logError('Could not send email on proposal notify:', { - // error: err, - // event, - // }); - // }); - - // return; - // } - // case Event.FAP_REVIEWER_NOTIFIED: { - // const { id: reviewId, userID, proposalPk } = event.fapReview; - // const fapReviewer = await userDataSource.getUser(userID); - // const proposal = await proposalDataSource.get(proposalPk); - - // if (!fapReviewer || !proposal) { - // return; - // } - - // mailService - // .sendMail({ - // content: { - // template_id: 'review-reminder', - // }, - // substitution_data: { - // fapReviewerPreferredName: fapReviewer.preferredname, - // fapReviewerLastName: fapReviewer.lastname, - // proposalNumber: proposal.proposalId, - // proposalTitle: proposal.title, - // commentForUser: proposal.commentForUser, - // }, - // recipients: [{ address: fapReviewer.email }], - // }) - // .then(async (res: any) => { - // await fapDataSource.setFapReviewNotificationEmailSent( - // reviewId, - // userID, - // proposalPk - // ); - // logger.logInfo('Email sent on FAP reviewer notify:', { - // result: res, - // event, - // }); - // }) - // .catch((err: string) => { - // logger.logError('Could not send email on FAP reviewer notify:', { - // error: err, - // event, - // }); - // }); - - // return; - // } - - // case Event.INTERNAL_REVIEW_CREATED: - // case Event.INTERNAL_REVIEW_UPDATED: - // case Event.INTERNAL_REVIEW_DELETED: { - // const assignedBy = await userDataSource.getUser( - // event.internalreview.assignedBy - // ); - - // const reviewer = await userDataSource.getUser( - // event.internalreview.reviewerId - // ); - - // const technicalReview = - // await technicalReviewDataSource.getTechnicalReviewById( - // event.internalreview.technicalReviewId - // ); - - // if (!assignedBy || !reviewer || !technicalReview) { - // logger.logError('Failed email invite', { event }); - - // return; - // } - - // const proposal = await proposalDataSource.get(technicalReview.proposalPk); - - // if (!proposal) { - // logger.logError('Failed email invite', { event }); - - // return; - // } - - // let technicalReviewerPreferredName = undefined; - // let technicalReviewerLastname = ''; - - // if (technicalReview.technicalReviewAssigneeId) { - // const technicalReviewer = await userDataSource.getUser( - // technicalReview.technicalReviewAssigneeId - // ); - - // if (technicalReviewer) { - // technicalReviewerPreferredName = technicalReviewer.preferredname; - // technicalReviewerLastname = technicalReviewer.lastname; - // } - // } - - // let templateId = 'internal-review-created'; - - // if (event.type === Event.INTERNAL_REVIEW_UPDATED) { - // templateId = 'internal-review-updated'; - // } else if (event.type === Event.INTERNAL_REVIEW_DELETED) { - // templateId = 'internal-review-deleted'; - // } - - // mailService - // .sendMail({ - // content: { - // template_id: templateId, - // }, - // substitution_data: { - // assignedByPreferredName: assignedBy.preferredname, - // assignedByLastname: assignedBy.lastname, - // reviewerPreferredName: reviewer.preferredname, - // reviewerLastname: reviewer.lastname, - // technicalReviewerPreferredName: technicalReviewerPreferredName, - // technicalReviewerLastname: technicalReviewerLastname, - // proposalTitle: proposal.title, - // proposalNumber: proposal.proposalId, - // reviewTitle: event.internalreview.title, - // }, - // recipients: [{ address: reviewer.email }], - // }) - // .then((res: any) => { - // logger.logInfo('Email sent on internal review change:', { - // result: res, - // event, - // }); - // }) - // .catch((err: string) => { - // logger.logError('Could not send email on internal review change:', { - // error: err, - // event, - // }); - // }); - // } + case Event.PROPOSAL_CREATED: { + const principalInvestigator = await userDataSource.getUser( + event.proposal.proposerId + ); + + const call = await callDataSource.getCall(event.proposal.callId); + + if (!principalInvestigator || !call) { + return; + } + + const templateId = 'proposal-created'; + + const emailTemplate = + await emailTemplateDataSource.getEmailTemplateByName(templateId); + + const options: EmailSettings = { + content: { + template_id: templateId, + db_template_id: emailTemplate ? emailTemplate.id : undefined, + }, + substitution_data: { + preferredName: principalInvestigator.preferredname, + lastName: principalInvestigator.lastname, + firstName: principalInvestigator.firstname, + proposal: event.proposal, + call: call, + }, + recipients: [{ address: principalInvestigator.email }], + }; + + mailService + .sendMail(options) + .then((res: any) => { + logger.logInfo('Emails sent on proposal creation:', { + result: res, + event, + }); + }) + .catch((err: string) => { + logger.logError('Could not send email(s) on proposal creation:', { + error: err, + event, + }); + }); + + return; + } + + case Event.PROPOSAL_NOTIFIED: { + const principalInvestigator = await userDataSource.getUser( + event.proposal.proposerId + ); + if (!principalInvestigator) { + return; + } + const { finalStatus } = event.proposal; + let templateId = ''; + if (finalStatus === ProposalEndStatus.ACCEPTED) { + templateId = 'proposal-accepted'; + } else if (finalStatus === ProposalEndStatus.REJECTED) { + templateId = 'proposal-rejected'; + } else if (finalStatus === ProposalEndStatus.RESERVED) { + templateId = 'proposal-reserved'; + } else { + logger.logError('Failed email notification', { event }); + + return; + } + + const emailTemplate = + await emailTemplateDataSource.getEmailTemplateByName(templateId); + + mailService + .sendMail({ + content: { + template_id: templateId, + db_template_id: emailTemplate ? emailTemplate.id : undefined, + }, + substitution_data: { + preferredName: principalInvestigator.preferredname, + lastName: principalInvestigator.lastname, + firstName: principalInvestigator.firstname, + proposal: event.proposal, + }, + recipients: [{ address: principalInvestigator.email }], + }) + .then((res: any) => { + logger.logInfo('Email sent on proposal notify:', { + result: res, + event, + }); + }) + .catch((err: string) => { + logger.logError('Could not send email on proposal notify:', { + error: err, + event, + }); + }); + + return; + } + case Event.FAP_REVIEWER_NOTIFIED: { + const { id: reviewId, userID, proposalPk } = event.fapReview; + const fapReviewer = await userDataSource.getUser(userID); + const proposal = await proposalDataSource.get(proposalPk); + + if (!fapReviewer || !proposal) { + return; + } + + const templateId = 'reviewer-reminder'; + const emailTemplate = + await emailTemplateDataSource.getEmailTemplateByName(templateId); + + mailService + .sendMail({ + content: { + template_id: templateId, + db_template_id: emailTemplate ? emailTemplate.id : undefined, + }, + substitution_data: { + preferredName: fapReviewer.preferredname, + lastName: fapReviewer.lastname, + proposal: proposal, + }, + recipients: [{ address: fapReviewer.email }], + }) + .then(async (res: any) => { + await fapDataSource.setFapReviewNotificationEmailSent( + reviewId, + userID, + proposalPk + ); + logger.logInfo('Email sent on FAP reviewer notify:', { + result: res, + event, + }); + }) + .catch((err: string) => { + logger.logError('Could not send email on FAP reviewer notify:', { + error: err, + event, + }); + }); + + return; + } + + case Event.INTERNAL_REVIEW_CREATED: + case Event.INTERNAL_REVIEW_UPDATED: + case Event.INTERNAL_REVIEW_DELETED: { + const assignedBy = await userDataSource.getUser( + event.internalreview.assignedBy + ); + + const reviewer = await userDataSource.getUser( + event.internalreview.reviewerId + ); + + const technicalReview = + await technicalReviewDataSource.getTechnicalReviewById( + event.internalreview.technicalReviewId + ); + + if (!assignedBy || !reviewer || !technicalReview) { + logger.logError('Failed email invite', { event }); + + return; + } + + const proposal = await proposalDataSource.get(technicalReview.proposalPk); + + if (!proposal) { + logger.logError('Failed email invite', { event }); + + return; + } + + let technicalReviewerPreferredName = undefined; + let technicalReviewerLastname = ''; + + if (technicalReview.technicalReviewAssigneeId) { + const technicalReviewer = await userDataSource.getUser( + technicalReview.technicalReviewAssigneeId + ); + + if (technicalReviewer) { + technicalReviewerPreferredName = technicalReviewer.preferredname; + technicalReviewerLastname = technicalReviewer.lastname; + } + } + + let templateId = 'internal-review-created'; + + if (event.type === Event.INTERNAL_REVIEW_UPDATED) { + templateId = 'internal-review-updated'; + } else if (event.type === Event.INTERNAL_REVIEW_DELETED) { + templateId = 'internal-review-deleted'; + } + + const emailTemplate = + await emailTemplateDataSource.getEmailTemplateByName(templateId); + + mailService + .sendMail({ + content: { + template_id: templateId, + db_template_id: emailTemplate ? emailTemplate.id : undefined, + }, + substitution_data: { + assignedByPreferredName: assignedBy.preferredname, + assignedByLastname: assignedBy.lastname, + reviewerPreferredName: reviewer.preferredname, + reviewerLastname: reviewer.lastname, + technicalReviewerPreferredName: technicalReviewerPreferredName, + technicalReviewerLastname: technicalReviewerLastname, + proposal: proposal, + reviewTitle: event.internalreview.title, + }, + recipients: [{ address: reviewer.email }], + }) + .then((res: any) => { + logger.logInfo('Email sent on internal review change:', { + result: res, + event, + }); + }) + .catch((err: string) => { + logger.logError('Could not send email on internal review change:', { + error: err, + event, + }); + }); + } } } diff --git a/apps/backend/src/eventHandlers/email/essEmailHandler.ts b/apps/backend/src/eventHandlers/email/essEmailHandler.ts index 0f06452882..c63fba052d 100644 --- a/apps/backend/src/eventHandlers/email/essEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/essEmailHandler.ts @@ -20,9 +20,9 @@ import { MailService } from '../MailService/MailService'; export enum EmailTemplateId { CO_PROPOSER_INVITE_ACCEPTED = 'co-proposer-invite-accepted', PROPOSAL_SUBMITTED = 'proposal-submitted', - ACCEPTED_PROPOSAL = 'Accepted-Proposal', - REJECTED_PROPOSAL = 'Rejected-Proposal', - RESERVED_PROPOSAL = 'Reserved-Proposal', + ACCEPTED_PROPOSAL = 'proposal-accepted', + REJECTED_PROPOSAL = 'proposal-rejected', + RESERVED_PROPOSAL = 'proposal-reserved', REVIEW_REMINDER = 'review-reminder', VISIT_REGISTRATION_APPROVED = 'visit-registration-approved', VISIT_REGISTRATION_CANCELLED = 'visit-registration-cancelled', diff --git a/apps/backend/src/mutations/EmailTemplateMutation.spec.ts b/apps/backend/src/mutations/EmailTemplateMutation.spec.ts index db9d458130..168aa7e5da 100644 --- a/apps/backend/src/mutations/EmailTemplateMutation.spec.ts +++ b/apps/backend/src/mutations/EmailTemplateMutation.spec.ts @@ -1,6 +1,10 @@ import { container } from 'tsyringe'; -import { dummyUserWithRole } from '../datasources/mockups/UserDataSource'; +import { dummyEmailTemplate } from '../datasources/mockups/EmailTemplateDataSource'; +import { + dummyUserOfficerWithRole, + dummyUserWithRole, +} from '../datasources/mockups/UserDataSource'; import EmailTemplateMutations from './EmailTemplateMutations'; const emailTemplateMutations = container.resolve(EmailTemplateMutations); @@ -11,12 +15,74 @@ describe('Test Email Template Mutations', () => { test('A user can not create an email template', () => { return expect( emailTemplateMutations.create(dummyUserWithRole, { - name: 'Test Email Template', - description: 'Test email template description', + name: 'Dummy Email Template', + description: 'This is a dummy email template for testing purposes.', + subject: 'Welcome to Our Service', + body: 'Hello, thank you for signing up for our service. We are excited to have you on board!', createdByUserId: 1, - subject: 'Test email template subject', - body: 'Test email template body', }) ).resolves.toHaveProperty('reason', 'INSUFFICIENT_PERMISSIONS'); }); + + test('A not logged in user can not create an email template', () => { + return expect( + emailTemplateMutations.create(null, { + name: 'Dummy Email Template', + description: 'This is a dummy email template for testing purposes.', + subject: 'Welcome to Our Service', + body: 'Hello, thank you for signing up for our service. We are excited to have you on board!', + createdByUserId: 1, + }) + ).resolves.toHaveProperty('reason', 'NOT_LOGGED_IN'); + }); + + test('A logged in user officer can create an email template', () => { + const emailTemplateToCreate = { + name: 'Dummy Email Template', + description: 'This is a dummy email template for testing purposes.', + subject: 'Welcome to Our Service', + body: 'Hello, thank you for signing up for our service. We are excited to have you on board!', + createdByUserId: 1, + }; + + return expect( + emailTemplateMutations.create( + dummyUserOfficerWithRole, + emailTemplateToCreate + ) + ).resolves.toStrictEqual({ + id: 1, + createdAt: '', + ...emailTemplateToCreate, + }); + }); + + test('A logged in user officer can update an email template', () => { + const emailTemplateToUpdate = { + id: 1, + name: 'Dummy Email Template', + description: 'This is a dummy email template for testing purposes.', + subject: 'Welcome to Our Service', + body: 'Hello, thank you for signing up for our service. We are excited to have you on board!', + createdByUserId: 1, + }; + + return expect( + emailTemplateMutations.update( + dummyUserOfficerWithRole, + emailTemplateToUpdate + ) + ).resolves.toStrictEqual({ + createdAt: '', + ...emailTemplateToUpdate, + }); + }); + + test('A logged in user officer can delete email template', () => { + return expect( + emailTemplateMutations.delete(dummyUserOfficerWithRole, { + emailTemplateId: 1, + }) + ).resolves.toBe(dummyEmailTemplate); + }); }); diff --git a/apps/backend/src/mutations/EmailTemplateMutations.ts b/apps/backend/src/mutations/EmailTemplateMutations.ts index a93bc26572..6eaa436ddf 100644 --- a/apps/backend/src/mutations/EmailTemplateMutations.ts +++ b/apps/backend/src/mutations/EmailTemplateMutations.ts @@ -1,10 +1,9 @@ import { inject, injectable } from 'tsyringe'; -import { Authorized } from 'type-graphql'; import { Tokens } from '../config/Tokens'; import { EmailTemplateDataSource } from '../datasources/EmailTemplateDataSource'; import StatusActionsDataSource from '../datasources/postgres/StatusActionsDataSource'; -import { EventBus } from '../decorators'; +import { Authorized, EventBus } from '../decorators'; import { Event } from '../events/event.enum'; import { EmailTemplate } from '../models/EmailTemplate'; import { Rejection, rejection } from '../models/Rejection'; @@ -22,8 +21,8 @@ export default class EmailTemplateMutations { private statusActionsDataSource: StatusActionsDataSource ) {} - @Authorized([Roles.USER_OFFICER]) @EventBus(Event.EMAIL_TEMPLATE_CREATED) + @Authorized([Roles.USER_OFFICER]) async create( agent: UserWithRole | null, args: CreateEmailTemplateInput diff --git a/apps/backend/src/statusActionEngine/emailActionHandler.ts b/apps/backend/src/statusActionEngine/emailActionHandler.ts index 1c147b2691..3853c2876e 100644 --- a/apps/backend/src/statusActionEngine/emailActionHandler.ts +++ b/apps/backend/src/statusActionEngine/emailActionHandler.ts @@ -475,7 +475,7 @@ const sendMail = async ( try { const res = await mailService.sendMail({ content: { - template_id: recipientWithData.template, + template_id: '', db_template_id: recipientWithData.templateId, email_rfc822: '', }, From e9cb6212257475ff41029d81666abbe06128f136 Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Tue, 2 Sep 2025 15:15:35 +0200 Subject: [PATCH 11/62] feat: fix email template events --- .../jobs/checkInviteReminderJob.spec.ts | 4 +-- .../datasources/mockups/InviteDataSource.ts | 2 +- .../src/datasources/postgres/records.ts | 2 +- .../email/eliEmailHandler.spec.ts | 29 ++++++++++++------- .../eventHandlers/email/eliEmailHandler.ts | 25 ++++++++-------- .../eventHandlers/email/emailTemplateId.ts | 18 ++++++++++++ .../email/essEmailHandler.spec.ts | 5 ++-- .../eventHandlers/email/essEmailHandler.ts | 16 +--------- .../src/eventHandlers/messageBroker.ts | 2 +- apps/backend/src/events/applicationEvents.ts | 6 ++-- apps/backend/src/models/Invite.ts | 2 +- .../src/mutations/EmailTemplateMutations.ts | 4 +-- .../src/mutations/InviteMutations.spec.ts | 4 +-- .../settings/workflow/EmailActionConfig.tsx | 23 +++++++++++---- 14 files changed, 84 insertions(+), 58 deletions(-) create mode 100644 apps/backend/src/eventHandlers/email/emailTemplateId.ts diff --git a/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.spec.ts b/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.spec.ts index ecedf68d6e..89e17b54e6 100644 --- a/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.spec.ts +++ b/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.spec.ts @@ -1,10 +1,10 @@ -import 'reflect-metadata'; import { faker } from '@faker-js/faker'; +import 'reflect-metadata'; import sinon from 'sinon'; import { container } from 'tsyringe'; import { Tokens } from '../../config/Tokens'; -import { EmailTemplateId } from '../../eventHandlers/email/essEmailHandler'; +import { EmailTemplateId } from '../../eventHandlers/email/emailTemplateId'; import { Invite } from '../../models/Invite'; import { RoleClaim } from '../../models/RoleClaim'; import { SettingsId } from '../../models/Settings'; diff --git a/apps/backend/src/datasources/mockups/InviteDataSource.ts b/apps/backend/src/datasources/mockups/InviteDataSource.ts index 855822aec8..471f3fb16c 100644 --- a/apps/backend/src/datasources/mockups/InviteDataSource.ts +++ b/apps/backend/src/datasources/mockups/InviteDataSource.ts @@ -1,7 +1,7 @@ import { inject, injectable } from 'tsyringe'; import { Tokens } from '../../config/Tokens'; -import { EmailTemplateId } from '../../eventHandlers/email/essEmailHandler'; +import { EmailTemplateId } from '../../eventHandlers/email/emailTemplateId'; import { Invite } from '../../models/Invite'; import { CoProposerClaimDataSource } from '../CoProposerClaimDataSource'; import { GetInvitesFilter, InviteDataSource } from '../InviteDataSource'; diff --git a/apps/backend/src/datasources/postgres/records.ts b/apps/backend/src/datasources/postgres/records.ts index db16e6e7ea..e3607dd9c8 100644 --- a/apps/backend/src/datasources/postgres/records.ts +++ b/apps/backend/src/datasources/postgres/records.ts @@ -3,7 +3,7 @@ import { ProposalPdfTemplateRecord, } from 'knex/types/tables'; -import { EmailTemplateId } from '../../eventHandlers/email/essEmailHandler'; +import { EmailTemplateId } from '../../eventHandlers/email/emailTemplateId'; import { Page } from '../../models/Admin'; import { FileMetadata } from '../../models/Blob'; import { AllocationTimeUnits, Call } from '../../models/Call'; diff --git a/apps/backend/src/eventHandlers/email/eliEmailHandler.spec.ts b/apps/backend/src/eventHandlers/email/eliEmailHandler.spec.ts index 6982b9fb38..4e40262fa0 100644 --- a/apps/backend/src/eventHandlers/email/eliEmailHandler.spec.ts +++ b/apps/backend/src/eventHandlers/email/eliEmailHandler.spec.ts @@ -9,6 +9,7 @@ import { ApplicationEvent } from '../../events/applicationEvents'; import { Event } from '../../events/event.enum'; import { UserRole } from '../../models/User'; import { eliEmailHandler } from './eliEmailHandler'; +import { EmailTemplateId } from './emailTemplateId'; import { getTemplateIdForInvite } from './essEmailHandler'; // Mock MailService @@ -61,7 +62,9 @@ describe('eliEmailHandler', () => { const result = await getTemplateIdForInvite(inviteId); - expect(result).toBe('user-office-registration-invitation-co-proposer'); + expect(result).toBe( + EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_CO_PROPOSER + ); }); test('should return reviewer template when internal reviewer role claim exists', async () => { @@ -84,7 +87,9 @@ describe('eliEmailHandler', () => { const result = await getTemplateIdForInvite(inviteId); - expect(result).toBe('user-office-registration-invitation-reviewer'); + expect(result).toBe( + EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_REVIEWER + ); }); test('should return user template when user role claim exists', async () => { @@ -107,7 +112,9 @@ describe('eliEmailHandler', () => { const result = await getTemplateIdForInvite(inviteId); - expect(result).toBe('user-office-registration-invitation-user'); + expect(result).toBe( + EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_USER + ); }); test('should use template proposal-created', async () => { @@ -132,7 +139,7 @@ describe('eliEmailHandler', () => { expect.objectContaining({ content: { db_template_id: 1, - template_id: 'proposal-created', + template_id: EmailTemplateId.PROPOSAL_CREATED, }, }) ); @@ -162,7 +169,7 @@ describe('eliEmailHandler', () => { expect.objectContaining({ content: { db_template_id: 1, - template_id: 'proposal-accepted', + template_id: EmailTemplateId.ACCEPTED_PROPOSAL, }, }) ); @@ -192,7 +199,7 @@ describe('eliEmailHandler', () => { expect.objectContaining({ content: { db_template_id: 1, - template_id: 'proposal-rejected', + template_id: EmailTemplateId.REJECTED_PROPOSAL, }, }) ); @@ -222,7 +229,7 @@ describe('eliEmailHandler', () => { expect.objectContaining({ content: { db_template_id: 1, - template_id: 'proposal-reserved', + template_id: EmailTemplateId.RESERVED_PROPOSAL, }, }) ); @@ -248,7 +255,7 @@ describe('eliEmailHandler', () => { expect.objectContaining({ content: { db_template_id: 1, - template_id: 'reviewer-reminder', + template_id: EmailTemplateId.REVIEW_REMINDER, }, }) ); @@ -282,7 +289,7 @@ describe('eliEmailHandler', () => { expect.objectContaining({ content: { db_template_id: 1, - template_id: 'internal-review-created', + template_id: EmailTemplateId.INTERNAL_REVIEW_CREATED, }, }) ); @@ -316,7 +323,7 @@ describe('eliEmailHandler', () => { expect.objectContaining({ content: { db_template_id: 1, - template_id: 'internal-review-updated', + template_id: EmailTemplateId.INTERNAL_REVIEW_UPDATED, }, }) ); @@ -350,7 +357,7 @@ describe('eliEmailHandler', () => { expect.objectContaining({ content: { db_template_id: 1, - template_id: 'internal-review-deleted', + template_id: EmailTemplateId.INTERNAL_REVIEW_DELETED, }, }) ); diff --git a/apps/backend/src/eventHandlers/email/eliEmailHandler.ts b/apps/backend/src/eventHandlers/email/eliEmailHandler.ts index 0f9e390131..3627b2ed48 100644 --- a/apps/backend/src/eventHandlers/email/eliEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/eliEmailHandler.ts @@ -17,6 +17,7 @@ import { ProposalEndStatus } from '../../models/Proposal'; import { UserRole } from '../../models/User'; import EmailSettings from '../MailService/EmailSettings'; import { MailService } from '../MailService/MailService'; +import { EmailTemplateId } from './emailTemplateId'; export async function eliEmailHandler(event: ApplicationEvent) { const mailService = container.resolve(Tokens.MailService); @@ -87,8 +88,8 @@ export async function eliEmailHandler(event: ApplicationEvent) { const templateId = event.emailinviteresponse.role === UserRole.USER - ? 'user-office-registration-invitation' - : 'user-office-registration-invitation-reviewer'; + ? EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_USER + : EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_REVIEWER; const emailTemplate = await emailTemplateDataSource.getEmailTemplateByName(templateId); @@ -202,7 +203,7 @@ export async function eliEmailHandler(event: ApplicationEvent) { return; } - const templateId = 'proposal-created'; + const templateId = EmailTemplateId.PROPOSAL_CREATED; const emailTemplate = await emailTemplateDataSource.getEmailTemplateByName(templateId); @@ -250,11 +251,11 @@ export async function eliEmailHandler(event: ApplicationEvent) { const { finalStatus } = event.proposal; let templateId = ''; if (finalStatus === ProposalEndStatus.ACCEPTED) { - templateId = 'proposal-accepted'; + templateId = EmailTemplateId.ACCEPTED_PROPOSAL; } else if (finalStatus === ProposalEndStatus.REJECTED) { - templateId = 'proposal-rejected'; + templateId = EmailTemplateId.REJECTED_PROPOSAL; } else if (finalStatus === ProposalEndStatus.RESERVED) { - templateId = 'proposal-reserved'; + templateId = EmailTemplateId.RESERVED_PROPOSAL; } else { logger.logError('Failed email notification', { event }); @@ -302,7 +303,7 @@ export async function eliEmailHandler(event: ApplicationEvent) { return; } - const templateId = 'reviewer-reminder'; + const templateId = EmailTemplateId.REVIEW_REMINDER; const emailTemplate = await emailTemplateDataSource.getEmailTemplateByName(templateId); @@ -384,12 +385,12 @@ export async function eliEmailHandler(event: ApplicationEvent) { } } - let templateId = 'internal-review-created'; + let templateId = EmailTemplateId.INTERNAL_REVIEW_CREATED; if (event.type === Event.INTERNAL_REVIEW_UPDATED) { - templateId = 'internal-review-updated'; + templateId = EmailTemplateId.INTERNAL_REVIEW_UPDATED; } else if (event.type === Event.INTERNAL_REVIEW_DELETED) { - templateId = 'internal-review-deleted'; + templateId = EmailTemplateId.INTERNAL_REVIEW_DELETED; } const emailTemplate = @@ -432,9 +433,9 @@ export async function eliEmailHandler(event: ApplicationEvent) { function getTemplateIdForRole(role: UserRole): string { switch (role) { case UserRole.USER: - return 'user-office-registration-invitation'; + return EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_USER; case UserRole.INTERNAL_REVIEWER: - return 'user-office-registration-invitation-reviewer'; + return EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_REVIEWER; default: throw new Error('No valid user role set for email invitation'); } diff --git a/apps/backend/src/eventHandlers/email/emailTemplateId.ts b/apps/backend/src/eventHandlers/email/emailTemplateId.ts new file mode 100644 index 0000000000..fc20821df3 --- /dev/null +++ b/apps/backend/src/eventHandlers/email/emailTemplateId.ts @@ -0,0 +1,18 @@ +export enum EmailTemplateId { + CO_PROPOSER_INVITE_ACCEPTED = 'co-proposer-invite-accepted', + PROPOSAL_SUBMITTED = 'proposal-submitted', + PROPOSAL_CREATED = 'proposal-created', + ACCEPTED_PROPOSAL = 'Accepted-Proposal', + REJECTED_PROPOSAL = 'Rejected-Proposal', + RESERVED_PROPOSAL = 'Reserved-Proposal', + REVIEW_REMINDER = 'review-reminder', + VISIT_REGISTRATION_APPROVED = 'visit-registration-approved', + VISIT_REGISTRATION_CANCELLED = 'visit-registration-cancelled', + USER_OFFICE_REGISTRATION_INVITATION_CO_PROPOSER = 'user-office-registration-invitation-co-proposer', + USER_OFFICE_REGISTRATION_INVITATION_VISIT_REGISTRATION = 'user-office-registration-invitation-visit-registration', + USER_OFFICE_REGISTRATION_INVITATION_REVIEWER = 'user-office-registration-invitation-reviewer', + USER_OFFICE_REGISTRATION_INVITATION_USER = 'user-office-registration-invitation-user', + INTERNAL_REVIEW_CREATED = 'internal-review-created', + INTERNAL_REVIEW_UPDATED = 'internal-review-updated', + INTERNAL_REVIEW_DELETED = 'internal-review-deleted', +} diff --git a/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts b/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts index b6393e67c2..42503fc67b 100644 --- a/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts +++ b/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts @@ -1,5 +1,5 @@ -import 'reflect-metadata'; import { faker } from '@faker-js/faker'; +import 'reflect-metadata'; import { container } from 'tsyringe'; import { Tokens } from '../../config/Tokens'; @@ -11,7 +11,8 @@ import { ApplicationEvent } from '../../events/applicationEvents'; import { Event } from '../../events/event.enum'; import { Invite } from '../../models/Invite'; import { MailService } from '../MailService/MailService'; -import { EmailTemplateId, essEmailHandler } from './essEmailHandler'; +import { EmailTemplateId } from './emailTemplateId'; +import { essEmailHandler } from './essEmailHandler'; describe('essEmailHandler', () => { let coProposerDataSourceMock: CoProposerClaimDataSourceMock; diff --git a/apps/backend/src/eventHandlers/email/essEmailHandler.ts b/apps/backend/src/eventHandlers/email/essEmailHandler.ts index c63fba052d..a4a8184359 100644 --- a/apps/backend/src/eventHandlers/email/essEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/essEmailHandler.ts @@ -17,21 +17,7 @@ import { ProposalEndStatus } from '../../models/Proposal'; import { UserRole } from '../../models/User'; import EmailSettings from '../MailService/EmailSettings'; import { MailService } from '../MailService/MailService'; -export enum EmailTemplateId { - CO_PROPOSER_INVITE_ACCEPTED = 'co-proposer-invite-accepted', - PROPOSAL_SUBMITTED = 'proposal-submitted', - ACCEPTED_PROPOSAL = 'proposal-accepted', - REJECTED_PROPOSAL = 'proposal-rejected', - RESERVED_PROPOSAL = 'proposal-reserved', - REVIEW_REMINDER = 'review-reminder', - VISIT_REGISTRATION_APPROVED = 'visit-registration-approved', - VISIT_REGISTRATION_CANCELLED = 'visit-registration-cancelled', - USER_OFFICE_REGISTRATION_INVITATION_CO_PROPOSER = 'user-office-registration-invitation-co-proposer', - USER_OFFICE_REGISTRATION_INVITATION_VISIT_REGISTRATION = 'user-office-registration-invitation-visit-registration', - USER_OFFICE_REGISTRATION_INVITATION_REVIEWER = 'user-office-registration-invitation-reviewer', - USER_OFFICE_REGISTRATION_INVITATION_USER = 'user-office-registration-invitation-user', -} - +import { EmailTemplateId } from './emailTemplateId'; export async function essEmailHandler(event: ApplicationEvent) { const mailService = container.resolve(Tokens.MailService); const proposalDataSource = container.resolve( diff --git a/apps/backend/src/eventHandlers/messageBroker.ts b/apps/backend/src/eventHandlers/messageBroker.ts index 0929a95053..8beec89019 100644 --- a/apps/backend/src/eventHandlers/messageBroker.ts +++ b/apps/backend/src/eventHandlers/messageBroker.ts @@ -275,7 +275,7 @@ export async function createPostToRabbitMQHandler() { case Event.EMAIL_TEMPLATE_CREATED: case Event.EMAIL_TEMPLATE_UPDATED: case Event.EMAIL_TEMPLATE_DELETED: { - const jsonMessage = JSON.stringify(event.emailTemplate); + const jsonMessage = JSON.stringify(event.emailtemplate); await rabbitMQ.sendMessageToExchange( EXCHANGE_NAME, diff --git a/apps/backend/src/events/applicationEvents.ts b/apps/backend/src/events/applicationEvents.ts index d53ea465ad..e725f01aab 100644 --- a/apps/backend/src/events/applicationEvents.ts +++ b/apps/backend/src/events/applicationEvents.ts @@ -446,17 +446,17 @@ interface ExperimentSafetyStatusChangedByUserEvent extends GeneralEvent { interface EmailTemplateCreatedEvent extends GeneralEvent { type: Event.EMAIL_TEMPLATE_CREATED; - emailTemplate: EmailTemplate; + emailtemplate: EmailTemplate; } interface EmailTemplateUpdatedEvent extends GeneralEvent { type: Event.EMAIL_TEMPLATE_UPDATED; - emailTemplate: EmailTemplate; + emailtemplate: EmailTemplate; } interface EmailTemplateDeletedEvent extends GeneralEvent { type: Event.EMAIL_TEMPLATE_DELETED; - emailTemplate: EmailTemplate; + emailtemplate: EmailTemplate; } export type ApplicationEvent = diff --git a/apps/backend/src/models/Invite.ts b/apps/backend/src/models/Invite.ts index 449682dff2..e406287693 100644 --- a/apps/backend/src/models/Invite.ts +++ b/apps/backend/src/models/Invite.ts @@ -1,4 +1,4 @@ -import { EmailTemplateId } from '../eventHandlers/email/essEmailHandler'; +import { EmailTemplateId } from '../eventHandlers/email/emailTemplateId'; export class Invite { constructor( diff --git a/apps/backend/src/mutations/EmailTemplateMutations.ts b/apps/backend/src/mutations/EmailTemplateMutations.ts index 6eaa436ddf..74490578b5 100644 --- a/apps/backend/src/mutations/EmailTemplateMutations.ts +++ b/apps/backend/src/mutations/EmailTemplateMutations.ts @@ -46,7 +46,7 @@ export default class EmailTemplateMutations { } @Authorized([Roles.USER_OFFICER]) - @EventBus(Event.EMAIL_TEMPLATE_CREATED) + @EventBus(Event.EMAIL_TEMPLATE_UPDATED) async update( agent: UserWithRole | null, args: UpdateEmailTemplateInput @@ -70,7 +70,7 @@ export default class EmailTemplateMutations { } @Authorized([Roles.USER_OFFICER]) - @EventBus(Event.EMAIL_TEMPLATE_CREATED) + @EventBus(Event.EMAIL_TEMPLATE_DELETED) async delete( agent: UserWithRole | null, { emailTemplateId }: { emailTemplateId: number } diff --git a/apps/backend/src/mutations/InviteMutations.spec.ts b/apps/backend/src/mutations/InviteMutations.spec.ts index 4c49279c6c..c7e9dc6b11 100644 --- a/apps/backend/src/mutations/InviteMutations.spec.ts +++ b/apps/backend/src/mutations/InviteMutations.spec.ts @@ -1,5 +1,5 @@ -import 'reflect-metadata'; import { faker } from '@faker-js/faker'; +import 'reflect-metadata'; import { container } from 'tsyringe'; import { Tokens } from '../config/Tokens'; @@ -14,7 +14,7 @@ import { dummyUserOfficerWithRole, dummyUserWithRole, } from '../datasources/mockups/UserDataSource'; -import { EmailTemplateId } from '../eventHandlers/email/essEmailHandler'; +import { EmailTemplateId } from '../eventHandlers/email/emailTemplateId'; import { MailService } from '../eventHandlers/MailService/MailService'; import { Invite } from '../models/Invite'; import { Rejection } from '../models/Rejection'; diff --git a/apps/frontend/src/components/settings/workflow/EmailActionConfig.tsx b/apps/frontend/src/components/settings/workflow/EmailActionConfig.tsx index e03862aa88..05f4a1fb43 100644 --- a/apps/frontend/src/components/settings/workflow/EmailActionConfig.tsx +++ b/apps/frontend/src/components/settings/workflow/EmailActionConfig.tsx @@ -172,6 +172,21 @@ const EmailActionConfig = ({ } }; + const getEmailTemplate = (foundRecipientWithEmailTemplateIndex: number) => { + if (foundRecipientWithEmailTemplateIndex !== -1) { + return ( + emailTemplates.find( + (template) => + template.id === + recipientsWithEmailTemplate[foundRecipientWithEmailTemplateIndex] + .emailTemplate.id + ) || null + ); + } else { + return null; + } + }; + return ( <> @@ -262,11 +277,9 @@ const EmailActionConfig = ({ newTemplateValue ); }} - value={ - recipientsWithEmailTemplate[ - foundRecipientWithEmailTemplateIndex - ].emailTemplate || null - } + value={getEmailTemplate( + foundRecipientWithEmailTemplateIndex + )} data-cy={`${recipient.name}-email-template`} /> {recipient.name === EmailStatusActionRecipients.OTHER && ( From 7f035d16fa900d37a411195d745ed446faefe442 Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Mon, 8 Sep 2025 10:08:46 +0200 Subject: [PATCH 12/62] fix: restore vite.config.mts --- apps/frontend/vite.config.mts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/frontend/vite.config.mts b/apps/frontend/vite.config.mts index 251df849d8..4bf15b0f00 100644 --- a/apps/frontend/vite.config.mts +++ b/apps/frontend/vite.config.mts @@ -1,8 +1,8 @@ import react from '@vitejs/plugin-react'; import { defineConfig, - splitVendorChunkPlugin, loadEnv, + splitVendorChunkPlugin, UserConfig, } from 'vite'; @@ -34,7 +34,6 @@ export default ({ mode }): UserConfig => { return acc; }, {}), }, - allowedHosts: ['localhost', 'sajat.eu'], }, resolve: { alias: { From 8d04cd895f51bb7a17e9049128bb5a8b69b82239 Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Mon, 8 Sep 2025 14:52:28 +0200 Subject: [PATCH 13/62] fix: e2e tests --- .../db_seeds/0007_EmailTemplates.sql | 15 +++++++++++ apps/e2e/cypress/e2e/statusActions.cy.ts | 26 +++++++++---------- apps/e2e/cypress/support/initialDBData.ts | 18 +++++++++++++ .../settings/workflow/EmailActionConfig.tsx | 2 +- 4 files changed, 47 insertions(+), 14 deletions(-) create mode 100644 apps/backend/db_patches/db_seeds/0007_EmailTemplates.sql diff --git a/apps/backend/db_patches/db_seeds/0007_EmailTemplates.sql b/apps/backend/db_patches/db_seeds/0007_EmailTemplates.sql new file mode 100644 index 0000000000..fef4bb421a --- /dev/null +++ b/apps/backend/db_patches/db_seeds/0007_EmailTemplates.sql @@ -0,0 +1,15 @@ +DO +$DO$ +BEGIN + + INSERT INTO email_templates (created_by, name, description, subject, body) + VALUES + (0, 'template-name-1', 'template-description-1', 'template-subject-1', 'template-body-1'); + + INSERT INTO email_templates (created_by, name, description, subject, body) + VALUES + (0, 'template-name-2', 'template-description-2', 'template-subject-2', 'template-body-2'); + +END; +$DO$ +LANGUAGE plpgsql; diff --git a/apps/e2e/cypress/e2e/statusActions.cy.ts b/apps/e2e/cypress/e2e/statusActions.cy.ts index 6977776f3d..c3211def58 100644 --- a/apps/e2e/cypress/e2e/statusActions.cy.ts +++ b/apps/e2e/cypress/e2e/statusActions.cy.ts @@ -1,11 +1,11 @@ import { faker } from '@faker-js/faker'; import { - Event as PROPOSAL_EVENTS, - EmailStatusActionRecipients, - StatusActionType, AllocationTimeUnits, - FeatureUpdateAction, + EmailStatusActionRecipients, FeatureId, + FeatureUpdateAction, + Event as PROPOSAL_EVENTS, + StatusActionType, } from '@user-office-software-libs/shared-types'; import { DateTime } from 'luxon'; @@ -132,7 +132,7 @@ context('Status actions tests', () => { name: EmailStatusActionRecipients.PI, description: '', }, - emailTemplate: { id: 'pi-template', name: 'PI template' }, + emailTemplate: { id: 1, name: 'template-name-1' }, combineEmails: true, }, ], @@ -223,7 +223,7 @@ context('Status actions tests', () => { name: EmailStatusActionRecipients.PI, description: '', }, - emailTemplate: { id: 'pi-template', name: 'PI template' }, + emailTemplate: { id: 1, name: 'template-name-1' }, }, ], }; @@ -291,7 +291,7 @@ context('Status actions tests', () => { name: EmailStatusActionRecipients.PI, description: '', }, - emailTemplate: { id: 'pi-template', name: 'PI template' }, + emailTemplate: { id: 1, name: 'template-name-1' }, }, ], }; @@ -441,7 +441,7 @@ context('Status actions tests', () => { description: 'Other email recipients manually added by their email', }, - emailTemplate: { id: 'my-first-email', name: 'My First Email' }, + emailTemplate: { id: 2, name: 'template-name-2' }, otherRecipientEmails: [faker.internet.email()], }, ], @@ -557,8 +557,8 @@ context('Status actions tests', () => { 'Other email recipients manually added by their email', }, emailTemplate: { - id: 'status-actions-test-template', - name: 'Status actions test template', + id: 2, + name: 'template-name-2', }, otherRecipientEmails: [statusActionEmail], }, @@ -668,7 +668,7 @@ context('Status actions tests', () => { name: EmailStatusActionRecipients.PI, description: '', }, - emailTemplate: { id: 'pi-template', name: 'PI template' }, + emailTemplate: { id: 1, name: 'template-name-1' }, }, { recipient: { @@ -677,8 +677,8 @@ context('Status actions tests', () => { 'Other email recipients manually added by their email', }, emailTemplate: { - id: 'status-actions-test-template', - name: 'Status actions test template', + id: 2, + name: 'template-name-2', }, otherRecipientEmails: [faker.internet.email()], }, diff --git a/apps/e2e/cypress/support/initialDBData.ts b/apps/e2e/cypress/support/initialDBData.ts index 7615fa107a..a9468bb2af 100644 --- a/apps/e2e/cypress/support/initialDBData.ts +++ b/apps/e2e/cypress/support/initialDBData.ts @@ -412,4 +412,22 @@ export default { proposalPk: 1, questionId: 'sample_declaration_question', }, + emailTemplates: { + template1: { + id: 1, + createdByUserId: 0, + name: 'template-name-1', + description: 'template-description-1', + subject: 'template-subject-1', + body: 'template-body-1', + }, + template2: { + id: 2, + createdByUserId: 0, + name: 'template-name-2', + description: 'template-description-2', + subject: 'template-subject-2', + body: 'template-body-2', + }, + }, }; diff --git a/apps/frontend/src/components/settings/workflow/EmailActionConfig.tsx b/apps/frontend/src/components/settings/workflow/EmailActionConfig.tsx index 05f4a1fb43..9142bb9cea 100644 --- a/apps/frontend/src/components/settings/workflow/EmailActionConfig.tsx +++ b/apps/frontend/src/components/settings/workflow/EmailActionConfig.tsx @@ -179,7 +179,7 @@ const EmailActionConfig = ({ (template) => template.id === recipientsWithEmailTemplate[foundRecipientWithEmailTemplateIndex] - .emailTemplate.id + ?.emailTemplate?.id ) || null ); } else { From 6e808f6c365fc4a84605b973e6d275ef92c79be5 Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Tue, 9 Sep 2025 09:35:03 +0200 Subject: [PATCH 14/62] fix: update email folder for temporary template files --- apps/backend/src/eventHandlers/MailService/MailService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/eventHandlers/MailService/MailService.ts b/apps/backend/src/eventHandlers/MailService/MailService.ts index 2a9af9cf25..58b5fa5365 100644 --- a/apps/backend/src/eventHandlers/MailService/MailService.ts +++ b/apps/backend/src/eventHandlers/MailService/MailService.ts @@ -49,7 +49,7 @@ export abstract class MailService { writeFileSync( path.join( process.env.EMAIL_TEMPLATE_PATH || '', - templateId + '.html.pug' + path.join('/tmp', templateId + '.html.pug') ), emailTemplate.body ); @@ -57,7 +57,7 @@ export abstract class MailService { writeFileSync( path.join( process.env.EMAIL_TEMPLATE_PATH || '', - templateId + '.subject.pug' + path.join('/tmp', templateId + '.subject.pug') ), '= `' + emailTemplate.subject + '`' ); From 62e48a5ac9222e335279d3968b75feab2902cef4 Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Tue, 9 Sep 2025 14:53:23 +0200 Subject: [PATCH 15/62] fix: update Mail service for e2e run --- .../eventHandlers/MailService/MailService.ts | 41 ++++++++++++------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/apps/backend/src/eventHandlers/MailService/MailService.ts b/apps/backend/src/eventHandlers/MailService/MailService.ts index 58b5fa5365..9f5af8df43 100644 --- a/apps/backend/src/eventHandlers/MailService/MailService.ts +++ b/apps/backend/src/eventHandlers/MailService/MailService.ts @@ -1,6 +1,7 @@ import { writeFileSync } from 'node:fs'; import path from 'node:path'; +import { logger } from '@user-office-software/duo-logger'; import EmailTemplates from 'email-templates'; import { container } from 'tsyringe'; @@ -46,21 +47,31 @@ export abstract class MailService { return null; } - writeFileSync( - path.join( - process.env.EMAIL_TEMPLATE_PATH || '', - path.join('/tmp', templateId + '.html.pug') - ), - emailTemplate.body - ); - - writeFileSync( - path.join( - process.env.EMAIL_TEMPLATE_PATH || '', - path.join('/tmp', templateId + '.subject.pug') - ), - '= `' + emailTemplate.subject + '`' - ); + try { + writeFileSync( + path.join( + process.env.EMAIL_TEMPLATE_PATH || '', + templateId + '.html.pug' + ), + emailTemplate.body + ); + + writeFileSync( + path.join( + process.env.EMAIL_TEMPLATE_PATH || '', + templateId + '.subject.pug' + ), + '= `' + emailTemplate.subject + '`' + ); + } catch (err) { + if (process.env.EMAIL_TEMPLATE_PATH !== '/config/emails') { + throw err; + } else { + logger.logWarn('Could not create email template', { + error: err, + }); + } + } return templateId; } else { From 19fac52ffc984521caba455bbf487ec157b91eb9 Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Tue, 9 Sep 2025 16:26:35 +0200 Subject: [PATCH 16/62] fix: update Mail service for e2e run --- apps/backend/src/eventHandlers/MailService/MailService.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/backend/src/eventHandlers/MailService/MailService.ts b/apps/backend/src/eventHandlers/MailService/MailService.ts index 9f5af8df43..28f947c60a 100644 --- a/apps/backend/src/eventHandlers/MailService/MailService.ts +++ b/apps/backend/src/eventHandlers/MailService/MailService.ts @@ -70,6 +70,8 @@ export abstract class MailService { logger.logWarn('Could not create email template', { error: err, }); + + return templateId; } } From 97683e4131c158f25fd9c23cbb5308e8d76ed5ab Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Wed, 10 Sep 2025 10:10:26 +0200 Subject: [PATCH 17/62] fix: change email templates verification --- apps/backend/src/eventHandlers/MailService/MailService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/eventHandlers/MailService/MailService.ts b/apps/backend/src/eventHandlers/MailService/MailService.ts index 28f947c60a..246276d83a 100644 --- a/apps/backend/src/eventHandlers/MailService/MailService.ts +++ b/apps/backend/src/eventHandlers/MailService/MailService.ts @@ -64,7 +64,7 @@ export abstract class MailService { '= `' + emailTemplate.subject + '`' ); } catch (err) { - if (process.env.EMAIL_TEMPLATE_PATH !== '/config/emails') { + if (process.env.EMAIL_TEMPLATE_PATH !== '/config/emails/') { throw err; } else { logger.logWarn('Could not create email template', { From 868727a227e1a174f50dc2a99d0f8aa61a7da7ff Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Wed, 10 Sep 2025 11:42:41 +0200 Subject: [PATCH 18/62] fix: update email temp directory --- .../eventHandlers/MailService/MailService.ts | 59 +++++++++---------- .../MailService/SMTPMailService.ts | 10 ++-- 2 files changed, 35 insertions(+), 34 deletions(-) diff --git a/apps/backend/src/eventHandlers/MailService/MailService.ts b/apps/backend/src/eventHandlers/MailService/MailService.ts index 246276d83a..585bd9df16 100644 --- a/apps/backend/src/eventHandlers/MailService/MailService.ts +++ b/apps/backend/src/eventHandlers/MailService/MailService.ts @@ -1,4 +1,5 @@ -import { writeFileSync } from 'node:fs'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import path from 'node:path'; import { logger } from '@user-office-software/duo-logger'; @@ -27,18 +28,18 @@ export abstract class MailService { ); } - async getTemplateId(options: EmailSettings): Promise { + async getEmailTemplate( + options: EmailSettings + ): Promise<{ name: string; path: string } | null> { const emailTemplateDataSource = container.resolve( Tokens.EmailTemplateDataSource ); if (process.env.NODE_ENV === 'test') { - return options.content.template_id; + return { name: options.content.template_id, path: '' }; } if (options.content.db_template_id) { - const templateId = 'temp_template'; - const emailTemplate = await emailTemplateDataSource.getEmailTemplate( options.content.db_template_id ); @@ -47,44 +48,42 @@ export abstract class MailService { return null; } + const tempDirPath = path.join(tmpdir(), 'email-templates'); + try { - writeFileSync( - path.join( - process.env.EMAIL_TEMPLATE_PATH || '', - templateId + '.html.pug' - ), - emailTemplate.body - ); + const tempDir = mkdtempSync(tempDirPath); + const tempFilePath = path.join(tempDir, emailTemplate.name); + + writeFileSync(tempFilePath + '.html.pug', emailTemplate.body); writeFileSync( - path.join( - process.env.EMAIL_TEMPLATE_PATH || '', - templateId + '.subject.pug' - ), + tempFilePath + '.subject.pug', '= `' + emailTemplate.subject + '`' ); + + return { name: emailTemplate.name, path: tempFilePath }; } catch (err) { - if (process.env.EMAIL_TEMPLATE_PATH !== '/config/emails/') { - throw err; - } else { - logger.logWarn('Could not create email template', { - error: err, - }); - - return templateId; - } - } + logger.logWarn('Could not create email template', { + error: err, + }); - return templateId; + return null; + } } else { - const template = + const templatePath = this.getEmailTemplatePath('html', options.content.template_id) + '.pug'; - if (!(await (this.emailTemplates as any).templateExists(template))) { + if (!(await (this.emailTemplates as any).templateExists(templatePath))) { return null; } - return options.content.template_id; + return { + name: options.content.template_id, + path: path.join( + process.env.EMAIL_TEMPLATE_PATH || '', + options.content.template_id + ), + }; } } } diff --git a/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts b/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts index 3878b60c14..b84a82df1a 100644 --- a/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts +++ b/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts @@ -63,7 +63,9 @@ export class SMTPMailService extends MailService { relativeTo: path.resolve(process.env.EMAIL_TEMPLATE_PATH || ''), }, }, - getPath: this.getEmailTemplatePath, + getPath: (type, template) => { + return `${template}.${type}`; + }, }); } @@ -106,9 +108,9 @@ export class SMTPMailService extends MailService { sendMailResults.id = 'test'; } - const templateId = await this.getTemplateId(options); + const template = await this.getEmailTemplate(options); - if (!templateId) { + if (!template) { logger.logError('Email template not found', { templateId: options.content.db_template_id, dbTemplateId: options.content.db_template_id, @@ -120,7 +122,7 @@ export class SMTPMailService extends MailService { options.recipients.forEach((participant) => { emailPromises.push( this.emailTemplates.send({ - template: templateId, + template: template.path, message: { ...(typeof participant.address !== 'string' ? { From 0336aece1f5b36c3e21317371ac3ea3b32bfeb07 Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Wed, 10 Sep 2025 14:47:56 +0200 Subject: [PATCH 19/62] fix: update e2d for email templates --- apps/e2e/cypress/e2e/statusActions.cy.ts | 46 +++++++++++++++----- apps/e2e/cypress/support/statusActionLogs.ts | 18 ++++++++ apps/e2e/cypress/types/statusActionLog.d.ts | 14 ++++++ 3 files changed, 68 insertions(+), 10 deletions(-) diff --git a/apps/e2e/cypress/e2e/statusActions.cy.ts b/apps/e2e/cypress/e2e/statusActions.cy.ts index c3211def58..62b39c8367 100644 --- a/apps/e2e/cypress/e2e/statusActions.cy.ts +++ b/apps/e2e/cypress/e2e/statusActions.cy.ts @@ -2,6 +2,7 @@ import { faker } from '@faker-js/faker'; import { AllocationTimeUnits, EmailStatusActionRecipients, + EmailTemplate, FeatureId, FeatureUpdateAction, Event as PROPOSAL_EVENTS, @@ -36,16 +37,26 @@ const newCall = { let proposal1Id: string; let proposal2Id: string; +let emailTemplate1: EmailTemplate | null; +let emailTemplate2: EmailTemplate | null; context('Status actions tests', () => { beforeEach(function () { - cy.resetDB(); + cy.resetDB(true); cy.getAndStoreFeaturesEnabled(); cy.updateFeature({ action: FeatureUpdateAction.ENABLE, featureIds: [FeatureId.PREGENERATED_PROPOSAL_PDF], }); + + cy.getEmailTemplate({ emailTemplateId: 1 }).then((result) => { + emailTemplate1 = result.emailTemplate; + }); + + cy.getEmailTemplate({ emailTemplateId: 2 }).then((result) => { + emailTemplate2 = result.emailTemplate; + }); }); describe('Status actions workflow tests', () => { @@ -132,7 +143,10 @@ context('Status actions tests', () => { name: EmailStatusActionRecipients.PI, description: '', }, - emailTemplate: { id: 1, name: 'template-name-1' }, + emailTemplate: { + id: emailTemplate1?.id || 1, + name: emailTemplate1?.name || 'template-name-1', + }, combineEmails: true, }, ], @@ -223,7 +237,10 @@ context('Status actions tests', () => { name: EmailStatusActionRecipients.PI, description: '', }, - emailTemplate: { id: 1, name: 'template-name-1' }, + emailTemplate: { + id: emailTemplate1?.id || 1, + name: emailTemplate1?.name || 'template-name-1', + }, }, ], }; @@ -291,7 +308,10 @@ context('Status actions tests', () => { name: EmailStatusActionRecipients.PI, description: '', }, - emailTemplate: { id: 1, name: 'template-name-1' }, + emailTemplate: { + id: emailTemplate1?.id || 1, + name: emailTemplate1?.name || 'template-name-1', + }, }, ], }; @@ -441,7 +461,10 @@ context('Status actions tests', () => { description: 'Other email recipients manually added by their email', }, - emailTemplate: { id: 2, name: 'template-name-2' }, + emailTemplate: { + id: emailTemplate1?.id || 1, + name: emailTemplate1?.name || 'template-name-1', + }, otherRecipientEmails: [faker.internet.email()], }, ], @@ -557,8 +580,8 @@ context('Status actions tests', () => { 'Other email recipients manually added by their email', }, emailTemplate: { - id: 2, - name: 'template-name-2', + id: emailTemplate1?.id || 1, + name: emailTemplate1?.name || 'template-name-1', }, otherRecipientEmails: [statusActionEmail], }, @@ -668,7 +691,10 @@ context('Status actions tests', () => { name: EmailStatusActionRecipients.PI, description: '', }, - emailTemplate: { id: 1, name: 'template-name-1' }, + emailTemplate: { + id: emailTemplate1?.id || 1, + name: emailTemplate1?.name || 'template-name-1', + }, }, { recipient: { @@ -677,8 +703,8 @@ context('Status actions tests', () => { 'Other email recipients manually added by their email', }, emailTemplate: { - id: 2, - name: 'template-name-2', + id: emailTemplate2?.id || 1, + name: emailTemplate2?.name || 'template-name-1', }, otherRecipientEmails: [faker.internet.email()], }, diff --git a/apps/e2e/cypress/support/statusActionLogs.ts b/apps/e2e/cypress/support/statusActionLogs.ts index 76871487da..3f93c97023 100644 --- a/apps/e2e/cypress/support/statusActionLogs.ts +++ b/apps/e2e/cypress/support/statusActionLogs.ts @@ -1,3 +1,10 @@ +import { + GetEmailTemplateQuery, + GetEmailTemplateQueryVariables, +} from '@user-office-software-libs/shared-types'; + +import { getE2EApi } from './utils'; + const navigateToStatusActionLogsSubmenu = (submenuName: string) => { cy.get('body').then(($body) => { if ($body.find(`[aria-label='${submenuName}']`).length) { @@ -9,7 +16,18 @@ const navigateToStatusActionLogsSubmenu = (submenuName: string) => { }); }; +const getEmailTemplate = ( + getEmailTemplatesInput: GetEmailTemplateQueryVariables +): Cypress.Chainable => { + const api = getE2EApi(); + const request = api.getEmailTemplate(getEmailTemplatesInput); + + return cy.wrap(request); +}; + Cypress.Commands.add( 'navigateToStatusActionLogsSubmenu', navigateToStatusActionLogsSubmenu ); + +Cypress.Commands.add('getEmailTemplate', getEmailTemplate); diff --git a/apps/e2e/cypress/types/statusActionLog.d.ts b/apps/e2e/cypress/types/statusActionLog.d.ts index 21a505b8be..1ef293ec11 100644 --- a/apps/e2e/cypress/types/statusActionLog.d.ts +++ b/apps/e2e/cypress/types/statusActionLog.d.ts @@ -1,3 +1,5 @@ +import { GetEmailTemplateQuery } from '@user-office-software-libs/shared-types'; + declare global { namespace Cypress { interface Chainable { @@ -10,6 +12,18 @@ declare global { * cy.expandStatusActionLogsSubmenu() */ navigateToStatusActionLogsSubmenu: (submenuName: string) => void; + + /** + * Gets email template + * + * @returns {typeof getEmailTemplate} + * @memberof Chainable + * @example + * cy.getProposals(getProposalsInput: GetProposalsQueryVariables) + */ + getEmailTemplate: ( + getEmailTemplatesInput: GetEmailTemplateQueryVariables + ) => Cypress.Chainable; } } } From 048b88ef9daa08f7f1ecdf9b075b10d49934baa5 Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Wed, 10 Sep 2025 16:45:21 +0200 Subject: [PATCH 20/62] fix: update e2d for email templates --- .../MailService/SMTPMailService.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts b/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts index b84a82df1a..9192de5c6f 100644 --- a/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts +++ b/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs'; import path from 'path'; import { logger } from '@user-office-software/duo-logger'; @@ -23,11 +24,17 @@ export class SMTPMailService extends MailService { const attachments = []; if (process.env.EMAIL_FOOTER_IMAGE_PATH !== undefined) { - attachments.push({ - filename: 'logo.png', - path: process.env.EMAIL_FOOTER_IMAGE_PATH, - cid: 'logo1', - }); + if (existsSync(process.env.EMAIL_FOOTER_IMAGE_PATH)) { + attachments.push({ + filename: 'logo.png', + path: process.env.EMAIL_FOOTER_IMAGE_PATH, + cid: 'logo1', + }); + } else { + logger.logWarn('Email footer image path does not exist', { + path: process.env.EMAIL_FOOTER_IMAGE_PATH, + }); + } } let smtpTransport: From e5adfe1982578eafbdfa01fcc0f9f803ed1c9ed5 Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Thu, 11 Sep 2025 14:52:25 +0200 Subject: [PATCH 21/62] fix: complete failed merge --- .../email/eliEmailHandler.spec.ts | 77 ------------------- .../eventHandlers/email/eliEmailHandler.ts | 63 +++++---------- .../email/essEmailHandler.spec.ts | 10 +-- .../src/mutations/InviteMutations.spec.ts | 5 +- 4 files changed, 22 insertions(+), 133 deletions(-) diff --git a/apps/backend/src/eventHandlers/email/eliEmailHandler.spec.ts b/apps/backend/src/eventHandlers/email/eliEmailHandler.spec.ts index 90e4979d58..4edac37c25 100644 --- a/apps/backend/src/eventHandlers/email/eliEmailHandler.spec.ts +++ b/apps/backend/src/eventHandlers/email/eliEmailHandler.spec.ts @@ -8,10 +8,7 @@ import { RoleClaimDataSourceMock } from '../../datasources/mockups/RoleClaimData import { ApplicationEvent } from '../../events/applicationEvents'; import { Event } from '../../events/event.enum'; import { eliEmailHandler } from './eliEmailHandler'; - import { EmailTemplateId } from './emailTemplateId'; -import { getTemplateIdForInvite } from './essEmailHandler'; - // Mock MailService const mockMailService = { @@ -44,80 +41,6 @@ describe('eliEmailHandler', () => { }); describe('getTemplateIdForInvite', () => { - test('should return co-proposer template when co-proposer claim exists', async () => { - const inviteId = faker.number.int(); - const mockCoProposerClaim = { - inviteId, - proposalPk: faker.number.int(), - }; - - // Mock co-proposer claim exists - jest - .spyOn(coProposerDataSourceMock, 'findByInviteId') - .mockResolvedValue([mockCoProposerClaim]); - - // Mock no role claims - jest - .spyOn(roleClaimDataSourceMock, 'findByInviteId') - .mockResolvedValue([]); - - const result = await getTemplateIdForInvite(inviteId); - - expect(result).toBe( - EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_CO_PROPOSER - ); - }); - - test('should return reviewer template when internal reviewer role claim exists', async () => { - const inviteId = faker.number.int(); - const mockRoleClaim = { - roleClaimId: faker.number.int(), - inviteId, - roleId: UserRole.INTERNAL_REVIEWER, - }; - - // Mock no co-proposer claims - jest - .spyOn(coProposerDataSourceMock, 'findByInviteId') - .mockResolvedValue([]); - - // Mock internal reviewer role claim - jest - .spyOn(roleClaimDataSourceMock, 'findByInviteId') - .mockResolvedValue([mockRoleClaim]); - - const result = await getTemplateIdForInvite(inviteId); - - expect(result).toBe( - EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_REVIEWER - ); - }); - - test('should return user template when user role claim exists', async () => { - const inviteId = faker.number.int(); - const mockRoleClaim = { - roleClaimId: faker.number.int(), - inviteId, - roleId: UserRole.USER, - }; - - // Mock no co-proposer claims - jest - .spyOn(coProposerDataSourceMock, 'findByInviteId') - .mockResolvedValue([]); - - // Mock user role claim - jest - .spyOn(roleClaimDataSourceMock, 'findByInviteId') - .mockResolvedValue([mockRoleClaim]); - - const result = await getTemplateIdForInvite(inviteId); - - expect(result).toBe( - EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_USER - ); - }); - test('should use template proposal-created', async () => { // Create a mock event for PROPOSAL_CREATED const mockEvent: ApplicationEvent = { diff --git a/apps/backend/src/eventHandlers/email/eliEmailHandler.ts b/apps/backend/src/eventHandlers/email/eliEmailHandler.ts index 8db51f8b8f..08bd59d1f2 100644 --- a/apps/backend/src/eventHandlers/email/eliEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/eliEmailHandler.ts @@ -9,6 +9,7 @@ import { InviteDataSource } from '../../datasources/InviteDataSource'; import { ProposalDataSource } from '../../datasources/ProposalDataSource'; import { RedeemCodesDataSource } from '../../datasources/RedeemCodesDataSource'; import { ReviewDataSource } from '../../datasources/ReviewDataSource'; +import { RoleClaimDataSource } from '../../datasources/RoleClaimDataSource'; import { UserDataSource } from '../../datasources/UserDataSource'; import { ApplicationEvent } from '../../events/applicationEvents'; import { Event } from '../../events/event.enum'; @@ -30,6 +31,14 @@ export async function eliEmailHandler(event: ApplicationEvent) { Tokens.UserDataSource ); + const roleClaimDataSource = container.resolve( + Tokens.RoleClaimDataSource + ); + + const inviteDataSource = container.resolve( + Tokens.InviteDataSource + ); + const callDataSource = container.resolve( Tokens.CallDataSource ); @@ -136,46 +145,6 @@ export async function eliEmailHandler(event: ApplicationEvent) { return; } - - const roleInviteClaim = await roleClaimDataSource.findByInviteId( - invite.id - ); - - const templateId = getTemplateIdForRole(roleInviteClaim[0].roleId); - - const emailTemplate = - await emailTemplateDataSource.getEmailTemplateByName(templateId); - - mailService - .sendMail({ - content: { - template_id: templateId, - db_template_id: emailTemplate ? emailTemplate.id : undefined, - }, - substitution_data: { - email: invite.email, - inviterName: inviter.firstname, - inviterLastname: inviter.lastname, - redeemCode: invite.code, - }, - recipients: [{ address: invite.email }], - }) - .then(async (res) => { - await inviteDataSource.update({ - id: invite.id, - isEmailSent: true, - }); - logger.logInfo('Successful email transmission', { res }); - }) - .catch((err: string) => { - logger.logException('Failed email transmission', err); - }) - .finally(() => { - inviteDataSource.update({ - id: invite.id, - templateId: templateId, - }); - await sendInviteEmail( invite, inviter, @@ -184,10 +153,11 @@ export async function eliEmailHandler(event: ApplicationEvent) { await eventBus.publish({ ...event, type: Event.PROPOSAL_CO_PROPOSER_INVITE_EMAIL_SENT, - + invite, }); }); } + break; } @@ -429,7 +399,6 @@ export async function eliEmailHandler(event: ApplicationEvent) { } } - function getTemplateIdForRole(role: UserRole): string { switch (role) { case UserRole.USER: @@ -439,6 +408,8 @@ function getTemplateIdForRole(role: UserRole): string { default: throw new Error('No valid user role set for email invitation'); } +} + async function sendInviteEmail( invite: Invite, inviter: BasicUserDetails, @@ -448,11 +419,18 @@ async function sendInviteEmail( const inviteDataSource = container.resolve( Tokens.InviteDataSource ); + const emailTemplateDataSource = container.resolve( + Tokens.EmailTemplateDataSource + ); + + const emailTemplate = + await emailTemplateDataSource.getEmailTemplateByName(templateId); return mailService .sendMail({ content: { template_id: templateId, + db_template_id: emailTemplate?.id, }, substitution_data: { email: invite.email, @@ -474,5 +452,4 @@ async function sendInviteEmail( .catch((err: string) => { logger.logException('Failed email transmission', err); }); - } diff --git a/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts b/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts index d261c9b0a1..be8eed38ad 100644 --- a/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts +++ b/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts @@ -1,9 +1,6 @@ import { faker } from '@faker-js/faker'; - -import 'reflect-metadata'; - import { logger } from '@user-office-software/duo-logger'; - +import 'reflect-metadata'; import { container } from 'tsyringe'; import { Tokens } from '../../config/Tokens'; @@ -16,14 +13,9 @@ import { import { ApplicationEvent } from '../../events/applicationEvents'; import { Event } from '../../events/event.enum'; import { Invite } from '../../models/Invite'; - -import { MailService } from '../MailService/MailService'; import { EmailTemplateId } from './emailTemplateId'; import { essEmailHandler } from './essEmailHandler'; -import { essEmailHandler } from './essEmailHandler'; - - // Mock MailService const mockMailService = { sendMail: jest.fn(), diff --git a/apps/backend/src/mutations/InviteMutations.spec.ts b/apps/backend/src/mutations/InviteMutations.spec.ts index beb25e9e41..5c9afcf462 100644 --- a/apps/backend/src/mutations/InviteMutations.spec.ts +++ b/apps/backend/src/mutations/InviteMutations.spec.ts @@ -14,11 +14,8 @@ import { dummyUserOfficerWithRole, dummyUserWithRole, } from '../datasources/mockups/UserDataSource'; - -import { EmailTemplateId } from '../eventHandlers/email/emailTemplateId'; - import { VisitDataSourceMock } from '../datasources/mockups/VisitDataSource'; - +import { EmailTemplateId } from '../eventHandlers/email/emailTemplateId'; import { MailService } from '../eventHandlers/MailService/MailService'; import { Event } from '../events/event.enum'; import { Invite } from '../models/Invite'; From bbc250ca53f0a888727014c74e1fbec1d12a1fe3 Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Fri, 12 Sep 2025 10:05:22 +0200 Subject: [PATCH 22/62] fix: delete unnecessary db seed file --- .../db_patches/db_seeds/0007_EmailTemplates.sql | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 apps/backend/db_patches/db_seeds/0007_EmailTemplates.sql diff --git a/apps/backend/db_patches/db_seeds/0007_EmailTemplates.sql b/apps/backend/db_patches/db_seeds/0007_EmailTemplates.sql deleted file mode 100644 index fef4bb421a..0000000000 --- a/apps/backend/db_patches/db_seeds/0007_EmailTemplates.sql +++ /dev/null @@ -1,15 +0,0 @@ -DO -$DO$ -BEGIN - - INSERT INTO email_templates (created_by, name, description, subject, body) - VALUES - (0, 'template-name-1', 'template-description-1', 'template-subject-1', 'template-body-1'); - - INSERT INTO email_templates (created_by, name, description, subject, body) - VALUES - (0, 'template-name-2', 'template-description-2', 'template-subject-2', 'template-body-2'); - -END; -$DO$ -LANGUAGE plpgsql; From 2bd3ea29960eb145546ceec1dbec26571cb295cf Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Mon, 15 Sep 2025 13:47:55 +0200 Subject: [PATCH 23/62] fix: add logs --- apps/backend/src/eventHandlers/MailService/MailService.ts | 6 ++++++ .../src/eventHandlers/MailService/SMTPMailService.ts | 4 ++++ .../src/eventHandlers/MailService/SkipSendMailService.ts | 2 ++ apps/backend/src/statusActionEngine/emailActionHandler.ts | 5 +++++ 4 files changed, 17 insertions(+) diff --git a/apps/backend/src/eventHandlers/MailService/MailService.ts b/apps/backend/src/eventHandlers/MailService/MailService.ts index 585bd9df16..fd98f7b9b2 100644 --- a/apps/backend/src/eventHandlers/MailService/MailService.ts +++ b/apps/backend/src/eventHandlers/MailService/MailService.ts @@ -54,6 +54,12 @@ export abstract class MailService { const tempDir = mkdtempSync(tempDirPath); const tempFilePath = path.join(tempDir, emailTemplate.name); + logger.logInfo('Creating email template', { + templateId: options.content.db_template_id, + dbTemplateId: options.content.db_template_id, + tempFilePath, + }); + writeFileSync(tempFilePath + '.html.pug', emailTemplate.body); writeFileSync( diff --git a/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts b/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts index 9192de5c6f..90580a9b99 100644 --- a/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts +++ b/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts @@ -21,6 +21,8 @@ export class SMTPMailService extends MailService { constructor() { super(); + logger.logInfo('Initializing SMTPMailService', {}); + const attachments = []; if (process.env.EMAIL_FOOTER_IMAGE_PATH !== undefined) { @@ -115,6 +117,8 @@ export class SMTPMailService extends MailService { sendMailResults.id = 'test'; } + logger.logInfo('Preparing to send email', { ...options }); + const template = await this.getEmailTemplate(options); if (!template) { diff --git a/apps/backend/src/eventHandlers/MailService/SkipSendMailService.ts b/apps/backend/src/eventHandlers/MailService/SkipSendMailService.ts index 184291a96a..a07950a699 100644 --- a/apps/backend/src/eventHandlers/MailService/SkipSendMailService.ts +++ b/apps/backend/src/eventHandlers/MailService/SkipSendMailService.ts @@ -7,6 +7,8 @@ import { ResultsPromise } from './SparkPost'; export class SkipSendMailService extends MailService { constructor() { super(); + + logger.logInfo('Initializing SkipSendMailService', {}); } async sendMail(options: EmailSettings): ResultsPromise { diff --git a/apps/backend/src/statusActionEngine/emailActionHandler.ts b/apps/backend/src/statusActionEngine/emailActionHandler.ts index 3853c2876e..0d8be00413 100644 --- a/apps/backend/src/statusActionEngine/emailActionHandler.ts +++ b/apps/backend/src/statusActionEngine/emailActionHandler.ts @@ -460,6 +460,11 @@ const sendMail = async ( return; } + logger.logInfo( + `Preparing to send email(s) using template: ${templateMessage} (${templateMessageId}) to ${recipientsWithData.length} recipient(s).`, + { recipientsWithData } + ); + const emailTemplate = await emailTemplateDataSource.getEmailTemplate(templateMessageId); From ce7e11bdf227ae74b5aed34858e07ec437565a00 Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Mon, 15 Sep 2025 16:16:28 +0200 Subject: [PATCH 24/62] fix: change temporary template file name --- apps/backend/src/eventHandlers/MailService/MailService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/eventHandlers/MailService/MailService.ts b/apps/backend/src/eventHandlers/MailService/MailService.ts index fd98f7b9b2..66cfa2bcf8 100644 --- a/apps/backend/src/eventHandlers/MailService/MailService.ts +++ b/apps/backend/src/eventHandlers/MailService/MailService.ts @@ -52,7 +52,7 @@ export abstract class MailService { try { const tempDir = mkdtempSync(tempDirPath); - const tempFilePath = path.join(tempDir, emailTemplate.name); + const tempFilePath = path.join(tempDir, emailTemplate.id.toString()); logger.logInfo('Creating email template', { templateId: options.content.db_template_id, From be112056a2b06c4c8a5e96bb526c18050e42f947 Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Tue, 16 Sep 2025 09:03:50 +0200 Subject: [PATCH 25/62] fix: add check for template file existence --- .../backend/src/eventHandlers/MailService/MailService.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/backend/src/eventHandlers/MailService/MailService.ts b/apps/backend/src/eventHandlers/MailService/MailService.ts index 66cfa2bcf8..f633acdf1c 100644 --- a/apps/backend/src/eventHandlers/MailService/MailService.ts +++ b/apps/backend/src/eventHandlers/MailService/MailService.ts @@ -67,6 +67,15 @@ export abstract class MailService { '= `' + emailTemplate.subject + '`' ); + const templatePath = + this.getEmailTemplatePath('html', tempFilePath) + '.pug'; + + if ( + !(await (this.emailTemplates as any).templateExists(templatePath)) + ) { + return null; + } + return { name: emailTemplate.name, path: tempFilePath }; } catch (err) { logger.logWarn('Could not create email template', { From 36e43416f3fa5af6f44879b36ed62b102214fb6a Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Tue, 16 Sep 2025 10:36:46 +0200 Subject: [PATCH 26/62] fix: add logs --- .../src/eventHandlers/MailService/MailService.ts | 13 +++++++++---- .../eventHandlers/MailService/SMTPMailService.ts | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/backend/src/eventHandlers/MailService/MailService.ts b/apps/backend/src/eventHandlers/MailService/MailService.ts index f633acdf1c..489b008471 100644 --- a/apps/backend/src/eventHandlers/MailService/MailService.ts +++ b/apps/backend/src/eventHandlers/MailService/MailService.ts @@ -67,12 +67,17 @@ export abstract class MailService { '= `' + emailTemplate.subject + '`' ); - const templatePath = - this.getEmailTemplatePath('html', tempFilePath) + '.pug'; - if ( - !(await (this.emailTemplates as any).templateExists(templatePath)) + !(await (this.emailTemplates as any).templateExists( + tempFilePath + '.html.pug' + )) ) { + logger.logWarn('Email template does not exist', { + templateId: options.content.db_template_id, + dbTemplateId: options.content.db_template_id, + tempFilePath, + }); + return null; } diff --git a/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts b/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts index 90580a9b99..e7d32175c5 100644 --- a/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts +++ b/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts @@ -123,7 +123,7 @@ export class SMTPMailService extends MailService { if (!template) { logger.logError('Email template not found', { - templateId: options.content.db_template_id, + templateId: options.content.template_id, dbTemplateId: options.content.db_template_id, }); From 5d5ecb01ed215b712d936dd150226cad66b75c11 Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Tue, 16 Sep 2025 13:07:41 +0200 Subject: [PATCH 27/62] fix: update e2e tests --- apps/e2e/cypress/e2e/statusActions.cy.ts | 47 ++++++++++++------------ 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/apps/e2e/cypress/e2e/statusActions.cy.ts b/apps/e2e/cypress/e2e/statusActions.cy.ts index 62b39c8367..623814fe4b 100644 --- a/apps/e2e/cypress/e2e/statusActions.cy.ts +++ b/apps/e2e/cypress/e2e/statusActions.cy.ts @@ -2,7 +2,6 @@ import { faker } from '@faker-js/faker'; import { AllocationTimeUnits, EmailStatusActionRecipients, - EmailTemplate, FeatureId, FeatureUpdateAction, Event as PROPOSAL_EVENTS, @@ -37,12 +36,12 @@ const newCall = { let proposal1Id: string; let proposal2Id: string; -let emailTemplate1: EmailTemplate | null; -let emailTemplate2: EmailTemplate | null; +//let emailTemplate1: EmailTemplate | null; +//let emailTemplate2: EmailTemplate | null; context('Status actions tests', () => { beforeEach(function () { - cy.resetDB(true); + cy.resetDB(); cy.getAndStoreFeaturesEnabled(); cy.updateFeature({ @@ -50,13 +49,13 @@ context('Status actions tests', () => { featureIds: [FeatureId.PREGENERATED_PROPOSAL_PDF], }); - cy.getEmailTemplate({ emailTemplateId: 1 }).then((result) => { - emailTemplate1 = result.emailTemplate; - }); + // cy.getEmailTemplate({ emailTemplateId: 1 }).then((result) => { + // emailTemplate1 = result.emailTemplate; + // }); - cy.getEmailTemplate({ emailTemplateId: 2 }).then((result) => { - emailTemplate2 = result.emailTemplate; - }); + // cy.getEmailTemplate({ emailTemplateId: 2 }).then((result) => { + // emailTemplate2 = result.emailTemplate; + // }); }); describe('Status actions workflow tests', () => { @@ -144,8 +143,8 @@ context('Status actions tests', () => { description: '', }, emailTemplate: { - id: emailTemplate1?.id || 1, - name: emailTemplate1?.name || 'template-name-1', + id: 0, + name: 'template-name-1', }, combineEmails: true, }, @@ -238,8 +237,8 @@ context('Status actions tests', () => { description: '', }, emailTemplate: { - id: emailTemplate1?.id || 1, - name: emailTemplate1?.name || 'template-name-1', + id: 0, + name: 'template-name-1', }, }, ], @@ -309,8 +308,8 @@ context('Status actions tests', () => { description: '', }, emailTemplate: { - id: emailTemplate1?.id || 1, - name: emailTemplate1?.name || 'template-name-1', + id: 0, + name: 'template-name-1', }, }, ], @@ -462,8 +461,8 @@ context('Status actions tests', () => { 'Other email recipients manually added by their email', }, emailTemplate: { - id: emailTemplate1?.id || 1, - name: emailTemplate1?.name || 'template-name-1', + id: 0, + name: 'template-name-1', }, otherRecipientEmails: [faker.internet.email()], }, @@ -580,8 +579,8 @@ context('Status actions tests', () => { 'Other email recipients manually added by their email', }, emailTemplate: { - id: emailTemplate1?.id || 1, - name: emailTemplate1?.name || 'template-name-1', + id: 0, + name: 'template-name-1', }, otherRecipientEmails: [statusActionEmail], }, @@ -692,8 +691,8 @@ context('Status actions tests', () => { description: '', }, emailTemplate: { - id: emailTemplate1?.id || 1, - name: emailTemplate1?.name || 'template-name-1', + id: 0, + name: 'template-name-1', }, }, { @@ -703,8 +702,8 @@ context('Status actions tests', () => { 'Other email recipients manually added by their email', }, emailTemplate: { - id: emailTemplate2?.id || 1, - name: emailTemplate2?.name || 'template-name-1', + id: 0, + name: 'template-name-1', }, otherRecipientEmails: [faker.internet.email()], }, From f2af9bc38a762d0e104a98d21fa79364b77e45be Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Tue, 16 Sep 2025 13:28:40 +0200 Subject: [PATCH 28/62] fix: update e2e tests --- apps/e2e/cypress/e2e/statusActions.cy.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/e2e/cypress/e2e/statusActions.cy.ts b/apps/e2e/cypress/e2e/statusActions.cy.ts index 623814fe4b..405f205fed 100644 --- a/apps/e2e/cypress/e2e/statusActions.cy.ts +++ b/apps/e2e/cypress/e2e/statusActions.cy.ts @@ -143,7 +143,7 @@ context('Status actions tests', () => { description: '', }, emailTemplate: { - id: 0, + id: 100, name: 'template-name-1', }, combineEmails: true, @@ -237,7 +237,7 @@ context('Status actions tests', () => { description: '', }, emailTemplate: { - id: 0, + id: 100, name: 'template-name-1', }, }, @@ -308,7 +308,7 @@ context('Status actions tests', () => { description: '', }, emailTemplate: { - id: 0, + id: 100, name: 'template-name-1', }, }, @@ -461,7 +461,7 @@ context('Status actions tests', () => { 'Other email recipients manually added by their email', }, emailTemplate: { - id: 0, + id: 100, name: 'template-name-1', }, otherRecipientEmails: [faker.internet.email()], @@ -579,7 +579,7 @@ context('Status actions tests', () => { 'Other email recipients manually added by their email', }, emailTemplate: { - id: 0, + id: 100, name: 'template-name-1', }, otherRecipientEmails: [statusActionEmail], @@ -691,7 +691,7 @@ context('Status actions tests', () => { description: '', }, emailTemplate: { - id: 0, + id: 100, name: 'template-name-1', }, }, @@ -702,7 +702,7 @@ context('Status actions tests', () => { 'Other email recipients manually added by their email', }, emailTemplate: { - id: 0, + id: 100, name: 'template-name-1', }, otherRecipientEmails: [faker.internet.email()], From c441c9fd0ade61235211a9fa4b1380b2d531f70c Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Tue, 16 Sep 2025 15:36:38 +0200 Subject: [PATCH 29/62] fix: update test-build.yml --- .github/workflows/test-build.yml | 1 + .../db_seeds/0007_EmailTemplates.sql | 15 ++++++++ .../MailService/SMTPMailService.ts | 9 +++++ apps/e2e/cypress/e2e/statusActions.cy.ts | 37 ++++++++++--------- 4 files changed, 44 insertions(+), 18 deletions(-) create mode 100644 apps/backend/db_patches/db_seeds/0007_EmailTemplates.sql diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index db182f9a78..131288e237 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -386,6 +386,7 @@ jobs: EMAIL_AUTH_HOST: exchsmtp.stfc.ac.uk EMAIL_TEMPLATE_PATH: /config/emails/ EMAIL_FOOTER_IMAGE_PATH: /config/logos/STFC-Logo-small.png + SKIP_SMTP_EMAIL_SENDING: true EXTERNAL_AUTH_LOGIN_URL: http://localhost:9003/auth/Login.aspx EXTERNAL_AUTH_LOGOUT_URL: http://localhost:9003/auth/Login.aspx EXTERNAL_UOWS_API_URL: http://localhost:1080/users-service diff --git a/apps/backend/db_patches/db_seeds/0007_EmailTemplates.sql b/apps/backend/db_patches/db_seeds/0007_EmailTemplates.sql new file mode 100644 index 0000000000..fef4bb421a --- /dev/null +++ b/apps/backend/db_patches/db_seeds/0007_EmailTemplates.sql @@ -0,0 +1,15 @@ +DO +$DO$ +BEGIN + + INSERT INTO email_templates (created_by, name, description, subject, body) + VALUES + (0, 'template-name-1', 'template-description-1', 'template-subject-1', 'template-body-1'); + + INSERT INTO email_templates (created_by, name, description, subject, body) + VALUES + (0, 'template-name-2', 'template-description-2', 'template-subject-2', 'template-body-2'); + +END; +$DO$ +LANGUAGE plpgsql; diff --git a/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts b/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts index e7d32175c5..df1fbc2301 100644 --- a/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts +++ b/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts @@ -130,6 +130,15 @@ export class SMTPMailService extends MailService { return { results: sendMailResults }; } + if (process.env.SKIP_SMTP_EMAIL_SENDING === 'true') { + logger.logInfo('Skipping email sending', { + templateId: options.content.template_id, + dbTemplateId: options.content.db_template_id, + }); + + return { results: sendMailResults }; + } + options.recipients.forEach((participant) => { emailPromises.push( this.emailTemplates.send({ diff --git a/apps/e2e/cypress/e2e/statusActions.cy.ts b/apps/e2e/cypress/e2e/statusActions.cy.ts index 405f205fed..b1b9ce5376 100644 --- a/apps/e2e/cypress/e2e/statusActions.cy.ts +++ b/apps/e2e/cypress/e2e/statusActions.cy.ts @@ -2,6 +2,7 @@ import { faker } from '@faker-js/faker'; import { AllocationTimeUnits, EmailStatusActionRecipients, + EmailTemplate, FeatureId, FeatureUpdateAction, Event as PROPOSAL_EVENTS, @@ -36,12 +37,12 @@ const newCall = { let proposal1Id: string; let proposal2Id: string; -//let emailTemplate1: EmailTemplate | null; -//let emailTemplate2: EmailTemplate | null; +let emailTemplate1: EmailTemplate | null; +let emailTemplate2: EmailTemplate | null; context('Status actions tests', () => { beforeEach(function () { - cy.resetDB(); + cy.resetDB(true); cy.getAndStoreFeaturesEnabled(); cy.updateFeature({ @@ -49,13 +50,13 @@ context('Status actions tests', () => { featureIds: [FeatureId.PREGENERATED_PROPOSAL_PDF], }); - // cy.getEmailTemplate({ emailTemplateId: 1 }).then((result) => { - // emailTemplate1 = result.emailTemplate; - // }); + cy.getEmailTemplate({ emailTemplateId: 1 }).then((result) => { + emailTemplate1 = result.emailTemplate; + }); - // cy.getEmailTemplate({ emailTemplateId: 2 }).then((result) => { - // emailTemplate2 = result.emailTemplate; - // }); + cy.getEmailTemplate({ emailTemplateId: 2 }).then((result) => { + emailTemplate2 = result.emailTemplate; + }); }); describe('Status actions workflow tests', () => { @@ -143,7 +144,7 @@ context('Status actions tests', () => { description: '', }, emailTemplate: { - id: 100, + id: emailTemplate1?.id, name: 'template-name-1', }, combineEmails: true, @@ -237,8 +238,8 @@ context('Status actions tests', () => { description: '', }, emailTemplate: { - id: 100, - name: 'template-name-1', + id: emailTemplate2?.id, + name: 'template-name-2', }, }, ], @@ -308,8 +309,8 @@ context('Status actions tests', () => { description: '', }, emailTemplate: { - id: 100, - name: 'template-name-1', + id: emailTemplate1?.id, + name: 'PI-email-template', }, }, ], @@ -461,7 +462,7 @@ context('Status actions tests', () => { 'Other email recipients manually added by their email', }, emailTemplate: { - id: 100, + id: emailTemplate1?.id, name: 'template-name-1', }, otherRecipientEmails: [faker.internet.email()], @@ -579,7 +580,7 @@ context('Status actions tests', () => { 'Other email recipients manually added by their email', }, emailTemplate: { - id: 100, + id: emailTemplate1?.id, name: 'template-name-1', }, otherRecipientEmails: [statusActionEmail], @@ -691,7 +692,7 @@ context('Status actions tests', () => { description: '', }, emailTemplate: { - id: 100, + id: emailTemplate1?.id, name: 'template-name-1', }, }, @@ -702,7 +703,7 @@ context('Status actions tests', () => { 'Other email recipients manually added by their email', }, emailTemplate: { - id: 100, + id: emailTemplate1?.id, name: 'template-name-1', }, otherRecipientEmails: [faker.internet.email()], From 9124e4330823d08f535c74fa073a655ed481c851 Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Thu, 25 Sep 2025 10:54:34 +0200 Subject: [PATCH 30/62] fix email template create patch to deal with NULL array length --- apps/backend/db_patches/0185_CreateEmailTemplatesTable.sql | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/backend/db_patches/0185_CreateEmailTemplatesTable.sql b/apps/backend/db_patches/0185_CreateEmailTemplatesTable.sql index 91256d652b..edf1446b31 100644 --- a/apps/backend/db_patches/0185_CreateEmailTemplatesTable.sql +++ b/apps/backend/db_patches/0185_CreateEmailTemplatesTable.sql @@ -69,6 +69,10 @@ BEGIN IF connection_loop_var.config IS NOT NULL THEN SELECT jsonb_array_length(connection_loop_var.config->'recipientsWithEmailTemplate') INTO array_size_var; + IF array_size_var IS NULL THEN + array_size_var := 0; + END IF; + FOR array_loop_var IN 0..array_size_var-1 LOOP SELECT connection_loop_var.config->'recipientsWithEmailTemplate'->array_loop_var->'emailTemplate'->>'name' INTO template_name; From 3bfb6bc6274410470f0c02ef905acb3069cd3402 Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Mon, 13 Oct 2025 10:02:58 +0200 Subject: [PATCH 31/62] fix: change resetDb in statusActions.ts.cy --- apps/e2e/cypress/e2e/statusActions.cy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/e2e/cypress/e2e/statusActions.cy.ts b/apps/e2e/cypress/e2e/statusActions.cy.ts index d59ed497e3..a8502192a8 100644 --- a/apps/e2e/cypress/e2e/statusActions.cy.ts +++ b/apps/e2e/cypress/e2e/statusActions.cy.ts @@ -42,7 +42,7 @@ let emailTemplate2: EmailTemplate | null; context('Status actions tests', () => { beforeEach(function () { - cy.resetDB(true); + cy.resetDB(); cy.getAndStoreFeaturesEnabled(); cy.updateFeature({ From b3fb8a660e3c4dd8d6029d918be9f3e06e4b4924 Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Tue, 18 Nov 2025 09:43:52 +0100 Subject: [PATCH 32/62] fix: review comments --- ...sql => 0202_CreateEmailTemplatesTable.sql} | 22 +-- apps/backend/package-lock.json | 37 +++- apps/backend/package.json | 4 +- .../asyncJobs/jobs/checkInviteReminderJob.ts | 2 +- .../src/config/dependencyConfigDefault.ts | 3 +- .../backend/src/config/dependencyConfigE2E.ts | 3 +- .../backend/src/config/dependencyConfigELI.ts | 3 +- .../backend/src/config/dependencyConfigESS.ts | 3 +- .../src/config/dependencyConfigSTFC.ts | 3 +- .../src/config/dependencyConfigTest.ts | 3 +- .../postgres/EmailTemplateDataSource.ts | 28 ++- .../MailService/EmailSettings.ts | 9 +- .../eventHandlers/MailService/MailService.ts | 98 ---------- .../MailService/SMTPMailService.ts | 181 ++++++++++-------- .../eventHandlers/email/eliEmailHandler.ts | 18 +- .../eventHandlers/email/emailTemplateId.ts | 2 + .../eventHandlers/email/essEmailHandler.ts | 12 +- .../eventHandlers/email/stfcEmailHandler.ts | 7 +- .../src/mutations/FeedbackMutations.ts | 6 +- .../mutations/CreateEmailTemplateMutation.ts | 8 +- .../mutations/UpdateEmailTemplateMutation.ts | 8 +- .../src/resolvers/types/EmailTemplate.ts | 2 +- .../statusActionEngine/emailActionHandler.ts | 29 +-- .../emailTemplate/createEmailTemplate.graphql | 2 +- .../emailTemplate/updateEmailTemplate.graphql | 2 +- apps/frontend/vite.config.mts | 2 +- package-lock.json | 138 +++++++++++++ package.json | 1 + 28 files changed, 361 insertions(+), 275 deletions(-) rename apps/backend/db_patches/{0185_CreateEmailTemplatesTable.sql => 0202_CreateEmailTemplatesTable.sql} (84%) diff --git a/apps/backend/db_patches/0185_CreateEmailTemplatesTable.sql b/apps/backend/db_patches/0202_CreateEmailTemplatesTable.sql similarity index 84% rename from apps/backend/db_patches/0185_CreateEmailTemplatesTable.sql rename to apps/backend/db_patches/0202_CreateEmailTemplatesTable.sql index edf1446b31..31447b41cd 100644 --- a/apps/backend/db_patches/0185_CreateEmailTemplatesTable.sql +++ b/apps/backend/db_patches/0202_CreateEmailTemplatesTable.sql @@ -21,47 +21,47 @@ BEGIN INSERT INTO email_templates (created_by, name, description, subject, body) VALUES - (0, 'CLF PI Co-I Submission Email', 'CLF PI Co-I Submission Email', 'Proposal submitted', 'Proposal submitted'); + (0, 'CLF PI Co-I Submission Email', 'CLF PI Co-I Submission Email', '= `Proposal submitted`', 'Proposal submitted'); INSERT INTO email_templates (created_by, name, description, subject, body) VALUES - (0, 'ISIS PI Co-I Submission Email', 'ISIS PI Co-I Submission Email', 'Proposal submitted', 'Proposal submitted'); + (0, 'ISIS PI Co-I Submission Email', 'ISIS PI Co-I Submission Email', '= `Proposal submitted`', 'Proposal submitted'); INSERT INTO email_templates (created_by, name, description, subject, body) VALUES - (0, 'ISIS Rapid PI Co-I Submission Email', 'ISIS Rapid PI Co-I Submission Email', 'Proposal submitted', 'Proposal submitted'); + (0, 'ISIS Rapid PI Co-I Submission Email', 'ISIS Rapid PI Co-I Submission Email', '= `Proposal submitted`', 'Proposal submitted'); INSERT INTO email_templates (created_by, name, description, subject, body) VALUES - (0, 'ISIS Rapid User Office Submission Email', 'ISIS Rapid User Office Submission Email', 'Proposal submitted', 'Proposal submitted'); + (0, 'ISIS Rapid User Office Submission Email', 'ISIS Rapid User Office Submission Email', '= `Proposal submitted`', 'Proposal submitted'); INSERT INTO email_templates (created_by, name, description, subject, body) VALUES - (0, 'ISIS Xpress PI Co-I Submission Email', 'ISIS Xpress PI Co-I Submission Email', 'Proposal submitted', 'Proposal submitted'); + (0, 'ISIS Xpress PI Co-I Submission Email', 'ISIS Xpress PI Co-I Submission Email', '= `Proposal submitted`', 'Proposal submitted'); INSERT INTO email_templates (created_by, name, description, subject, body) VALUES - (0, 'ISIS Xpress Scientist Submission Email', 'ISIS Xpress Scientist Submission Email', 'Proposal submitted', 'Proposal submitted'); + (0, 'ISIS Xpress Scientist Submission Email', 'ISIS Xpress Scientist Submission Email', '= `Proposal submitted`', 'Proposal submitted'); INSERT INTO email_templates (created_by, name, description, subject, body) VALUES - (0, 'ISIS Xpress PI Co-I Under Review Email', 'ISIS Xpress PI Co-I Under Review Email', 'Proposal under review', 'Proposal under review'); + (0, 'ISIS Xpress PI Co-I Under Review Email', 'ISIS Xpress PI Co-I Under Review Email', '= `Proposal under review`', 'Proposal under review'); INSERT INTO email_templates (created_by, name, description, subject, body) VALUES - (0, 'ISIS Xpress PI Co-I Approval Email', 'ISIS Xpress PI Co-I Approval Email', 'Proposal approved', 'Proposal approved'); + (0, 'ISIS Xpress PI Co-I Approval Email', 'ISIS Xpress PI Co-I Approval Email', '= `Proposal approved`', 'Proposal approved'); INSERT INTO email_templates (created_by, name, description, subject, body) VALUES - (0, 'ISIS Xpress SRA Request Email', 'ISIS Xpress SRA Request Email', 'Proposal SRA request', 'Proposal SRA request'); + (0, 'ISIS Xpress SRA Request Email', 'ISIS Xpress SRA Request Email', '= `Proposal SRA request`', 'Proposal SRA request'); INSERT INTO email_templates (created_by, name, description, subject, body) VALUES - (0, 'ISIS Xpress PI Co-I Reject Email', 'ISIS Xpress PI Co-I Reject Email', 'Proposal unsuccessful', 'Proposal unsuccessful'); + (0, 'ISIS Xpress PI Co-I Reject Email', 'ISIS Xpress PI Co-I Reject Email', '= `Proposal unsuccessful`', 'Proposal unsuccessful'); INSERT INTO email_templates (created_by, name, description, subject, body) VALUES - (0, 'ISIS Xpress PI Co-I Finish Email', 'ISIS Xpress PI Co-I Finish Email', 'Proposal finished', 'Proposal finished'); + (0, 'ISIS Xpress PI Co-I Finish Email', 'ISIS Xpress PI Co-I Finish Email', '= `Proposal finished`', 'Proposal finished'); FOR connection_loop_var IN SELECT * FROM workflow_connection_has_actions diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index 9fbb583133..f67a48d7b6 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -70,6 +70,7 @@ "@types/content-disposition": "^0.5.4", "@types/cors": "^2.8.13", "@types/cron": "^1.7.3", + "@types/ejs": "^3.1.5", "@types/email-templates": "^8.0.4", "@types/express": "^4.17.13", "@types/express-jwt": "^6.0.4", @@ -82,6 +83,7 @@ "@types/node": "^22.13.10", "@types/pg": "^8.11.5", "@types/pg-large-object": "^2.0.7", + "@types/pug": "^2.0.10", "@types/sanitize-html": "^2.6.2", "@types/simple-oauth2": "^4.1.1", "@types/sinon": "^9.0.11", @@ -563,6 +565,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.2.tgz", "integrity": "sha512-n7s51eWdaWZ3vGT2tD4T7J6eJs3QoBXydv7vkUM06Bf1cbVD2Kc2UrkzhiQwobfV7NwOnQXYL7UBJ5VPU+RGoQ==", "devOptional": true, + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.22.13", @@ -2298,6 +2301,7 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -3177,6 +3181,13 @@ "moment": ">=2.14.0" } }, + "node_modules/@types/ejs": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz", + "integrity": "sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/email-templates": { "version": "8.0.4", "resolved": "https://registry.npmjs.org/@types/email-templates/-/email-templates-8.0.4.tgz", @@ -3479,6 +3490,13 @@ "node": ">=12" } }, + "node_modules/@types/pug": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.10.tgz", + "integrity": "sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/qs": { "version": "6.9.7", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", @@ -3599,6 +3617,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.7.1.tgz", "integrity": "sha512-KwfdWXJBOviaBVhxO3p5TJiLpNuh2iyXyjmWN0f1nU87pwyvfS0EmjC6ukQVYVFJd/K1+0NWGPDXiyEyQorn0Q==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "7.7.1", @@ -3634,6 +3653,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.7.1.tgz", "integrity": "sha512-vmPzBOOtz48F6JAGVS/kZYk4EkXao6iGrD838sp1w3NQQC0W8ry/q641KU4PrG7AKNAf56NOcR8GOpH8l9FPCw==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.7.1", "@typescript-eslint/types": "7.7.1", @@ -3952,6 +3972,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4517,6 +4538,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001541", "electron-to-chromium": "^1.4.535", @@ -4928,6 +4950,7 @@ "version": "0.14.0", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.0.tgz", "integrity": "sha512-ct3ltplN8I9fOwUd8GrP8UQixwff129BkEtuWDKL5W45cQuLd19xqmTLu5ge78YDm/fdje6FMt0hGOhl0lii3A==", + "peer": true, "dependencies": { "@types/validator": "^13.7.10", "libphonenumber-js": "^1.10.14", @@ -5900,6 +5923,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5955,6 +5979,7 @@ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", "dev": true, + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7218,6 +7243,7 @@ "version": "16.8.1", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.1.tgz", "integrity": "sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==", + "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -7274,6 +7300,7 @@ "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "peer": true, "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", @@ -8089,6 +8116,7 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -9139,7 +9167,8 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "peer": true }, "node_modules/lodash-es": { "version": "4.17.21", @@ -10185,6 +10214,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "peer": true, "bin": { "mustache": "bin/mustache" } @@ -11126,6 +11156,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.4.tgz", "integrity": "sha512-FWu1oLHKCrtpO1ypU6J0SbK2d9Ckwysq6bHj/uaCP26DxrPpppCLQRGVuqAxSTvhF00AcvDRyYrLNW7ocBhFFQ==", "dev": true, + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -11297,6 +11328,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/pug/-/pug-3.0.3.tgz", "integrity": "sha512-uBi6kmc9f3SZ3PXxqcHiUZLmIXgfgWooKWXcwSGwQd2Zi5Rb0bT14+8CJjJgI8AB+nndLaNgHGrcc6bPIB665g==", + "peer": true, "dependencies": { "pug-code-gen": "^3.0.3", "pug-filters": "^4.0.0", @@ -13208,7 +13240,8 @@ "node_modules/underscore": { "version": "1.13.6", "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.6.tgz", - "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==" + "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==", + "peer": true }, "node_modules/underscore.deep": { "version": "0.5.3", diff --git a/apps/backend/package.json b/apps/backend/package.json index a4e155571e..fa99a11389 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -92,6 +92,7 @@ "@types/content-disposition": "^0.5.4", "@types/cors": "^2.8.13", "@types/cron": "^1.7.3", + "@types/ejs": "^3.1.5", "@types/email-templates": "^8.0.4", "@types/express": "^4.17.13", "@types/express-jwt": "^6.0.4", @@ -104,6 +105,7 @@ "@types/node": "^22.13.10", "@types/pg": "^8.11.5", "@types/pg-large-object": "^2.0.7", + "@types/pug": "^2.0.10", "@types/sanitize-html": "^2.6.2", "@types/simple-oauth2": "^4.1.1", "@types/sinon": "^9.0.11", @@ -129,4 +131,4 @@ "npm": ">=10.9.2", "node": ">=22.0.0" } -} \ No newline at end of file +} diff --git a/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.ts b/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.ts index a893f51945..935d2dda35 100644 --- a/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.ts +++ b/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.ts @@ -124,7 +124,7 @@ const checkInviteReminder = async () => { try { await mailService.sendMail({ content: { - template_id: templateId, + template: templateId, }, substitution_data: { email: invite.email, diff --git a/apps/backend/src/config/dependencyConfigDefault.ts b/apps/backend/src/config/dependencyConfigDefault.ts index 43ba2d8b54..8f2a73cabf 100644 --- a/apps/backend/src/config/dependencyConfigDefault.ts +++ b/apps/backend/src/config/dependencyConfigDefault.ts @@ -125,6 +125,7 @@ mapClass(Tokens.WorkflowDataSource, PostgresWorkflowDataSource); mapClass(Tokens.StatusDataSource, PostgresStatusDataSource); mapClass(Tokens.ExperimentDataSource, PostgresExperimentDataSource); mapClass(Tokens.TagDataSource, PostgresTagDataSource); +mapClass(Tokens.EmailTemplateDataSource, PostgresEmailTemplateDataSource); mapClass(Tokens.UserAuthorization, OAuthAuthorization); mapClass(Tokens.ProposalAuthorization, ProposalAuthorization); @@ -150,5 +151,3 @@ mapValue(Tokens.ConfigureEnvironment, configureBaseEnvironment); mapValue(Tokens.ConfigureLogger, () => setLogger(new ConsoleLogger())); mapClass(Tokens.BasicUserDetailsLoader, BasicUserDetailsLoader); - -mapClass(Tokens.EmailTemplateDataSource, PostgresEmailTemplateDataSource); diff --git a/apps/backend/src/config/dependencyConfigE2E.ts b/apps/backend/src/config/dependencyConfigE2E.ts index 80a370abda..4336289145 100644 --- a/apps/backend/src/config/dependencyConfigE2E.ts +++ b/apps/backend/src/config/dependencyConfigE2E.ts @@ -115,6 +115,7 @@ mapClass(Tokens.StatusActionsLogsDataSource, StatusActionsLogsDataSource); mapClass(Tokens.WorkflowDataSource, PostgresWorkflowDataSource); mapClass(Tokens.StatusDataSource, PostgresStatusDataSource); mapClass(Tokens.TagDataSource, PostgresTagDataSource); +mapClass(Tokens.EmailTemplateDataSource, PostgresEmailTemplateDataSource); mapClass(Tokens.ExperimentDataSource, PostgresExperimentDataSource); mapClass(Tokens.UserAuthorization, OAuthAuthorization); @@ -141,5 +142,3 @@ mapValue(Tokens.ConfigureEnvironment, configureESSDevelopmentEnvironment); mapValue(Tokens.ConfigureLogger, () => setLogger(new ConsoleLogger())); mapClass(Tokens.BasicUserDetailsLoader, BasicUserDetailsLoader); - -mapClass(Tokens.EmailTemplateDataSource, PostgresEmailTemplateDataSource); diff --git a/apps/backend/src/config/dependencyConfigELI.ts b/apps/backend/src/config/dependencyConfigELI.ts index 80b4517cfe..03a5ccf3a0 100644 --- a/apps/backend/src/config/dependencyConfigELI.ts +++ b/apps/backend/src/config/dependencyConfigELI.ts @@ -118,6 +118,7 @@ mapClass(Tokens.StatusActionsLogsDataSource, StatusActionsLogsDataSource); mapClass(Tokens.WorkflowDataSource, PostgresWorkflowDataSource); mapClass(Tokens.StatusDataSource, PostgresStatusDataSource); mapClass(Tokens.TagDataSource, PostgresTagDataSource); +mapClass(Tokens.EmailTemplateDataSource, PostgresEmailTemplateDataSource); mapClass(Tokens.ExperimentDataSource, PostgresExperimentDataSource); mapClass(Tokens.UserAuthorization, OAuthAuthorization); @@ -148,5 +149,3 @@ mapValue(Tokens.ConfigureLogger, configureGraylogLogger); mapClass(Tokens.BasicUserDetailsLoader, BasicUserDetailsLoader); mapClass(Tokens.DataAccessUsersAuthorization, DataAccessUsersAuthorization); - -mapClass(Tokens.EmailTemplateDataSource, PostgresEmailTemplateDataSource); diff --git a/apps/backend/src/config/dependencyConfigESS.ts b/apps/backend/src/config/dependencyConfigESS.ts index 49b727c04e..71f3bc319f 100644 --- a/apps/backend/src/config/dependencyConfigESS.ts +++ b/apps/backend/src/config/dependencyConfigESS.ts @@ -119,6 +119,7 @@ mapClass(Tokens.TagDataSource, PostgresTagDataSource); mapClass(Tokens.WorkflowDataSource, PostgresWorkflowDataSource); mapClass(Tokens.StatusDataSource, PostgresStatusDataSource); mapClass(Tokens.ExperimentDataSource, PostgresExperimentDataSource); +mapClass(Tokens.EmailTemplateDataSource, PostgresEmailTemplateDataSource); mapClass(Tokens.UserAuthorization, OAuthAuthorization); mapClass(Tokens.ProposalAuthorization, ProposalAuthorization); @@ -148,5 +149,3 @@ mapValue(Tokens.ConfigureLogger, configureGraylogLogger); mapClass(Tokens.BasicUserDetailsLoader, BasicUserDetailsLoader); mapClass(Tokens.DataAccessUsersAuthorization, DataAccessUsersAuthorization); - -mapClass(Tokens.EmailTemplateDataSource, PostgresEmailTemplateDataSource); diff --git a/apps/backend/src/config/dependencyConfigSTFC.ts b/apps/backend/src/config/dependencyConfigSTFC.ts index d45a920592..9010a99850 100644 --- a/apps/backend/src/config/dependencyConfigSTFC.ts +++ b/apps/backend/src/config/dependencyConfigSTFC.ts @@ -115,6 +115,7 @@ mapClass(Tokens.StatusActionsLogsDataSource, StatusActionsLogsDataSource); mapClass(Tokens.WorkflowDataSource, PostgresWorkflowDataSource); mapClass(Tokens.StatusDataSource, PostgresStatusDataSource); mapClass(Tokens.TagDataSource, PostgresTagDataSource); +mapClass(Tokens.EmailTemplateDataSource, PostgresEmailTemplateDataSource); mapClass(Tokens.ExperimentDataSource, PostgresExperimentDataSource); mapClass(Tokens.UserAuthorization, StfcUserAuthorization); @@ -141,5 +142,3 @@ mapValue(Tokens.ConfigureEnvironment, configureSTFCEnvironment); mapValue(Tokens.ConfigureLogger, configureSTFCWinstonLogger); mapClass(Tokens.BasicUserDetailsLoader, BasicUserDetailsLoader); - -mapClass(Tokens.EmailTemplateDataSource, PostgresEmailTemplateDataSource); diff --git a/apps/backend/src/config/dependencyConfigTest.ts b/apps/backend/src/config/dependencyConfigTest.ts index 7864e0e4c9..31868e6ad4 100644 --- a/apps/backend/src/config/dependencyConfigTest.ts +++ b/apps/backend/src/config/dependencyConfigTest.ts @@ -107,6 +107,7 @@ mapClass(Tokens.VisitRegistrationAuthorization, VisitRegistrationAuthorization); mapClass(Tokens.PredefinedMessageDataSource, PredefinedMessageDataSourceMock); mapClass(Tokens.StatusActionsLogsDataSource, StatusActionsLogsDataSourceMock); mapClass(Tokens.TagDataSource, TagDataSourceMock); +mapClass(Tokens.EmailTemplateDataSource, EmailTemplateDataSourceMock); mapClass(Tokens.UserAuthorization, UserAuthorizationMock); mapClass(Tokens.ProposalAuthorization, ProposalAuthorization); @@ -129,5 +130,3 @@ mapValue(Tokens.ConfigureLogger, () => mapClass(Tokens.BasicUserDetailsLoader, BasicUserDetailsLoader); mapValue(Tokens.EventBus, createApplicationEventBus()); - -mapClass(Tokens.EmailTemplateDataSource, EmailTemplateDataSourceMock); diff --git a/apps/backend/src/datasources/postgres/EmailTemplateDataSource.ts b/apps/backend/src/datasources/postgres/EmailTemplateDataSource.ts index a5c37dc4ff..95f7142d53 100644 --- a/apps/backend/src/datasources/postgres/EmailTemplateDataSource.ts +++ b/apps/backend/src/datasources/postgres/EmailTemplateDataSource.ts @@ -39,7 +39,7 @@ export default class PostgresEmailTemplateDataSource const query = database('email_templates').select(['*']); if (filter?.filter) { - query.where('name', 'ilike', `%${filter.filter}%`); + query.whereILikeEscaped('name', '%?%', filter.filter); } if (filter?.emailTemplateIds) { @@ -84,9 +84,13 @@ export default class PostgresEmailTemplateDataSource ['*'] ) .from('email_templates') - .then((resultSet: EmailTemplateRecord[]) => - createEmailTemplateObject(resultSet[0]) - ); + .then((emailTemplates: EmailTemplateRecord[]) => { + if (emailTemplates?.length === 0) { + throw new GraphQLError(`Failed to create email template '${name}'`); + } + + return createEmailTemplateObject(emailTemplates[0]); + }); } async update( @@ -108,8 +112,14 @@ export default class PostgresEmailTemplateDataSource ) .from('email_templates') .where('email_templates.email_template_id', emailTemplateId) - .then((resultSet: EmailTemplateRecord[]) => { - return createEmailTemplateObject(resultSet[0]); + .then((emailTemplates: EmailTemplateRecord[]) => { + if (emailTemplates === undefined || emailTemplates.length !== 1) { + throw new GraphQLError( + `Failed to update email template with id '${emailTemplateId}'` + ); + } + + return createEmailTemplateObject(emailTemplates[0]); }); } @@ -119,14 +129,14 @@ export default class PostgresEmailTemplateDataSource .del() .from('email_templates') .returning('*') - .then((emailTemplate: EmailTemplateRecord[]) => { - if (emailTemplate === undefined || emailTemplate.length !== 1) { + .then((emailTemplates: EmailTemplateRecord[]) => { + if (emailTemplates === undefined || emailTemplates.length !== 1) { throw new GraphQLError( `Could not delete emailTemplate with id:${id}` ); } - return createEmailTemplateObject(emailTemplate[0]); + return createEmailTemplateObject(emailTemplates[0]); }); } } diff --git a/apps/backend/src/eventHandlers/MailService/EmailSettings.ts b/apps/backend/src/eventHandlers/MailService/EmailSettings.ts index 343e57e83a..4ec6d9cd34 100644 --- a/apps/backend/src/eventHandlers/MailService/EmailSettings.ts +++ b/apps/backend/src/eventHandlers/MailService/EmailSettings.ts @@ -149,8 +149,7 @@ export interface CreateTransmission { content: | InlineContent | { - template_id?: string; - db_template_id?: number; + template?: string; email_rfc822?: string; use_draft_template?: boolean; }; @@ -159,12 +158,10 @@ export interface CreateTransmission { export default interface EmailSettings extends CreateTransmission { content: | { - template_id: string; - db_template_id?: number; + template: string; } | { - template_id: string; - db_template_id?: number; + template: string; email_rfc822: string; }; recipients: ( diff --git a/apps/backend/src/eventHandlers/MailService/MailService.ts b/apps/backend/src/eventHandlers/MailService/MailService.ts index 489b008471..ea01a662a0 100644 --- a/apps/backend/src/eventHandlers/MailService/MailService.ts +++ b/apps/backend/src/eventHandlers/MailService/MailService.ts @@ -1,111 +1,13 @@ -import { mkdtempSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; - -import { logger } from '@user-office-software/duo-logger'; -import EmailTemplates from 'email-templates'; -import { container } from 'tsyringe'; - -import { Tokens } from '../../config/Tokens'; -import { EmailTemplateDataSource } from '../../datasources/EmailTemplateDataSource'; import EmailSettings from './EmailSettings'; import { ResultsPromise } from './SparkPost'; export abstract class MailService { - protected emailTemplates: EmailTemplates; - abstract sendMail(options: EmailSettings): ResultsPromise; abstract getEmailTemplates( includeDraft?: boolean ): ResultsPromise< (SparkPostTemplate | STFCEmailTemplate | ELIEmailTemplate)[] >; - - getEmailTemplatePath(type: string, template: string): string { - return path.join( - process.env.EMAIL_TEMPLATE_PATH || '', - `${template}.${type}` - ); - } - - async getEmailTemplate( - options: EmailSettings - ): Promise<{ name: string; path: string } | null> { - const emailTemplateDataSource = container.resolve( - Tokens.EmailTemplateDataSource - ); - - if (process.env.NODE_ENV === 'test') { - return { name: options.content.template_id, path: '' }; - } - - if (options.content.db_template_id) { - const emailTemplate = await emailTemplateDataSource.getEmailTemplate( - options.content.db_template_id - ); - - if (!emailTemplate) { - return null; - } - - const tempDirPath = path.join(tmpdir(), 'email-templates'); - - try { - const tempDir = mkdtempSync(tempDirPath); - const tempFilePath = path.join(tempDir, emailTemplate.id.toString()); - - logger.logInfo('Creating email template', { - templateId: options.content.db_template_id, - dbTemplateId: options.content.db_template_id, - tempFilePath, - }); - - writeFileSync(tempFilePath + '.html.pug', emailTemplate.body); - - writeFileSync( - tempFilePath + '.subject.pug', - '= `' + emailTemplate.subject + '`' - ); - - if ( - !(await (this.emailTemplates as any).templateExists( - tempFilePath + '.html.pug' - )) - ) { - logger.logWarn('Email template does not exist', { - templateId: options.content.db_template_id, - dbTemplateId: options.content.db_template_id, - tempFilePath, - }); - - return null; - } - - return { name: emailTemplate.name, path: tempFilePath }; - } catch (err) { - logger.logWarn('Could not create email template', { - error: err, - }); - - return null; - } - } else { - const templatePath = - this.getEmailTemplatePath('html', options.content.template_id) + '.pug'; - - if (!(await (this.emailTemplates as any).templateExists(templatePath))) { - return null; - } - - return { - name: options.content.template_id, - path: path.join( - process.env.EMAIL_TEMPLATE_PATH || '', - options.content.template_id - ), - }; - } - } } export type SparkPostTemplate = { diff --git a/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts b/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts index df1fbc2301..ce7be64965 100644 --- a/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts +++ b/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts @@ -1,4 +1,4 @@ -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import path from 'path'; import { logger } from '@user-office-software/duo-logger'; @@ -7,10 +7,12 @@ import * as nodemailer from 'nodemailer'; import { Transporter } from 'nodemailer'; import SMTPPool from 'nodemailer/lib/smtp-pool'; import SMTPTransport from 'nodemailer/lib/smtp-transport'; +import pug from 'pug'; import { container } from 'tsyringe'; import { Tokens } from '../../config/Tokens'; import { AdminDataSource } from '../../datasources/AdminDataSource'; +import { EmailTemplateDataSource } from '../../datasources/EmailTemplateDataSource'; import { SettingsId } from '../../models/Settings'; import { isProduction } from '../../utils/helperFunctions'; import EmailSettings from './EmailSettings'; @@ -18,6 +20,8 @@ import { MailService, STFCEmailTemplate, SendMailResults } from './MailService'; import { ResultsPromise } from './SparkPost'; export class SMTPMailService extends MailService { + private emailTemplate: EmailTemplates; + constructor() { super(); @@ -59,7 +63,7 @@ export class SMTPMailService extends MailService { }); } - this.emailTemplates = new EmailTemplates({ + this.emailTemplate = new EmailTemplates({ message: { from: process.env.EMAIL_SENDER, attachments, @@ -72,12 +76,78 @@ export class SMTPMailService extends MailService { relativeTo: path.resolve(process.env.EMAIL_TEMPLATE_PATH || ''), }, }, - getPath: (type, template) => { - return `${template}.${type}`; + render: (view: string, locals?: any) => { + return new Promise((resolve, reject) => { + const lastSlashIndex = view.lastIndexOf('/'); + const templateBody = + lastSlashIndex !== -1 ? view.substring(0, lastSlashIndex) : view; + + this.emailTemplate + .juiceResources(templateBody) + .then((html) => { + resolve(html); + }) + .catch((err) => { + reject(err); + }); + }); }, }); } + private getEmailTemplatePath(type: string, template: string): string { + return path.join( + process.env.EMAIL_TEMPLATE_PATH || '', + `${template}.${type}` + ); + } + + private async getEmailTemplate(options: EmailSettings): Promise<{ + subject: string; + body: string; + } | null> { + if (process.env.NODE_ENV === 'test') { + return { subject: '= `test subject`', body: 'test body' }; + } + + let templateBody = ''; + let templateSubject = ''; + + const emailTemplateDataSource = container.resolve( + Tokens.EmailTemplateDataSource + ); + + const emailTemplate = await emailTemplateDataSource.getEmailTemplateByName( + options.content.template + ); + + if (emailTemplate) { + templateBody = emailTemplate.body; + templateSubject = emailTemplate.subject; + } else { + const templateBodyPath = + this.getEmailTemplatePath('html', options.content.template) + '.pug'; + const templateSubjectPath = + this.getEmailTemplatePath('subject', options.content.template) + '.pug'; + + try { + templateBody = readFileSync(templateBodyPath, 'utf-8'); + templateSubject = readFileSync(templateSubjectPath, 'utf-8'); + } catch (error) { + logger.logError('Email template file not found', { + error: error, + }); + + return null; + } + } + + return { + subject: pug.render(templateSubject, {}), + body: pug.render(templateBody, options.substitution_data || {}), + }; + } + private getSmtpAuthOptions() { if (process.env.EMAIL_AUTH_USERNAME && process.env.EMAIL_AUTH_PASSWORD) { return { @@ -117,14 +187,11 @@ export class SMTPMailService extends MailService { sendMailResults.id = 'test'; } - logger.logInfo('Preparing to send email', { ...options }); - const template = await this.getEmailTemplate(options); if (!template) { logger.logError('Email template not found', { - templateId: options.content.template_id, - dbTemplateId: options.content.db_template_id, + template: options.content.template, }); return { results: sendMailResults }; @@ -132,38 +199,39 @@ export class SMTPMailService extends MailService { if (process.env.SKIP_SMTP_EMAIL_SENDING === 'true') { logger.logInfo('Skipping email sending', { - templateId: options.content.template_id, - dbTemplateId: options.content.db_template_id, + template: options.content.template, }); return { results: sendMailResults }; } options.recipients.forEach((participant) => { - emailPromises.push( - this.emailTemplates.send({ - template: template.path, - message: { - ...(typeof participant.address !== 'string' - ? { - to: { - address: isProduction - ? participant.address?.email - : process.env.SINK_EMAIL, - name: participant.address?.header_to, - }, - bcc: bccAddress, - } - : { - to: isProduction - ? participant.address + const emailOptions = { + message: { + ...(typeof participant.address !== 'string' + ? { + to: { + address: isProduction + ? participant.address?.email : process.env.SINK_EMAIL, - bcc: bccAddress, - }), - }, - locals: options.substitution_data, - }) - ); + name: participant.address?.header_to, + }, + bcc: bccAddress, + subject: template.subject, + } + : { + to: isProduction + ? participant.address + : process.env.SINK_EMAIL, + bcc: bccAddress, + subject: template.subject, + }), + }, + locals: options.substitution_data, + template: template.body, + }; + + emailPromises.push(this.emailTemplate.send(emailOptions)); }); return Promise.allSettled(emailPromises).then((results) => { @@ -186,52 +254,7 @@ export class SMTPMailService extends MailService { async getEmailTemplates(): ResultsPromise { return { - results: [ - { - id: 'clf-proposal-submitted-pi', - name: 'CLF PI Co-I Submission Email', - }, - { - id: 'isis-proposal-submitted-pi', - name: 'ISIS PI Co-I Submission Email', - }, - { - id: 'isis-rapid-proposal-submitted-pi', - name: 'ISIS Rapid PI Co-I Submission Email', - }, - { - id: 'isis-rapid-proposal-submitted-uo', - name: 'ISIS Rapid User Office Submission Email', - }, - { - id: 'xpress-proposal-submitted-pi', - name: 'ISIS Xpress PI Co-I Submission Email', - }, - { - id: 'xpress-proposal-submitted-sc', - name: 'ISIS Xpress Scientist Submission Email', - }, - { - id: 'xpress-proposal-under-review', - name: 'ISIS Xpress PI Co-I Under Review Email', - }, - { - id: 'xpress-proposal-approved', - name: 'ISIS Xpress PI Co-I Approval Email', - }, - { - id: 'xpress-proposal-sra', - name: 'ISIS Xpress SRA Request Email', - }, - { - id: 'xpress-proposal-unsuccessful', - name: 'ISIS Xpress PI Co-I Reject Email', - }, - { - id: 'xpress-proposal-finished', - name: 'ISIS Xpress PI Co-I Finish Email', - }, - ], + results: [], }; } } diff --git a/apps/backend/src/eventHandlers/email/eliEmailHandler.ts b/apps/backend/src/eventHandlers/email/eliEmailHandler.ts index 97c8644e9e..152ff8587b 100644 --- a/apps/backend/src/eventHandlers/email/eliEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/eliEmailHandler.ts @@ -102,8 +102,7 @@ export async function eliEmailHandler(event: ApplicationEvent) { mailService .sendMail({ content: { - template_id: templateId, - db_template_id: emailTemplate ? emailTemplate.id : undefined, + template: emailTemplate?.name || templateId || '', }, substitution_data: { firstname: user.preferredname, @@ -179,8 +178,7 @@ export async function eliEmailHandler(event: ApplicationEvent) { const options: EmailSettings = { content: { - template_id: templateId, - db_template_id: emailTemplate ? emailTemplate.id : undefined, + template: emailTemplate?.name || templateId || '', }, substitution_data: { preferredName: principalInvestigator.preferredname, @@ -237,8 +235,7 @@ export async function eliEmailHandler(event: ApplicationEvent) { mailService .sendMail({ content: { - template_id: templateId, - db_template_id: emailTemplate ? emailTemplate.id : undefined, + template: emailTemplate?.name || templateId || '', }, substitution_data: { preferredName: principalInvestigator.preferredname, @@ -279,8 +276,7 @@ export async function eliEmailHandler(event: ApplicationEvent) { mailService .sendMail({ content: { - template_id: templateId, - db_template_id: emailTemplate ? emailTemplate.id : undefined, + template: emailTemplate?.name || templateId || '', }, substitution_data: { preferredName: fapReviewer.preferredname, @@ -368,8 +364,7 @@ export async function eliEmailHandler(event: ApplicationEvent) { mailService .sendMail({ content: { - template_id: templateId, - db_template_id: emailTemplate ? emailTemplate.id : undefined, + template: emailTemplate?.name || templateId || '', }, substitution_data: { assignedByPreferredName: assignedBy.preferredname, @@ -429,8 +424,7 @@ async function sendInviteEmail( return mailService .sendMail({ content: { - template_id: templateId, - db_template_id: emailTemplate?.id, + template: emailTemplate?.name || templateId || '', }, substitution_data: { email: invite.email, diff --git a/apps/backend/src/eventHandlers/email/emailTemplateId.ts b/apps/backend/src/eventHandlers/email/emailTemplateId.ts index fc20821df3..225e319523 100644 --- a/apps/backend/src/eventHandlers/email/emailTemplateId.ts +++ b/apps/backend/src/eventHandlers/email/emailTemplateId.ts @@ -15,4 +15,6 @@ export enum EmailTemplateId { INTERNAL_REVIEW_CREATED = 'internal-review-created', INTERNAL_REVIEW_UPDATED = 'internal-review-updated', INTERNAL_REVIEW_DELETED = 'internal-review-deleted', + CALL_CREATED_EMAIL = 'call-created-email', + FEEDBACK_REQUEST = 'feedback-request', } diff --git a/apps/backend/src/eventHandlers/email/essEmailHandler.ts b/apps/backend/src/eventHandlers/email/essEmailHandler.ts index 38737e9bc1..8fc9cb41a8 100644 --- a/apps/backend/src/eventHandlers/email/essEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/essEmailHandler.ts @@ -104,7 +104,7 @@ export async function essEmailHandler(event: ApplicationEvent) { mailService .sendMail({ content: { - template_id: EmailTemplateId.CO_PROPOSER_INVITE_ACCEPTED, + template: EmailTemplateId.CO_PROPOSER_INVITE_ACCEPTED, }, substitution_data: { piPreferredname: principalInvestigator.preferredname, @@ -148,7 +148,7 @@ export async function essEmailHandler(event: ApplicationEvent) { const options: EmailSettings = { content: { - template_id: EmailTemplateId.PROPOSAL_SUBMITTED, + template: EmailTemplateId.PROPOSAL_SUBMITTED, }, substitution_data: { piPreferredname: principalInvestigator.preferredname, @@ -216,7 +216,7 @@ export async function essEmailHandler(event: ApplicationEvent) { mailService .sendMail({ content: { - template_id: templateId, + template: templateId, }, substitution_data: { piPreferredname: principalInvestigator.preferredname, @@ -337,7 +337,7 @@ export async function essEmailHandler(event: ApplicationEvent) { mailService .sendMail({ content: { - template_id: EmailTemplateId.REVIEW_REMINDER, + template: EmailTemplateId.REVIEW_REMINDER, }, substitution_data: { fapReviewerPreferredName: fapReviewer.preferredname, @@ -418,7 +418,7 @@ export async function essEmailHandler(event: ApplicationEvent) { mailService .sendMail({ content: { - template_id: templateId, + template: templateId, }, substitution_data: { preferredname: user.preferredname, @@ -474,7 +474,7 @@ async function sendInviteEmail( return mailService .sendMail({ content: { - template_id: templateId, + template: templateId, }, substitution_data: { email: invite.email, diff --git a/apps/backend/src/eventHandlers/email/stfcEmailHandler.ts b/apps/backend/src/eventHandlers/email/stfcEmailHandler.ts index 3c4c253c5d..a4abf1c09b 100644 --- a/apps/backend/src/eventHandlers/email/stfcEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/stfcEmailHandler.ts @@ -6,6 +6,7 @@ import { ApplicationEvent } from '../../events/applicationEvents'; import { Event } from '../../events/event.enum'; import EmailSettings from '../MailService/EmailSettings'; import { MailService } from '../MailService/MailService'; +import { EmailTemplateId } from './emailTemplateId'; export async function stfcEmailHandler(event: ApplicationEvent) { //test for null @@ -26,7 +27,7 @@ export async function stfcEmailHandler(event: ApplicationEvent) { return; } - const templateID = 'call-created-email'; + const notificationEmailAddress = process.env.FBS_EMAIL; const eventCallPartial = (({ shortCode, startCall, endCall }) => ({ shortCode, @@ -35,7 +36,7 @@ export async function stfcEmailHandler(event: ApplicationEvent) { }))(event.call); const emailSettings = callCreationEmail( eventCallPartial, - templateID, + EmailTemplateId.CALL_CREATED_EMAIL, notificationEmailAddress ); @@ -67,7 +68,7 @@ const callCreationEmail = function createNotificationEmail( ): EmailSettings { const emailSettings: EmailSettings = { content: { - template_id: templateID, + template: templateID, }, substitution_data: { ...notificationInput, diff --git a/apps/backend/src/mutations/FeedbackMutations.ts b/apps/backend/src/mutations/FeedbackMutations.ts index 492ede0aaf..055d4a08d1 100644 --- a/apps/backend/src/mutations/FeedbackMutations.ts +++ b/apps/backend/src/mutations/FeedbackMutations.ts @@ -9,11 +9,11 @@ import { ExperimentDataSource } from '../datasources/ExperimentDataSource'; import { FeedbackDataSource } from '../datasources/FeedbackDataSource'; import { QuestionaryDataSource } from '../datasources/QuestionaryDataSource'; import { Authorized } from '../decorators'; +import { EmailTemplateId } from '../eventHandlers/email/emailTemplateId'; import { MailService } from '../eventHandlers/MailService/MailService'; import { ExperimentStatus } from '../models/Experiment'; import { Feedback, FeedbackStatus } from '../models/Feedback'; -import { rejection } from '../models/Rejection'; -import { Rejection } from '../models/Rejection'; +import { rejection, Rejection } from '../models/Rejection'; import { Roles } from '../models/Role'; import { SettingsId } from '../models/Settings'; import { TemplateGroupId } from '../models/Template'; @@ -327,7 +327,7 @@ export default class FeedbackMutations { try { const { results } = await this.mailService.sendMail({ content: { - template_id: 'feedback-request', + template: EmailTemplateId.FEEDBACK_REQUEST, }, substitution_data: { teamleadPreferredname: teamLead.preferredname, diff --git a/apps/backend/src/resolvers/mutations/CreateEmailTemplateMutation.ts b/apps/backend/src/resolvers/mutations/CreateEmailTemplateMutation.ts index 07a2bb8ca0..edc82b954e 100644 --- a/apps/backend/src/resolvers/mutations/CreateEmailTemplateMutation.ts +++ b/apps/backend/src/resolvers/mutations/CreateEmailTemplateMutation.ts @@ -16,16 +16,16 @@ export class CreateEmailTemplateInput { @Field(() => Int) public createdByUserId: number; - @Field() + @Field(() => String) public name: string; - @Field({ nullable: true }) + @Field(() => String) public description: string; - @Field() + @Field(() => String) public subject: string; - @Field() + @Field(() => String) public body: string; } diff --git a/apps/backend/src/resolvers/mutations/UpdateEmailTemplateMutation.ts b/apps/backend/src/resolvers/mutations/UpdateEmailTemplateMutation.ts index 9de4156753..85429560a4 100644 --- a/apps/backend/src/resolvers/mutations/UpdateEmailTemplateMutation.ts +++ b/apps/backend/src/resolvers/mutations/UpdateEmailTemplateMutation.ts @@ -19,16 +19,16 @@ export class UpdateEmailTemplateInput { @Field(() => Int) public createdByUserId: number; - @Field() + @Field(() => String) public name: string; - @Field({ nullable: true }) + @Field(() => String) public description: string; - @Field() + @Field(() => String) public subject: string; - @Field() + @Field(() => String) public body: string; } diff --git a/apps/backend/src/resolvers/types/EmailTemplate.ts b/apps/backend/src/resolvers/types/EmailTemplate.ts index f3dc0d7e99..fd13dbd875 100644 --- a/apps/backend/src/resolvers/types/EmailTemplate.ts +++ b/apps/backend/src/resolvers/types/EmailTemplate.ts @@ -3,7 +3,7 @@ import { Field, Int, ObjectType } from 'type-graphql'; import { EmailTemplate as EmailTemplateOrigin } from '../../models/EmailTemplate'; @ObjectType() -export class EmailTemplate implements Partial { +export class EmailTemplate implements EmailTemplateOrigin { @Field(() => Int) public id: number; diff --git a/apps/backend/src/statusActionEngine/emailActionHandler.ts b/apps/backend/src/statusActionEngine/emailActionHandler.ts index a45091506f..6e5d852057 100644 --- a/apps/backend/src/statusActionEngine/emailActionHandler.ts +++ b/apps/backend/src/statusActionEngine/emailActionHandler.ts @@ -92,7 +92,6 @@ export const emailStatusActionRecipient = async ( ) => { const proposalPks = proposals.map((proposal) => proposal.primaryKey); const templateMessageId = recipientWithTemplate.emailTemplate.id; - const templateMessage = recipientWithTemplate.emailTemplate.name; const successfulMessage = !!statusActionsLogId ? 'Email successfully sent on status action replay' : 'Email successfully sent'; @@ -117,7 +116,6 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessage, templateMessageId, loggedInUserId ); @@ -143,7 +141,6 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessage, templateMessageId, loggedInUserId )); @@ -168,7 +165,6 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessage, templateMessageId, loggedInUserId ); @@ -193,7 +189,6 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessage, templateMessageId, loggedInUserId ); @@ -219,7 +214,6 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessage, templateMessageId, loggedInUserId ); @@ -297,7 +291,6 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessage, templateMessageId, loggedInUserId ); @@ -323,7 +316,6 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessage, templateMessageId, loggedInUserId ); @@ -384,7 +376,6 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessage, templateMessageId, loggedInUserId ); @@ -418,7 +409,6 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessage, templateMessageId, loggedInUserId ); @@ -439,7 +429,6 @@ const sendMail = async ( ) => Promise, successfulMessage: string, failMessage: string, - templateMessage: string, templateMessageId: number, loggedInUserId?: number | null ) => { @@ -462,11 +451,6 @@ const sendMail = async ( return; } - logger.logInfo( - `Preparing to send email(s) using template: ${templateMessage} (${templateMessageId}) to ${recipientsWithData.length} recipient(s).`, - { recipientsWithData } - ); - const emailTemplate = await emailTemplateDataSource.getEmailTemplate(templateMessageId); @@ -476,14 +460,19 @@ const sendMail = async ( { recipientsWithData } ); } + + logger.logInfo( + `Preparing to send email(s) using template: ${emailTemplate?.name} (${templateMessageId}) to ${recipientsWithData.length} recipient(s).`, + { recipientsWithData } + ); + try { const mailServiceResponse = await Promise.all( recipientsWithData.map(async (recipientWithData) => { try { const res = await mailService.sendMail({ content: { - template_id: '', - db_template_id: recipientWithData.templateId, + template: emailTemplate?.name || '', email_rfc822: '', }, substitution_data: { @@ -508,7 +497,7 @@ const sendMail = async ( const evt = constructProposalStatusChangeEvent( proposal, loggedInUserId || null, - `${successfulMessage} template: ${templateMessage} to: ${recipientWithData.email} recipient: ${recipientWithData.id}`, + `${successfulMessage} template: ${emailTemplate?.name} to: ${recipientWithData.email} recipient: ${recipientWithData.id}`, undefined ); emailEventHandler(evt); @@ -525,7 +514,7 @@ const sendMail = async ( const evt = constructProposalStatusChangeEvent( proposal, loggedInUserId || null, - `${failMessage} template: ${templateMessage} to: ${recipientWithData.email} recipient: ${recipientWithData.id}`, + `${failMessage} template: ${emailTemplate?.name} to: ${recipientWithData.email} recipient: ${recipientWithData.id}`, undefined ); emailEventHandler(evt); diff --git a/apps/frontend/src/graphql/emailTemplate/createEmailTemplate.graphql b/apps/frontend/src/graphql/emailTemplate/createEmailTemplate.graphql index 94517b7731..8301b3ec68 100644 --- a/apps/frontend/src/graphql/emailTemplate/createEmailTemplate.graphql +++ b/apps/frontend/src/graphql/emailTemplate/createEmailTemplate.graphql @@ -1,7 +1,7 @@ mutation createEmailTemplate( $createdByUserId: Int! $name: String! - $description: String + $description: String! $subject: String! $body: String! ) { diff --git a/apps/frontend/src/graphql/emailTemplate/updateEmailTemplate.graphql b/apps/frontend/src/graphql/emailTemplate/updateEmailTemplate.graphql index 59bc50efcf..01693de5b3 100644 --- a/apps/frontend/src/graphql/emailTemplate/updateEmailTemplate.graphql +++ b/apps/frontend/src/graphql/emailTemplate/updateEmailTemplate.graphql @@ -2,7 +2,7 @@ mutation updateEmailTemplate( $id: Int! $createdByUserId: Int! $name: String! - $description: String + $description: String! $subject: String! $body: String! ) { diff --git a/apps/frontend/vite.config.mts b/apps/frontend/vite.config.mts index 4bf15b0f00..3a748059d6 100644 --- a/apps/frontend/vite.config.mts +++ b/apps/frontend/vite.config.mts @@ -1,8 +1,8 @@ import react from '@vitejs/plugin-react'; import { defineConfig, - loadEnv, splitVendorChunkPlugin, + loadEnv, UserConfig, } from 'vite'; diff --git a/package-lock.json b/package-lock.json index 8b6982ff47..ce07bd7b10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "license": "ISC", "dependencies": { "concurrently": "^9.0.1", + "ejs": "^3.1.10", "wait-on": "^9.0.1" }, "devDependencies": { @@ -124,6 +125,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -139,6 +146,21 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -369,6 +391,21 @@ "node": ">= 0.4" } }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -441,6 +478,15 @@ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "dev": true }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -637,6 +683,23 @@ "node": ">=0.12.0" } }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/joi": { "version": "18.0.1", "resolved": "https://registry.npmjs.org/joi/-/joi-18.0.1.tgz", @@ -929,6 +992,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -964,6 +1039,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -1331,6 +1412,11 @@ "color-convert": "^2.0.1" } }, + "async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==" + }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1346,6 +1432,19 @@ "proxy-from-env": "^1.1.0" } }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "requires": { + "balanced-match": "^1.0.0" + } + }, "braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -1506,6 +1605,14 @@ "gopd": "^1.2.0" } }, + "ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "requires": { + "jake": "^10.8.5" + } + }, "emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -1557,6 +1664,14 @@ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "dev": true }, + "filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "requires": { + "minimatch": "^5.0.1" + } + }, "fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1673,6 +1788,16 @@ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true }, + "jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "requires": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + } + }, "joi": { "version": "18.0.1", "resolved": "https://registry.npmjs.org/joi/-/joi-18.0.1.tgz", @@ -1870,6 +1995,14 @@ "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", "dev": true }, + "minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "requires": { + "brace-expansion": "^2.0.1" + } + }, "minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -1890,6 +2023,11 @@ "mimic-function": "^5.0.0" } }, + "picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, "picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", diff --git a/package.json b/package.json index fe5e700965..56146f8949 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ ], "dependencies": { "concurrently": "^9.0.1", + "ejs": "^3.1.10", "wait-on": "^9.0.1" }, "devDependencies": { From d2657cad6d02cfcab222f7b1c10b0c481eeba103 Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Wed, 19 Nov 2025 16:06:00 +0100 Subject: [PATCH 33/62] fix: build error --- .../src/eventHandlers/MailService/SMTPMailService.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/eventHandlers/MailService/SMTPMailService.spec.ts b/apps/backend/src/eventHandlers/MailService/SMTPMailService.spec.ts index 48f57ccd84..4ab033a6d7 100644 --- a/apps/backend/src/eventHandlers/MailService/SMTPMailService.spec.ts +++ b/apps/backend/src/eventHandlers/MailService/SMTPMailService.spec.ts @@ -21,7 +21,7 @@ test('Return result should indicate all emails were successfully sent', async () const options: EmailSettings = { content: { - template_id: path.resolve('src', 'eventHandlers', 'emails', 'submit'), + template: path.resolve('src', 'eventHandlers', 'emails', 'submit'), }, substitution_data: { piPreferredname: 'John', @@ -75,7 +75,7 @@ test('All emails with bcc were successfully sent', async () => { const options: EmailSettings = { content: { - template_id: path.resolve('src', 'eventHandlers', 'emails', 'submit'), + template: path.resolve('src', 'eventHandlers', 'emails', 'submit'), }, substitution_data: substitutionData, recipients: [ From b599dc2d962f9bb80c1625b845bc2893991ea8da Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Mon, 24 Nov 2025 14:49:58 +0100 Subject: [PATCH 34/62] fix: review comments --- .../mockups/EmailTemplateDataSource.ts | 71 ++++++++++++++++++- .../eventHandlers/MailService/MailService.ts | 2 +- .../MailService/SMTPMailService.ts | 30 +++++--- .../email/eliEmailHandler.spec.ts | 24 +++---- .../email/essEmailHandler.spec.ts | 8 +-- .../src/mutations/InviteMutations.spec.ts | 4 +- .../src/queries/StatusActionQueries.ts | 7 +- .../src/resolvers/types/StatusAction.ts | 2 +- .../src/resolvers/types/StatusActionConfig.ts | 15 ++-- .../statusActionEngine/emailActionHandler.ts | 30 ++++---- .../statusActionEngine/statusActionUtils.ts | 2 - .../settings/workflow/EmailActionConfig.tsx | 10 +-- .../fragment.statusActionConfig.graphql | 3 +- .../hooks/settings/useStatusActionsData.ts | 6 +- 14 files changed, 139 insertions(+), 75 deletions(-) diff --git a/apps/backend/src/datasources/mockups/EmailTemplateDataSource.ts b/apps/backend/src/datasources/mockups/EmailTemplateDataSource.ts index 140bebf0d7..ff00dcc0f5 100644 --- a/apps/backend/src/datasources/mockups/EmailTemplateDataSource.ts +++ b/apps/backend/src/datasources/mockups/EmailTemplateDataSource.ts @@ -1,3 +1,4 @@ +import { EmailTemplateId } from '../../eventHandlers/email/emailTemplateId'; import { EmailTemplate } from '../../models/EmailTemplate'; import { EmailTemplatesFilter } from '../../resolvers/queries/EmailTemplatesQuery'; import { EmailTemplateDataSource } from '../EmailTemplateDataSource'; @@ -28,6 +29,70 @@ export class EmailTemplateDataSourceMock implements EmailTemplateDataSource { 'Welcome to Our Service', 'Hello, thank you for signing up for our service. We are excited to have you on board!' ), + new EmailTemplate( + 1, + 2, + EmailTemplateId.PROPOSAL_CREATED, + 'This is a dummy email template for testing purposes.', + 'Welcome to Our Service', + 'Hello, thank you for signing up for our service. We are excited to have you on board!' + ), + new EmailTemplate( + 1, + 2, + EmailTemplateId.ACCEPTED_PROPOSAL, + 'This is a dummy email template for testing purposes.', + 'Welcome to Our Service', + 'Hello, thank you for signing up for our service. We are excited to have you on board!' + ), + new EmailTemplate( + 1, + 2, + EmailTemplateId.REJECTED_PROPOSAL, + 'This is a dummy email template for testing purposes.', + 'Welcome to Our Service', + 'Hello, thank you for signing up for our service. We are excited to have you on board!' + ), + new EmailTemplate( + 1, + 2, + EmailTemplateId.RESERVED_PROPOSAL, + 'This is a dummy email template for testing purposes.', + 'Welcome to Our Service', + 'Hello, thank you for signing up for our service. We are excited to have you on board!' + ), + new EmailTemplate( + 1, + 2, + EmailTemplateId.REVIEW_REMINDER, + 'This is a dummy email template for testing purposes.', + 'Welcome to Our Service', + 'Hello, thank you for signing up for our service. We are excited to have you on board!' + ), + new EmailTemplate( + 1, + 2, + EmailTemplateId.INTERNAL_REVIEW_CREATED, + 'This is a dummy email template for testing purposes.', + 'Welcome to Our Service', + 'Hello, thank you for signing up for our service. We are excited to have you on board!' + ), + new EmailTemplate( + 1, + 2, + EmailTemplateId.INTERNAL_REVIEW_DELETED, + 'This is a dummy email template for testing purposes.', + 'Welcome to Our Service', + 'Hello, thank you for signing up for our service. We are excited to have you on board!' + ), + new EmailTemplate( + 1, + 2, + EmailTemplateId.INTERNAL_REVIEW_UPDATED, + 'This is a dummy email template for testing purposes.', + 'Welcome to Our Service', + 'Hello, thank you for signing up for our service. We are excited to have you on board!' + ), ]; } @@ -41,11 +106,13 @@ export class EmailTemplateDataSourceMock implements EmailTemplateDataSource { } async getEmailTemplate(id: number): Promise { - return dummyEmailTemplate; + return this.emailTemplates.find((e) => e.id == id) || dummyEmailTemplate; } async getEmailTemplateByName(name: string): Promise { - return dummyEmailTemplate; + return ( + this.emailTemplates.find((e) => e.name == name) || dummyEmailTemplate + ); } async create( diff --git a/apps/backend/src/eventHandlers/MailService/MailService.ts b/apps/backend/src/eventHandlers/MailService/MailService.ts index ea01a662a0..cab1bdbb26 100644 --- a/apps/backend/src/eventHandlers/MailService/MailService.ts +++ b/apps/backend/src/eventHandlers/MailService/MailService.ts @@ -28,7 +28,7 @@ export type STFCEmailTemplate = { }; export type ELIEmailTemplate = { - id: string; + id: number; name: string; }; diff --git a/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts b/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts index ce7be64965..f6ad053503 100644 --- a/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts +++ b/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts @@ -16,17 +16,22 @@ import { EmailTemplateDataSource } from '../../datasources/EmailTemplateDataSour import { SettingsId } from '../../models/Settings'; import { isProduction } from '../../utils/helperFunctions'; import EmailSettings from './EmailSettings'; -import { MailService, STFCEmailTemplate, SendMailResults } from './MailService'; +import { ELIEmailTemplate, MailService, SendMailResults } from './MailService'; import { ResultsPromise } from './SparkPost'; export class SMTPMailService extends MailService { private emailTemplate: EmailTemplates; + private emailTemplateDataSource: EmailTemplateDataSource; constructor() { super(); logger.logInfo('Initializing SMTPMailService', {}); + this.emailTemplateDataSource = container.resolve( + Tokens.EmailTemplateDataSource + ); + const attachments = []; if (process.env.EMAIL_FOOTER_IMAGE_PATH !== undefined) { @@ -107,19 +112,16 @@ export class SMTPMailService extends MailService { body: string; } | null> { if (process.env.NODE_ENV === 'test') { - return { subject: '= `test subject`', body: 'test body' }; + return { subject: '= ``', body: '' }; } let templateBody = ''; let templateSubject = ''; - const emailTemplateDataSource = container.resolve( - Tokens.EmailTemplateDataSource - ); - - const emailTemplate = await emailTemplateDataSource.getEmailTemplateByName( - options.content.template - ); + const emailTemplate = + await this.emailTemplateDataSource.getEmailTemplateByName( + options.content.template + ); if (emailTemplate) { templateBody = emailTemplate.body; @@ -252,9 +254,15 @@ export class SMTPMailService extends MailService { }); } - async getEmailTemplates(): ResultsPromise { + async getEmailTemplates(): ResultsPromise { + const emailTemplates = + await this.emailTemplateDataSource.getEmailTemplates(); + return { - results: [], + results: emailTemplates.emailTemplates.map((template) => ({ + id: template.id, + name: template.name || '', + })), }; } } diff --git a/apps/backend/src/eventHandlers/email/eliEmailHandler.spec.ts b/apps/backend/src/eventHandlers/email/eliEmailHandler.spec.ts index 4edac37c25..626f02a6d8 100644 --- a/apps/backend/src/eventHandlers/email/eliEmailHandler.spec.ts +++ b/apps/backend/src/eventHandlers/email/eliEmailHandler.spec.ts @@ -62,8 +62,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - db_template_id: 1, - template_id: EmailTemplateId.PROPOSAL_CREATED, + template: EmailTemplateId.PROPOSAL_CREATED, }, }) ); @@ -92,8 +91,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - db_template_id: 1, - template_id: EmailTemplateId.ACCEPTED_PROPOSAL, + template: EmailTemplateId.ACCEPTED_PROPOSAL, }, }) ); @@ -122,8 +120,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - db_template_id: 1, - template_id: EmailTemplateId.REJECTED_PROPOSAL, + template: EmailTemplateId.REJECTED_PROPOSAL, }, }) ); @@ -152,8 +149,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - db_template_id: 1, - template_id: EmailTemplateId.RESERVED_PROPOSAL, + template: EmailTemplateId.RESERVED_PROPOSAL, }, }) ); @@ -178,8 +174,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - db_template_id: 1, - template_id: EmailTemplateId.REVIEW_REMINDER, + template: EmailTemplateId.REVIEW_REMINDER, }, }) ); @@ -212,8 +207,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - db_template_id: 1, - template_id: EmailTemplateId.INTERNAL_REVIEW_CREATED, + template: EmailTemplateId.INTERNAL_REVIEW_CREATED, }, }) ); @@ -246,8 +240,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - db_template_id: 1, - template_id: EmailTemplateId.INTERNAL_REVIEW_UPDATED, + template: EmailTemplateId.INTERNAL_REVIEW_UPDATED, }, }) ); @@ -280,8 +273,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - db_template_id: 1, - template_id: EmailTemplateId.INTERNAL_REVIEW_DELETED, + template: EmailTemplateId.INTERNAL_REVIEW_DELETED, }, }) ); diff --git a/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts b/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts index 79b91c7b2c..080c7ebdc3 100644 --- a/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts +++ b/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts @@ -6,8 +6,8 @@ import { container } from 'tsyringe'; import { Tokens } from '../../config/Tokens'; import { CoProposerClaimDataSourceMock } from '../../datasources/mockups/CoProposerClaimDataSource'; import { - ProposalDataSourceMock, dummyProposal, + ProposalDataSourceMock, } from '../../datasources/mockups/ProposalDataSource'; import { basicDummyUser, @@ -84,7 +84,7 @@ describe('essEmailHandler co-proposer invites', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith({ content: { - template_id: EmailTemplateId.CO_PROPOSER_INVITE_ACCEPTED, + template: EmailTemplateId.CO_PROPOSER_INVITE_ACCEPTED, }, substitution_data: { piPreferredname: expect.any(String), @@ -135,7 +135,7 @@ describe('essEmailHandler co-proposer invites', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith({ content: { - template_id: + template: EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_VISIT_REGISTRATION, }, substitution_data: { @@ -330,7 +330,7 @@ describe('essEmailHandler co-proposer invites', () => { expect(sendMailsSpy).toHaveBeenCalledTimes(1); const arg = sendMailsSpy.mock.calls[0][0]; - expect(arg.content.template_id).toBe(EmailTemplateId.PROPOSAL_SUBMITTED); + expect(arg.content.template).toBe(EmailTemplateId.PROPOSAL_SUBMITTED); // Recipients: first is PI, rest are co-proposers with header_to pointing to PI expect(arg.recipients).toEqual([ diff --git a/apps/backend/src/mutations/InviteMutations.spec.ts b/apps/backend/src/mutations/InviteMutations.spec.ts index 3664b5e164..5255dfd28f 100644 --- a/apps/backend/src/mutations/InviteMutations.spec.ts +++ b/apps/backend/src/mutations/InviteMutations.spec.ts @@ -238,7 +238,7 @@ describe('Test Invite Mutations', () => { expect.objectContaining({ recipients: [{ address: email }], content: { - template_id: + template: EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_CO_PROPOSER, }, }) @@ -310,7 +310,7 @@ describe('Test Invite Mutations', () => { expect.objectContaining({ recipients: [{ address: email }], content: { - template_id: + template: EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_VISIT_REGISTRATION, }, }) diff --git a/apps/backend/src/queries/StatusActionQueries.ts b/apps/backend/src/queries/StatusActionQueries.ts index 4ba3a4c70d..7ba6490ffc 100644 --- a/apps/backend/src/queries/StatusActionQueries.ts +++ b/apps/backend/src/queries/StatusActionQueries.ts @@ -63,11 +63,14 @@ export default class StatusActionQueries { })); const emailTemplatesResult = - await this.emailTemplateDataSource.getEmailTemplates(); + await this.emailService.getEmailTemplates(); return new EmailActionDefaultConfig( allEmailRecipients, - emailTemplatesResult.emailTemplates + emailTemplatesResult.results.map((e) => ({ + id: e.name, + name: e.name, + })) ); case StatusActionType.RABBITMQ: diff --git a/apps/backend/src/resolvers/types/StatusAction.ts b/apps/backend/src/resolvers/types/StatusAction.ts index 95df315dd5..ca7d69bbad 100644 --- a/apps/backend/src/resolvers/types/StatusAction.ts +++ b/apps/backend/src/resolvers/types/StatusAction.ts @@ -15,8 +15,8 @@ import { } from '../../models/StatusAction'; import { EmailActionDefaultConfig, - StatusActionDefaultConfig, RabbitMQActionDefaultConfig, + StatusActionDefaultConfig, } from './StatusActionConfig'; @ObjectType() diff --git a/apps/backend/src/resolvers/types/StatusActionConfig.ts b/apps/backend/src/resolvers/types/StatusActionConfig.ts index 9034c14762..9f70884d99 100644 --- a/apps/backend/src/resolvers/types/StatusActionConfig.ts +++ b/apps/backend/src/resolvers/types/StatusActionConfig.ts @@ -1,6 +1,4 @@ -import { Field, Int, ObjectType, createUnionType } from 'type-graphql'; - -import { EmailTemplate } from './EmailTemplate'; +import { Field, ObjectType, createUnionType } from 'type-graphql'; export enum EmailStatusActionRecipients { PI = 'PI', @@ -57,9 +55,8 @@ export class EmailStatusActionRecipient { } @ObjectType() export class EmailStatusActionEmailTemplate { - @Field(() => Int) - public id: number; - + @Field(() => String) + public id: string; @Field(() => String) public name: string; } @@ -104,7 +101,7 @@ export const StatusActionConfig = createUnionType({ export class EmailActionDefaultConfig extends StatusActionConfigBase { constructor( recipients: EmailStatusActionRecipient[], - emailTemplates: EmailTemplate[] + emailTemplates: EmailStatusActionEmailTemplate[] ) { super(); this.recipients = recipients; @@ -114,8 +111,8 @@ export class EmailActionDefaultConfig extends StatusActionConfigBase { @Field(() => [EmailStatusActionRecipient]) recipients: EmailStatusActionRecipient[]; - @Field(() => [EmailTemplate]) - emailTemplates: EmailTemplate[]; + @Field(() => [EmailStatusActionEmailTemplate]) + emailTemplates: EmailStatusActionEmailTemplate[]; } @ObjectType() diff --git a/apps/backend/src/statusActionEngine/emailActionHandler.ts b/apps/backend/src/statusActionEngine/emailActionHandler.ts index 6e5d852057..7360f72183 100644 --- a/apps/backend/src/statusActionEngine/emailActionHandler.ts +++ b/apps/backend/src/statusActionEngine/emailActionHandler.ts @@ -91,7 +91,7 @@ export const emailStatusActionRecipient = async ( loggedInUserId?: number | null ) => { const proposalPks = proposals.map((proposal) => proposal.primaryKey); - const templateMessageId = recipientWithTemplate.emailTemplate.id; + const emailTemplateName = recipientWithTemplate.emailTemplate.name; const successfulMessage = !!statusActionsLogId ? 'Email successfully sent on status action replay' : 'Email successfully sent'; @@ -116,7 +116,7 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessageId, + emailTemplateName, loggedInUserId ); @@ -141,7 +141,7 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessageId, + emailTemplateName, loggedInUserId )); @@ -165,7 +165,7 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessageId, + emailTemplateName, loggedInUserId ); @@ -189,7 +189,7 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessageId, + emailTemplateName, loggedInUserId ); @@ -214,7 +214,7 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessageId, + emailTemplateName, loggedInUserId ); @@ -252,7 +252,6 @@ export const emailStatusActionRecipient = async ( email: userOfficeEmail, proposals: proposals, template: recipientWithTemplate.emailTemplate.name, - templateId: recipientWithTemplate.emailTemplate.id, }, ]; } else { @@ -291,7 +290,7 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessageId, + emailTemplateName, loggedInUserId ); @@ -316,7 +315,7 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessageId, + emailTemplateName, loggedInUserId ); @@ -352,7 +351,6 @@ export const emailStatusActionRecipient = async ( email: experimentSafetyEmail, proposals: proposals, template: recipientWithTemplate.emailTemplate.name, - templateId: recipientWithTemplate.emailTemplate.id, }, ]; } else { @@ -376,7 +374,7 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessageId, + emailTemplateName, loggedInUserId ); @@ -409,7 +407,7 @@ export const emailStatusActionRecipient = async ( }), successfulMessage, failMessage, - templateMessageId, + emailTemplateName, loggedInUserId ); } @@ -429,7 +427,7 @@ const sendMail = async ( ) => Promise, successfulMessage: string, failMessage: string, - templateMessageId: number, + emailTemplateName: string, loggedInUserId?: number | null ) => { const mailService = container.resolve(Tokens.MailService); @@ -452,17 +450,17 @@ const sendMail = async ( } const emailTemplate = - await emailTemplateDataSource.getEmailTemplate(templateMessageId); + await emailTemplateDataSource.getEmailTemplateByName(emailTemplateName); if (!emailTemplate) { logger.logInfo( - `Could not send email(s) because the email template with id ${templateMessageId} does not exist.`, + `Could not send email(s) because the email template with id ${emailTemplateName} does not exist.`, { recipientsWithData } ); } logger.logInfo( - `Preparing to send email(s) using template: ${emailTemplate?.name} (${templateMessageId}) to ${recipientsWithData.length} recipient(s).`, + `Preparing to send email(s) using template: ${emailTemplate?.name} (${emailTemplateName}) to ${recipientsWithData.length} recipient(s).`, { recipientsWithData } ); diff --git a/apps/backend/src/statusActionEngine/statusActionUtils.ts b/apps/backend/src/statusActionEngine/statusActionUtils.ts index f23d699f39..eb1a7352ef 100644 --- a/apps/backend/src/statusActionEngine/statusActionUtils.ts +++ b/apps/backend/src/statusActionEngine/statusActionUtils.ts @@ -59,7 +59,6 @@ export const groupProposalsByProperties = ( export type EmailReadyType = { id: EmailStatusActionRecipients; proposals: WorkflowEngineProposalType[]; - templateId: number; template: string; email: string; firstName?: string; @@ -216,7 +215,6 @@ export const getEmailReadyArrayOfUsersAndProposals = async ( id: recipientsWithEmailTemplate.recipient.name, proposals: [proposal], template: recipientsWithEmailTemplate.emailTemplate.name, - templateId: recipientsWithEmailTemplate.emailTemplate.id, email: recipient.email, firstName: recipient.firstname, lastName: recipient.lastname, diff --git a/apps/frontend/src/components/settings/workflow/EmailActionConfig.tsx b/apps/frontend/src/components/settings/workflow/EmailActionConfig.tsx index 9142bb9cea..23e06ea946 100644 --- a/apps/frontend/src/components/settings/workflow/EmailActionConfig.tsx +++ b/apps/frontend/src/components/settings/workflow/EmailActionConfig.tsx @@ -15,15 +15,15 @@ import * as Yup from 'yup'; import { EmailActionConfig as EmailActionConfigType, + EmailStatusActionEmailTemplate, EmailStatusActionRecipient, EmailStatusActionRecipients, - EmailTemplate, } from 'generated/sdk'; type EmailActionConfigProps = { emailStatusActionConfig: EmailActionConfigType; recipients: EmailStatusActionRecipient[]; - emailTemplates: EmailTemplate[]; + emailTemplates: EmailStatusActionEmailTemplate[]; isRecipientRequired?: boolean; isEmailTemplateRequired?: boolean; }; @@ -177,9 +177,9 @@ const EmailActionConfig = ({ return ( emailTemplates.find( (template) => - template.id === + template.name === recipientsWithEmailTemplate[foundRecipientWithEmailTemplateIndex] - ?.emailTemplate?.id + ?.emailTemplate?.name ) || null ); } else { @@ -254,7 +254,7 @@ const EmailActionConfig = ({ options={emailTemplates || []} getOptionLabel={(option) => option.name} isOptionEqualToValue={(option, value) => - option.id === value.id + option.name === value.name } renderInput={(params) => ( Date: Mon, 24 Nov 2025 14:59:37 +0100 Subject: [PATCH 35/62] fix: email template delete --- .../src/datasources/StatusActionsDataSource.ts | 2 +- .../mockups/StatusActionsDataSource.ts | 2 +- .../postgres/StatusActionsDataSource.ts | 4 ++-- .../src/mutations/EmailTemplateMutations.ts | 16 ++++++++-------- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/backend/src/datasources/StatusActionsDataSource.ts b/apps/backend/src/datasources/StatusActionsDataSource.ts index c73a623bbd..245d7e7091 100644 --- a/apps/backend/src/datasources/StatusActionsDataSource.ts +++ b/apps/backend/src/datasources/StatusActionsDataSource.ts @@ -14,7 +14,7 @@ export interface StatusActionsDataSource { statusActionId: number ): Promise; hasEmailTemplateIdConnectionStatusAction( - emailTemplateId: number + emailTemplateName: string ): Promise; updateConnectionStatusAction( diff --git a/apps/backend/src/datasources/mockups/StatusActionsDataSource.ts b/apps/backend/src/datasources/mockups/StatusActionsDataSource.ts index 7b78d90e92..d9d18f03c7 100644 --- a/apps/backend/src/datasources/mockups/StatusActionsDataSource.ts +++ b/apps/backend/src/datasources/mockups/StatusActionsDataSource.ts @@ -48,7 +48,7 @@ export class StatusActionsDataSourceMock implements StatusActionsDataSource { } async hasEmailTemplateIdConnectionStatusAction( - emailTemplateId: number + emailTemplateName: string ): Promise { return false; } diff --git a/apps/backend/src/datasources/postgres/StatusActionsDataSource.ts b/apps/backend/src/datasources/postgres/StatusActionsDataSource.ts index 80b5b2a06a..c80b264def 100644 --- a/apps/backend/src/datasources/postgres/StatusActionsDataSource.ts +++ b/apps/backend/src/datasources/postgres/StatusActionsDataSource.ts @@ -132,10 +132,10 @@ export default class PostgresStatusActionsDataSource } async hasEmailTemplateIdConnectionStatusAction( - emailTemplateId: number + emailTemplateName: string ): Promise { const fromClause = "config->'recipientsWithEmailTemplate'"; - const pattern = `\'[{"emailTemplate": {"id": ${emailTemplateId}}}]\'`; + const pattern = `\'[{"emailTemplate": {"name": "${emailTemplateName}"}}]\'`; const countResult = await database.raw( `select count(*) from workflow_connection_has_actions where ${fromClause} @> ${pattern}` ); diff --git a/apps/backend/src/mutations/EmailTemplateMutations.ts b/apps/backend/src/mutations/EmailTemplateMutations.ts index 74490578b5..41f352b9ac 100644 --- a/apps/backend/src/mutations/EmailTemplateMutations.ts +++ b/apps/backend/src/mutations/EmailTemplateMutations.ts @@ -75,9 +75,16 @@ export default class EmailTemplateMutations { agent: UserWithRole | null, { emailTemplateId }: { emailTemplateId: number } ): Promise { + const emailTemplate = + await this.dataSource.getEmailTemplate(emailTemplateId); + + if (!emailTemplate) { + return rejection('Email template not found', { emailTemplateId }); + } + const has = await this.statusActionsDataSource.hasEmailTemplateIdConnectionStatusAction( - emailTemplateId + emailTemplate.name ); if (has) { @@ -87,13 +94,6 @@ export default class EmailTemplateMutations { ); } - const emailTemplate = - await this.dataSource.getEmailTemplate(emailTemplateId); - - if (!emailTemplate) { - return rejection('Email template not found', { emailTemplateId }); - } - try { const result = await this.dataSource.delete(emailTemplateId); From 039f3c8121f2c6c08e4fcbe1bf2dbde4875c222b Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Mon, 24 Nov 2025 15:46:50 +0100 Subject: [PATCH 36/62] fix: backend test --- .../src/eventHandlers/MailService/SMTPMailService.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/backend/src/eventHandlers/MailService/SMTPMailService.spec.ts b/apps/backend/src/eventHandlers/MailService/SMTPMailService.spec.ts index 4ab033a6d7..56d1d88a08 100644 --- a/apps/backend/src/eventHandlers/MailService/SMTPMailService.spec.ts +++ b/apps/backend/src/eventHandlers/MailService/SMTPMailService.spec.ts @@ -93,6 +93,7 @@ test('All emails with bcc were successfully sent', async () => { message: { to: process.env.SINK_EMAIL, bcc: bccEmail, + subject: '= ``', }, locals: substitutionData, }); From 60aa48cbdcff63614c28110a24732ed3179396e8 Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Tue, 25 Nov 2025 09:07:14 +0100 Subject: [PATCH 37/62] fix: e2e tests --- apps/e2e/cypress/e2e/statusActions.cy.ts | 39 +++++++++--------------- 1 file changed, 14 insertions(+), 25 deletions(-) diff --git a/apps/e2e/cypress/e2e/statusActions.cy.ts b/apps/e2e/cypress/e2e/statusActions.cy.ts index 91b886106b..14688e3ba8 100644 --- a/apps/e2e/cypress/e2e/statusActions.cy.ts +++ b/apps/e2e/cypress/e2e/statusActions.cy.ts @@ -2,7 +2,6 @@ import { faker } from '@faker-js/faker'; import { AllocationTimeUnits, EmailStatusActionRecipients, - EmailTemplate, FeatureId, FeatureUpdateAction, Event as PROPOSAL_EVENTS, @@ -37,8 +36,6 @@ const newCall = { let proposal1Id: string; let proposal2Id: string; -let emailTemplate1: EmailTemplate | null; -let emailTemplate2: EmailTemplate | null; context('Status actions tests', () => { beforeEach(function () { @@ -49,14 +46,6 @@ context('Status actions tests', () => { action: FeatureUpdateAction.ENABLE, featureIds: [FeatureId.PREGENERATED_PROPOSAL_PDF], }); - - cy.getEmailTemplate({ emailTemplateId: 1 }).then((result) => { - emailTemplate1 = result.emailTemplate; - }); - - cy.getEmailTemplate({ emailTemplateId: 2 }).then((result) => { - emailTemplate2 = result.emailTemplate; - }); }); describe('Status actions workflow tests', () => { @@ -143,8 +132,8 @@ context('Status actions tests', () => { description: '', }, emailTemplate: { - id: emailTemplate1?.id, - name: 'template-name-1', + id: 'My First Email', + name: 'My First Email', }, combineEmails: true, }, @@ -237,8 +226,8 @@ context('Status actions tests', () => { description: '', }, emailTemplate: { - id: emailTemplate2?.id, - name: 'template-name-2', + id: 'My Second Email', + name: 'My Second Email', }, }, ], @@ -312,8 +301,8 @@ context('Status actions tests', () => { description: '', }, emailTemplate: { - id: emailTemplate1?.id, - name: 'PI-email-template', + id: 'My First Email', + name: 'My First Email', }, }, ], @@ -473,8 +462,8 @@ context('Status actions tests', () => { 'Other email recipients manually added by their email', }, emailTemplate: { - id: emailTemplate1?.id, - name: 'template-name-1', + id: 'My First Email', + name: 'My First Email', }, otherRecipientEmails: [faker.internet.email()], }, @@ -594,8 +583,8 @@ context('Status actions tests', () => { 'Other email recipients manually added by their email', }, emailTemplate: { - id: emailTemplate1?.id, - name: 'template-name-1', + id: 'My First Email', + name: 'My First Email', }, otherRecipientEmails: [statusActionEmail], }, @@ -708,8 +697,8 @@ context('Status actions tests', () => { description: '', }, emailTemplate: { - id: emailTemplate1?.id, - name: 'template-name-1', + id: 'My First Email', + name: 'My First Email', }, }, { @@ -719,8 +708,8 @@ context('Status actions tests', () => { 'Other email recipients manually added by their email', }, emailTemplate: { - id: emailTemplate1?.id, - name: 'template-name-1', + id: 'My First Email', + name: 'My First Email', }, otherRecipientEmails: [faker.internet.email()], }, From 5235f1beaea0f6bd091bb44e8eb6583cbdbd2a81 Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Mon, 5 Jan 2026 10:40:04 +0100 Subject: [PATCH 38/62] fix: review findings --- .../src/datasources/postgres/EmailTemplateDataSource.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/datasources/postgres/EmailTemplateDataSource.ts b/apps/backend/src/datasources/postgres/EmailTemplateDataSource.ts index 95f7142d53..4470ac5666 100644 --- a/apps/backend/src/datasources/postgres/EmailTemplateDataSource.ts +++ b/apps/backend/src/datasources/postgres/EmailTemplateDataSource.ts @@ -85,7 +85,7 @@ export default class PostgresEmailTemplateDataSource ) .from('email_templates') .then((emailTemplates: EmailTemplateRecord[]) => { - if (emailTemplates?.length === 0) { + if (emailTemplates?.length !== 1) { throw new GraphQLError(`Failed to create email template '${name}'`); } @@ -113,7 +113,7 @@ export default class PostgresEmailTemplateDataSource .from('email_templates') .where('email_templates.email_template_id', emailTemplateId) .then((emailTemplates: EmailTemplateRecord[]) => { - if (emailTemplates === undefined || emailTemplates.length !== 1) { + if (emailTemplates?.length !== 1) { throw new GraphQLError( `Failed to update email template with id '${emailTemplateId}'` ); @@ -130,7 +130,7 @@ export default class PostgresEmailTemplateDataSource .from('email_templates') .returning('*') .then((emailTemplates: EmailTemplateRecord[]) => { - if (emailTemplates === undefined || emailTemplates.length !== 1) { + if (emailTemplates?.length !== 1) { throw new GraphQLError( `Could not delete emailTemplate with id:${id}` ); From 09bbddd095120a9bf387e7212742a9f142e6893c Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Tue, 6 Jan 2026 14:07:57 +0100 Subject: [PATCH 39/62] fix: use rendered html directly when sending email --- apps/backend/src/eventHandlers/MailService/SMTPMailService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts b/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts index f6ad053503..48b9baa90f 100644 --- a/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts +++ b/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts @@ -220,6 +220,7 @@ export class SMTPMailService extends MailService { }, bcc: bccAddress, subject: template.subject, + html: template.body, } : { to: isProduction @@ -227,10 +228,9 @@ export class SMTPMailService extends MailService { : process.env.SINK_EMAIL, bcc: bccAddress, subject: template.subject, + html: template.body, }), }, - locals: options.substitution_data, - template: template.body, }; emailPromises.push(this.emailTemplate.send(emailOptions)); From 60667c40a42198fe7a70aa9f0b624ef97455f48a Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Wed, 7 Jan 2026 10:15:38 +0100 Subject: [PATCH 40/62] fix: backend tests --- .../src/eventHandlers/MailService/SMTPMailService.spec.ts | 1 + .../src/eventHandlers/email/stfcEmailHandler.spec.ts | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/eventHandlers/MailService/SMTPMailService.spec.ts b/apps/backend/src/eventHandlers/MailService/SMTPMailService.spec.ts index 56d1d88a08..44f36bafaa 100644 --- a/apps/backend/src/eventHandlers/MailService/SMTPMailService.spec.ts +++ b/apps/backend/src/eventHandlers/MailService/SMTPMailService.spec.ts @@ -94,6 +94,7 @@ test('All emails with bcc were successfully sent', async () => { to: process.env.SINK_EMAIL, bcc: bccEmail, subject: '= ``', + html: '', }, locals: substitutionData, }); diff --git a/apps/backend/src/eventHandlers/email/stfcEmailHandler.spec.ts b/apps/backend/src/eventHandlers/email/stfcEmailHandler.spec.ts index fbc8f3e739..a887ba306c 100644 --- a/apps/backend/src/eventHandlers/email/stfcEmailHandler.spec.ts +++ b/apps/backend/src/eventHandlers/email/stfcEmailHandler.spec.ts @@ -1,6 +1,6 @@ -import 'reflect-metadata'; import { faker } from '@faker-js/faker'; import * as Logger from '@user-office-software/duo-logger'; +import 'reflect-metadata'; import { container } from 'tsyringe'; import { Tokens } from '../../config/Tokens'; @@ -69,7 +69,7 @@ describe('stfcEmailHandler', () => { expect(process.env.FBS_EMAIL).toBe(inviteEmail); expect(mockMailService.sendMail).toHaveBeenCalledWith({ - content: { template_id: 'call-created-email' }, + content: { template: 'call-created-email' }, substitution_data: { shortCode: 'string', startCall: new Date(2000, 1, 1), @@ -109,7 +109,7 @@ describe('stfcEmailHandler', () => { await new Promise(setImmediate); expect(mockMailService.sendMail).toHaveBeenCalledWith({ - content: { template_id: 'call-created-email' }, + content: { template: 'call-created-email' }, substitution_data: { shortCode: 'error', }, From 6464c61490caf2c1d3af56170ca65bfb336bf6b3 Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Wed, 7 Jan 2026 11:36:22 +0100 Subject: [PATCH 41/62] fix: e2e tests --- .../db_patches/db_seeds/0007_EmailTemplates.sql | 14 +++++++++++--- apps/e2e/cypress/e2e/statusActions.cy.ts | 14 +++++++------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/apps/backend/db_patches/db_seeds/0007_EmailTemplates.sql b/apps/backend/db_patches/db_seeds/0007_EmailTemplates.sql index fef4bb421a..e64ee6b114 100644 --- a/apps/backend/db_patches/db_seeds/0007_EmailTemplates.sql +++ b/apps/backend/db_patches/db_seeds/0007_EmailTemplates.sql @@ -2,13 +2,21 @@ DO $DO$ BEGIN - INSERT INTO email_templates (created_by, name, description, subject, body) + INSERT INTO email_templates (created_by, name, description, subject, body) VALUES (0, 'template-name-1', 'template-description-1', 'template-subject-1', 'template-body-1'); - INSERT INTO email_templates (created_by, name, description, subject, body) + INSERT INTO email_templates (created_by, name, description, subject, body) VALUES - (0, 'template-name-2', 'template-description-2', 'template-subject-2', 'template-body-2'); + (0, 'template-name-2', 'template-description-2', 'template-subject-2', 'template-body-2'); + + INSERT INTO email_templates (created_by, name, description, subject, body) + VALUES + (0, 'my-first-email', 'My First Email', 'My First Email Subject', 'My First Email Body'); + + INSERT INTO email_templates (created_by, name, description, subject, body) + VALUES + (0, 'my-second-email', 'My Second Email', 'My Second Email Subject', 'My Second Email Body'); END; $DO$ diff --git a/apps/e2e/cypress/e2e/statusActions.cy.ts b/apps/e2e/cypress/e2e/statusActions.cy.ts index 14688e3ba8..b9f673da42 100644 --- a/apps/e2e/cypress/e2e/statusActions.cy.ts +++ b/apps/e2e/cypress/e2e/statusActions.cy.ts @@ -132,7 +132,7 @@ context('Status actions tests', () => { description: '', }, emailTemplate: { - id: 'My First Email', + id: 'my-first-email', name: 'My First Email', }, combineEmails: true, @@ -226,7 +226,7 @@ context('Status actions tests', () => { description: '', }, emailTemplate: { - id: 'My Second Email', + id: 'my-second-email', name: 'My Second Email', }, }, @@ -301,7 +301,7 @@ context('Status actions tests', () => { description: '', }, emailTemplate: { - id: 'My First Email', + id: 'my-first-email', name: 'My First Email', }, }, @@ -462,7 +462,7 @@ context('Status actions tests', () => { 'Other email recipients manually added by their email', }, emailTemplate: { - id: 'My First Email', + id: 'my-first-email', name: 'My First Email', }, otherRecipientEmails: [faker.internet.email()], @@ -583,7 +583,7 @@ context('Status actions tests', () => { 'Other email recipients manually added by their email', }, emailTemplate: { - id: 'My First Email', + id: 'my-first-email', name: 'My First Email', }, otherRecipientEmails: [statusActionEmail], @@ -697,7 +697,7 @@ context('Status actions tests', () => { description: '', }, emailTemplate: { - id: 'My First Email', + id: 'my-first-email', name: 'My First Email', }, }, @@ -708,7 +708,7 @@ context('Status actions tests', () => { 'Other email recipients manually added by their email', }, emailTemplate: { - id: 'My First Email', + id: 'my-first-email', name: 'My First Email', }, otherRecipientEmails: [faker.internet.email()], From 39dd7f519576c5cedb5180c41c0f9fbf9c65b778 Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Wed, 7 Jan 2026 13:22:38 +0100 Subject: [PATCH 42/62] fix: e2e tests --- apps/e2e/cypress/e2e/statusActions.cy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/e2e/cypress/e2e/statusActions.cy.ts b/apps/e2e/cypress/e2e/statusActions.cy.ts index b9f673da42..f8d2b3124a 100644 --- a/apps/e2e/cypress/e2e/statusActions.cy.ts +++ b/apps/e2e/cypress/e2e/statusActions.cy.ts @@ -39,7 +39,7 @@ let proposal2Id: string; context('Status actions tests', () => { beforeEach(function () { - cy.resetDB(); + cy.resetDB(true); cy.getAndStoreFeaturesEnabled(); cy.updateFeature({ From 1958cb37bb497309e031b4102ea5b57ed29b28ed Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Wed, 7 Jan 2026 16:44:59 +0100 Subject: [PATCH 43/62] fix: e2e tests --- apps/e2e/cypress/e2e/statusActions.cy.ts | 18 +++++++++++++- apps/e2e/cypress/support/e2e.ts | 13 +++++----- apps/e2e/cypress/support/emailTemplate.ts | 18 ++++++++++++++ apps/e2e/cypress/support/template.ts | 4 +-- apps/e2e/cypress/types/emailTemplates.d.ts | 29 ++++++++++++++++++++++ 5 files changed, 73 insertions(+), 9 deletions(-) create mode 100644 apps/e2e/cypress/support/emailTemplate.ts create mode 100644 apps/e2e/cypress/types/emailTemplates.d.ts diff --git a/apps/e2e/cypress/e2e/statusActions.cy.ts b/apps/e2e/cypress/e2e/statusActions.cy.ts index f8d2b3124a..982e71e035 100644 --- a/apps/e2e/cypress/e2e/statusActions.cy.ts +++ b/apps/e2e/cypress/e2e/statusActions.cy.ts @@ -39,9 +39,25 @@ let proposal2Id: string; context('Status actions tests', () => { beforeEach(function () { - cy.resetDB(true); + cy.resetDB(); cy.getAndStoreFeaturesEnabled(); + cy.createEmailTemplate({ + createdByUserId: 0, + name: 'my-first-email', + description: 'My First Email', + subject: 'My First Subject', + body: 'My First Body', + }); + + cy.createEmailTemplate({ + createdByUserId: 0, + name: 'my-second-email', + description: 'My Second Email', + subject: 'My Second Subject', + body: 'My Second Body', + }); + cy.updateFeature({ action: FeatureUpdateAction.ENABLE, featureIds: [FeatureId.PREGENERATED_PROPOSAL_PDF], diff --git a/apps/e2e/cypress/support/e2e.ts b/apps/e2e/cypress/support/e2e.ts index 27f094350d..297973d0b3 100644 --- a/apps/e2e/cypress/support/e2e.ts +++ b/apps/e2e/cypress/support/e2e.ts @@ -16,18 +16,19 @@ // Import commands.js using ES2015 syntax: import './admin'; import './call'; +import './emailTemplate'; +import './fap'; import './instrument'; import './internalReview'; import './invite'; +import './predefinedMessages'; import './proposal'; +import './settings'; +import './statusActionLogs'; +import './tag'; +import './technique'; import './template'; import './user'; import './utils'; import './visits'; import './workflow'; -import './fap'; -import './predefinedMessages'; -import './settings'; -import './technique'; -import './statusActionLogs'; -import './tag'; diff --git a/apps/e2e/cypress/support/emailTemplate.ts b/apps/e2e/cypress/support/emailTemplate.ts new file mode 100644 index 0000000000..f913685eb4 --- /dev/null +++ b/apps/e2e/cypress/support/emailTemplate.ts @@ -0,0 +1,18 @@ +import { + CreateEmailTemplateMutation, + CreateEmailTemplateMutationVariables, +} from '@user-office-software-libs/shared-types'; + +import { getE2EApi } from './utils'; + +const api = getE2EApi(); + +function createEmailTemplate( + createEmailTemplateInput: CreateEmailTemplateMutationVariables +): Cypress.Chainable { + const request = api.createEmailTemplate(createEmailTemplateInput); + + return cy.wrap(request); +} + +Cypress.Commands.add('createEmailTemplate', createEmailTemplate); diff --git a/apps/e2e/cypress/support/template.ts b/apps/e2e/cypress/support/template.ts index 905d816ecf..4edc2921ba 100644 --- a/apps/e2e/cypress/support/template.ts +++ b/apps/e2e/cypress/support/template.ts @@ -14,14 +14,14 @@ import { CreateTemplateMutation, CreateTemplateMutationVariables, CreateTopicMutation, - UpdateTopicMutation, CreateTopicMutationVariables, + SetActiveTemplateMutationVariables, UpdateQuestionMutation, UpdateQuestionMutationVariables, UpdateQuestionTemplateRelationSettingsMutation, UpdateQuestionTemplateRelationSettingsMutationVariables, + UpdateTopicMutation, UpdateTopicMutationVariables, - SetActiveTemplateMutationVariables, } from '@user-office-software-libs/shared-types'; import { getE2EApi } from './utils'; diff --git a/apps/e2e/cypress/types/emailTemplates.d.ts b/apps/e2e/cypress/types/emailTemplates.d.ts new file mode 100644 index 0000000000..b2174d30c3 --- /dev/null +++ b/apps/e2e/cypress/types/emailTemplates.d.ts @@ -0,0 +1,29 @@ +import { + CreateEmailTemplateMutation, + CreateEmailTemplateMutationVariables, +} from '@user-office-software-libs/shared-types'; + +declare global { + namespace Cypress { + interface Chainable { + /** + * Creates a new email template with the given values + * + * @returns {typeof createEmailTemplateMutation} + * @memberof Chainable + * @example + * cy.createEmailTemplate({ + * name: faker.random.words(2), + * shortCode: faker.random.alphaNumeric(15), + * description: faker.random.words(5), + * managerUserId: 1 + * }); + */ + createEmailTemplate( + createEmailTemplateInput: CreateEmailTemplateMutationVariables + ): Cypress.Chainable; + } + } +} + +export {}; From 59cf824b22d8873635feb1c9eb2d84e36d9f8f40 Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Thu, 8 Jan 2026 09:46:31 +0100 Subject: [PATCH 44/62] fix: e2e tests --- apps/e2e/cypress/e2e/statusActions.cy.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/e2e/cypress/e2e/statusActions.cy.ts b/apps/e2e/cypress/e2e/statusActions.cy.ts index 982e71e035..ba507bf4c1 100644 --- a/apps/e2e/cypress/e2e/statusActions.cy.ts +++ b/apps/e2e/cypress/e2e/statusActions.cy.ts @@ -149,7 +149,7 @@ context('Status actions tests', () => { }, emailTemplate: { id: 'my-first-email', - name: 'My First Email', + name: 'my-first-email', }, combineEmails: true, }, @@ -243,7 +243,7 @@ context('Status actions tests', () => { }, emailTemplate: { id: 'my-second-email', - name: 'My Second Email', + name: 'my-second-email', }, }, ], @@ -318,7 +318,7 @@ context('Status actions tests', () => { }, emailTemplate: { id: 'my-first-email', - name: 'My First Email', + name: 'my-first-email', }, }, ], @@ -479,7 +479,7 @@ context('Status actions tests', () => { }, emailTemplate: { id: 'my-first-email', - name: 'My First Email', + name: 'my-first-email', }, otherRecipientEmails: [faker.internet.email()], }, @@ -600,7 +600,7 @@ context('Status actions tests', () => { }, emailTemplate: { id: 'my-first-email', - name: 'My First Email', + name: 'my-first-email', }, otherRecipientEmails: [statusActionEmail], }, @@ -714,7 +714,7 @@ context('Status actions tests', () => { }, emailTemplate: { id: 'my-first-email', - name: 'My First Email', + name: 'my-first-email', }, }, { @@ -725,7 +725,7 @@ context('Status actions tests', () => { }, emailTemplate: { id: 'my-first-email', - name: 'My First Email', + name: 'my-first-email', }, otherRecipientEmails: [faker.internet.email()], }, From ccd1efa9b8eb9a78e9535d2b64fb1421a7e01c3e Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Fri, 9 Jan 2026 10:40:11 +0100 Subject: [PATCH 45/62] fix: e2e tests --- .../src/eventHandlers/MailService/SkipSendMailService.ts | 4 ++-- apps/backend/src/queries/StatusActionQueries.ts | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/eventHandlers/MailService/SkipSendMailService.ts b/apps/backend/src/eventHandlers/MailService/SkipSendMailService.ts index a07950a699..a0abd06818 100644 --- a/apps/backend/src/eventHandlers/MailService/SkipSendMailService.ts +++ b/apps/backend/src/eventHandlers/MailService/SkipSendMailService.ts @@ -36,7 +36,7 @@ export class SkipSendMailService extends MailService { id: 'my-first-email', has_draft: false, published: true, - name: 'My First Email', + name: 'my-first-email', shared_with_subaccounts: false, has_published: true, last_update_time: '2022-08-14T08:42:50+00:00', @@ -47,7 +47,7 @@ export class SkipSendMailService extends MailService { id: 'my-second-email', has_draft: false, published: true, - name: 'My Second Email', + name: 'my-second-email', shared_with_subaccounts: false, has_published: true, last_update_time: '2023-08-14T08:42:50+00:00', diff --git a/apps/backend/src/queries/StatusActionQueries.ts b/apps/backend/src/queries/StatusActionQueries.ts index 7ba6490ffc..eb8e434407 100644 --- a/apps/backend/src/queries/StatusActionQueries.ts +++ b/apps/backend/src/queries/StatusActionQueries.ts @@ -1,3 +1,4 @@ +import { logger } from '@user-office-software/duo-logger'; import { inject, injectable } from 'tsyringe'; import { Tokens } from '../config/Tokens'; @@ -65,6 +66,8 @@ export default class StatusActionQueries { const emailTemplatesResult = await this.emailService.getEmailTemplates(); + logger.logInfo('Email templates', { emailTemplatesResult }); + return new EmailActionDefaultConfig( allEmailRecipients, emailTemplatesResult.results.map((e) => ({ From b362fdf8bf1f121fd1a111c8d0e07887cd737f70 Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Fri, 9 Jan 2026 14:01:27 +0100 Subject: [PATCH 46/62] fix: backend tests --- .../MailService/SMTPMailService.spec.ts | 2 - .../MailService/SMTPMailService.ts | 50 +++++++++---------- 2 files changed, 25 insertions(+), 27 deletions(-) diff --git a/apps/backend/src/eventHandlers/MailService/SMTPMailService.spec.ts b/apps/backend/src/eventHandlers/MailService/SMTPMailService.spec.ts index 44f36bafaa..7543b0abc9 100644 --- a/apps/backend/src/eventHandlers/MailService/SMTPMailService.spec.ts +++ b/apps/backend/src/eventHandlers/MailService/SMTPMailService.spec.ts @@ -89,14 +89,12 @@ test('All emails with bcc were successfully sent', async () => { const result = await smtpMailService.sendMail(options); expect(emailInfo).toHaveBeenCalledWith({ - template: expect.any(String), message: { to: process.env.SINK_EMAIL, bcc: bccEmail, subject: '= ``', html: '', }, - locals: substitutionData, }); return expect(result).toStrictEqual({ diff --git a/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts b/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts index 48b9baa90f..36c64cc939 100644 --- a/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts +++ b/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts @@ -208,32 +208,32 @@ export class SMTPMailService extends MailService { } options.recipients.forEach((participant) => { - const emailOptions = { - message: { - ...(typeof participant.address !== 'string' - ? { - to: { - address: isProduction - ? participant.address?.email + emailPromises.push( + this.emailTemplate.send({ + message: { + ...(typeof participant.address !== 'string' + ? { + to: { + address: isProduction + ? participant.address?.email + : process.env.SINK_EMAIL, + name: participant.address?.header_to, + }, + bcc: bccAddress, + subject: template.subject, + html: template.body, + } + : { + to: isProduction + ? participant.address : process.env.SINK_EMAIL, - name: participant.address?.header_to, - }, - bcc: bccAddress, - subject: template.subject, - html: template.body, - } - : { - to: isProduction - ? participant.address - : process.env.SINK_EMAIL, - bcc: bccAddress, - subject: template.subject, - html: template.body, - }), - }, - }; - - emailPromises.push(this.emailTemplate.send(emailOptions)); + bcc: bccAddress, + subject: template.subject, + html: template.body, + }), + }, + }) + ); }); return Promise.allSettled(emailPromises).then((results) => { From ab6bb338288c073ec333c0995b64081d194d01ea Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Mon, 12 Jan 2026 08:43:57 +0100 Subject: [PATCH 47/62] fix: remove migration part of DB script --- .../0202_CreateEmailTemplatesTable.sql | 83 ------------------- 1 file changed, 83 deletions(-) diff --git a/apps/backend/db_patches/0202_CreateEmailTemplatesTable.sql b/apps/backend/db_patches/0202_CreateEmailTemplatesTable.sql index 31447b41cd..e851b45c4d 100644 --- a/apps/backend/db_patches/0202_CreateEmailTemplatesTable.sql +++ b/apps/backend/db_patches/0202_CreateEmailTemplatesTable.sql @@ -1,14 +1,7 @@ DO $$ -DECLARE - email_template_id_var int; - connection_loop_var record; - array_size_var int; - array_loop_var int; - template_name varchar(200); BEGIN IF register_patch('CreateEmailTemplatesTable.sql', 'Gergely Nyiri', 'Create email templates table', '2025-07-15') THEN - CREATE TABLE IF NOT EXISTS email_templates ( email_template_id serial PRIMARY KEY, created_by INT NOT NULL REFERENCES users(user_id), @@ -18,82 +11,6 @@ BEGIN subject varchar(160) NOT NULL, body text ); - - INSERT INTO email_templates (created_by, name, description, subject, body) - VALUES - (0, 'CLF PI Co-I Submission Email', 'CLF PI Co-I Submission Email', '= `Proposal submitted`', 'Proposal submitted'); - - INSERT INTO email_templates (created_by, name, description, subject, body) - VALUES - (0, 'ISIS PI Co-I Submission Email', 'ISIS PI Co-I Submission Email', '= `Proposal submitted`', 'Proposal submitted'); - - INSERT INTO email_templates (created_by, name, description, subject, body) - VALUES - (0, 'ISIS Rapid PI Co-I Submission Email', 'ISIS Rapid PI Co-I Submission Email', '= `Proposal submitted`', 'Proposal submitted'); - - INSERT INTO email_templates (created_by, name, description, subject, body) - VALUES - (0, 'ISIS Rapid User Office Submission Email', 'ISIS Rapid User Office Submission Email', '= `Proposal submitted`', 'Proposal submitted'); - - INSERT INTO email_templates (created_by, name, description, subject, body) - VALUES - (0, 'ISIS Xpress PI Co-I Submission Email', 'ISIS Xpress PI Co-I Submission Email', '= `Proposal submitted`', 'Proposal submitted'); - - INSERT INTO email_templates (created_by, name, description, subject, body) - VALUES - (0, 'ISIS Xpress Scientist Submission Email', 'ISIS Xpress Scientist Submission Email', '= `Proposal submitted`', 'Proposal submitted'); - - INSERT INTO email_templates (created_by, name, description, subject, body) - VALUES - (0, 'ISIS Xpress PI Co-I Under Review Email', 'ISIS Xpress PI Co-I Under Review Email', '= `Proposal under review`', 'Proposal under review'); - - INSERT INTO email_templates (created_by, name, description, subject, body) - VALUES - (0, 'ISIS Xpress PI Co-I Approval Email', 'ISIS Xpress PI Co-I Approval Email', '= `Proposal approved`', 'Proposal approved'); - - INSERT INTO email_templates (created_by, name, description, subject, body) - VALUES - (0, 'ISIS Xpress SRA Request Email', 'ISIS Xpress SRA Request Email', '= `Proposal SRA request`', 'Proposal SRA request'); - - INSERT INTO email_templates (created_by, name, description, subject, body) - VALUES - (0, 'ISIS Xpress PI Co-I Reject Email', 'ISIS Xpress PI Co-I Reject Email', '= `Proposal unsuccessful`', 'Proposal unsuccessful'); - - INSERT INTO email_templates (created_by, name, description, subject, body) - VALUES - (0, 'ISIS Xpress PI Co-I Finish Email', 'ISIS Xpress PI Co-I Finish Email', '= `Proposal finished`', 'Proposal finished'); - - FOR connection_loop_var IN - SELECT * FROM workflow_connection_has_actions - LOOP - IF connection_loop_var.config IS NOT NULL THEN - SELECT jsonb_array_length(connection_loop_var.config->'recipientsWithEmailTemplate') INTO array_size_var; - - IF array_size_var IS NULL THEN - array_size_var := 0; - END IF; - - FOR array_loop_var IN 0..array_size_var-1 LOOP - SELECT connection_loop_var.config->'recipientsWithEmailTemplate'->array_loop_var->'emailTemplate'->>'name' INTO template_name; - - IF template_name IS NOT NULL THEN - -- RAISE NOTICE 'Processing connection % % with template % to %', array_loop_var, connection_loop_var.connection_id, template_name, update_config; - - SELECT email_templates.email_template_id INTO email_template_id_var FROM email_templates WHERE name = template_name; - - IF email_template_id_var IS NOT NULL THEN - UPDATE workflow_connection_has_actions - SET config = jsonb_set(config::jsonb, ('{recipientsWithEmailTemplate, ' || array_loop_var || ', emailTemplate, id}')::text[], to_jsonb(email_template_id_var), false) - WHERE connection_id = connection_loop_var.connection_id; - -- ELSE - -- RAISE WARNING 'No email template found for name %', template_name; - END IF; - -- ELSE - -- RAISE WARNING 'No email template found for connection %', template_name; - END IF; - END LOOP; - END IF; - END LOOP; END IF; END; $$ From c2db754939b44230aba1579ef5dfd2dd646155ae Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Tue, 27 Jan 2026 14:02:08 +0100 Subject: [PATCH 48/62] fix: rename emailTemplateId to emailTemplateName --- .../jobs/checkInviteReminderJob.spec.ts | 4 +- .../mockups/EmailTemplateDataSource.ts | 18 +++---- .../datasources/mockups/InviteDataSource.ts | 12 ++--- .../src/datasources/postgres/records.ts | 6 +-- .../email/eliEmailHandler.spec.ts | 18 +++---- .../eventHandlers/email/eliEmailHandler.ts | 50 +++++++++---------- ...mailTemplateId.ts => emailTemplateName.ts} | 2 +- .../email/essEmailHandler.spec.ts | 16 +++--- .../eventHandlers/email/essEmailHandler.ts | 24 ++++----- .../eventHandlers/email/stfcEmailHandler.ts | 4 +- apps/backend/src/models/Invite.ts | 4 +- .../src/mutations/FeedbackMutations.ts | 4 +- .../src/mutations/InviteMutations.spec.ts | 6 +-- package-lock.json | 14 ++++++ package.json | 1 + 15 files changed, 99 insertions(+), 84 deletions(-) rename apps/backend/src/eventHandlers/email/{emailTemplateId.ts => emailTemplateName.ts} (97%) diff --git a/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.spec.ts b/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.spec.ts index 89e17b54e6..feb1b1e99b 100644 --- a/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.spec.ts +++ b/apps/backend/src/asyncJobs/jobs/checkInviteReminderJob.spec.ts @@ -4,7 +4,7 @@ import sinon from 'sinon'; import { container } from 'tsyringe'; import { Tokens } from '../../config/Tokens'; -import { EmailTemplateId } from '../../eventHandlers/email/emailTemplateId'; +import { EmailTemplateName } from '../../eventHandlers/email/emailTemplateName'; import { Invite } from '../../models/Invite'; import { RoleClaim } from '../../models/RoleClaim'; import { SettingsId } from '../../models/Settings'; @@ -126,7 +126,7 @@ describe('checkInviteReminderJob', () => { claimedByUserId: null, isEmailSent: true, expiresAt: new Date('2025-12-31T00:00:00.000Z'), - templateId: EmailTemplateId.PROPOSAL_SUBMITTED, + templateId: EmailTemplateName.PROPOSAL_SUBMITTED, }; mockInviter = { diff --git a/apps/backend/src/datasources/mockups/EmailTemplateDataSource.ts b/apps/backend/src/datasources/mockups/EmailTemplateDataSource.ts index ff00dcc0f5..8b9132a16a 100644 --- a/apps/backend/src/datasources/mockups/EmailTemplateDataSource.ts +++ b/apps/backend/src/datasources/mockups/EmailTemplateDataSource.ts @@ -1,4 +1,4 @@ -import { EmailTemplateId } from '../../eventHandlers/email/emailTemplateId'; +import { EmailTemplateName } from '../../eventHandlers/email/emailTemplateName'; import { EmailTemplate } from '../../models/EmailTemplate'; import { EmailTemplatesFilter } from '../../resolvers/queries/EmailTemplatesQuery'; import { EmailTemplateDataSource } from '../EmailTemplateDataSource'; @@ -32,7 +32,7 @@ export class EmailTemplateDataSourceMock implements EmailTemplateDataSource { new EmailTemplate( 1, 2, - EmailTemplateId.PROPOSAL_CREATED, + EmailTemplateName.PROPOSAL_CREATED, 'This is a dummy email template for testing purposes.', 'Welcome to Our Service', 'Hello, thank you for signing up for our service. We are excited to have you on board!' @@ -40,7 +40,7 @@ export class EmailTemplateDataSourceMock implements EmailTemplateDataSource { new EmailTemplate( 1, 2, - EmailTemplateId.ACCEPTED_PROPOSAL, + EmailTemplateName.ACCEPTED_PROPOSAL, 'This is a dummy email template for testing purposes.', 'Welcome to Our Service', 'Hello, thank you for signing up for our service. We are excited to have you on board!' @@ -48,7 +48,7 @@ export class EmailTemplateDataSourceMock implements EmailTemplateDataSource { new EmailTemplate( 1, 2, - EmailTemplateId.REJECTED_PROPOSAL, + EmailTemplateName.REJECTED_PROPOSAL, 'This is a dummy email template for testing purposes.', 'Welcome to Our Service', 'Hello, thank you for signing up for our service. We are excited to have you on board!' @@ -56,7 +56,7 @@ export class EmailTemplateDataSourceMock implements EmailTemplateDataSource { new EmailTemplate( 1, 2, - EmailTemplateId.RESERVED_PROPOSAL, + EmailTemplateName.RESERVED_PROPOSAL, 'This is a dummy email template for testing purposes.', 'Welcome to Our Service', 'Hello, thank you for signing up for our service. We are excited to have you on board!' @@ -64,7 +64,7 @@ export class EmailTemplateDataSourceMock implements EmailTemplateDataSource { new EmailTemplate( 1, 2, - EmailTemplateId.REVIEW_REMINDER, + EmailTemplateName.REVIEW_REMINDER, 'This is a dummy email template for testing purposes.', 'Welcome to Our Service', 'Hello, thank you for signing up for our service. We are excited to have you on board!' @@ -72,7 +72,7 @@ export class EmailTemplateDataSourceMock implements EmailTemplateDataSource { new EmailTemplate( 1, 2, - EmailTemplateId.INTERNAL_REVIEW_CREATED, + EmailTemplateName.INTERNAL_REVIEW_CREATED, 'This is a dummy email template for testing purposes.', 'Welcome to Our Service', 'Hello, thank you for signing up for our service. We are excited to have you on board!' @@ -80,7 +80,7 @@ export class EmailTemplateDataSourceMock implements EmailTemplateDataSource { new EmailTemplate( 1, 2, - EmailTemplateId.INTERNAL_REVIEW_DELETED, + EmailTemplateName.INTERNAL_REVIEW_DELETED, 'This is a dummy email template for testing purposes.', 'Welcome to Our Service', 'Hello, thank you for signing up for our service. We are excited to have you on board!' @@ -88,7 +88,7 @@ export class EmailTemplateDataSourceMock implements EmailTemplateDataSource { new EmailTemplate( 1, 2, - EmailTemplateId.INTERNAL_REVIEW_UPDATED, + EmailTemplateName.INTERNAL_REVIEW_UPDATED, 'This is a dummy email template for testing purposes.', 'Welcome to Our Service', 'Hello, thank you for signing up for our service. We are excited to have you on board!' diff --git a/apps/backend/src/datasources/mockups/InviteDataSource.ts b/apps/backend/src/datasources/mockups/InviteDataSource.ts index dc42b8a866..434e8230f2 100644 --- a/apps/backend/src/datasources/mockups/InviteDataSource.ts +++ b/apps/backend/src/datasources/mockups/InviteDataSource.ts @@ -1,7 +1,7 @@ import { inject, injectable } from 'tsyringe'; import { Tokens } from '../../config/Tokens'; -import { EmailTemplateId } from '../../eventHandlers/email/emailTemplateId'; +import { EmailTemplateName } from '../../eventHandlers/email/emailTemplateName'; import { CoProposerClaim } from '../../models/CoProposerClaim'; import { Invite } from '../../models/Invite'; import { CoProposerClaimDataSource } from '../CoProposerClaimDataSource'; @@ -65,7 +65,7 @@ export class InviteDataSourceMock implements InviteDataSource { null, true, null, - EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_USER + EmailTemplateName.USER_OFFICE_REGISTRATION_INVITATION_USER ), new Invite( 2, @@ -77,7 +77,7 @@ export class InviteDataSourceMock implements InviteDataSource { 1, true, null, - EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_CO_PROPOSER + EmailTemplateName.USER_OFFICE_REGISTRATION_INVITATION_CO_PROPOSER ), new Invite( 3, @@ -89,7 +89,7 @@ export class InviteDataSourceMock implements InviteDataSource { null, false, new Date('2022-01-01'), - EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_REVIEWER + EmailTemplateName.USER_OFFICE_REGISTRATION_INVITATION_REVIEWER ), ]; @@ -143,7 +143,7 @@ export class InviteDataSourceMock implements InviteDataSource { note: string; createdByUserId: number; expiresAt: Date | null; - templateId?: EmailTemplateId | null; + templateId?: EmailTemplateName | null; }): Promise { const { code, email, createdByUserId, expiresAt, templateId } = args; @@ -157,7 +157,7 @@ export class InviteDataSourceMock implements InviteDataSource { null, false, expiresAt ?? null, - templateId ?? (null as EmailTemplateId | null) + templateId ?? (null as EmailTemplateName | null) ); this.invites.push(newInvite); diff --git a/apps/backend/src/datasources/postgres/records.ts b/apps/backend/src/datasources/postgres/records.ts index be563f7281..507ed36979 100644 --- a/apps/backend/src/datasources/postgres/records.ts +++ b/apps/backend/src/datasources/postgres/records.ts @@ -3,7 +3,7 @@ import { ProposalPdfTemplateRecord, } from 'knex/types/tables'; -import { EmailTemplateId } from '../../eventHandlers/email/emailTemplateId'; +import { EmailTemplateName } from '../../eventHandlers/email/emailTemplateName'; import { Page } from '../../models/Admin'; import { FileMetadata } from '../../models/Blob'; import { AllocationTimeUnits, Call } from '../../models/Call'; @@ -1423,7 +1423,7 @@ export interface InviteRecord { readonly claimed_at: Date | null; readonly is_email_sent: boolean; readonly expires_at: Date | null; - readonly template_id: EmailTemplateId | null; + readonly template_id: EmailTemplateName | null; } export const createInviteObject = (invite: InviteRecord) => @@ -1437,7 +1437,7 @@ export const createInviteObject = (invite: InviteRecord) => invite.claimed_by, invite.is_email_sent, invite.expires_at, - invite.template_id as EmailTemplateId | null + invite.template_id as EmailTemplateName | null ); export interface RoleClaimRecord { diff --git a/apps/backend/src/eventHandlers/email/eliEmailHandler.spec.ts b/apps/backend/src/eventHandlers/email/eliEmailHandler.spec.ts index 626f02a6d8..11adbc84d0 100644 --- a/apps/backend/src/eventHandlers/email/eliEmailHandler.spec.ts +++ b/apps/backend/src/eventHandlers/email/eliEmailHandler.spec.ts @@ -8,7 +8,7 @@ import { RoleClaimDataSourceMock } from '../../datasources/mockups/RoleClaimData import { ApplicationEvent } from '../../events/applicationEvents'; import { Event } from '../../events/event.enum'; import { eliEmailHandler } from './eliEmailHandler'; -import { EmailTemplateId } from './emailTemplateId'; +import { EmailTemplateName } from './emailTemplateName'; // Mock MailService const mockMailService = { @@ -62,7 +62,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - template: EmailTemplateId.PROPOSAL_CREATED, + template: EmailTemplateName.PROPOSAL_CREATED, }, }) ); @@ -91,7 +91,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - template: EmailTemplateId.ACCEPTED_PROPOSAL, + template: EmailTemplateName.ACCEPTED_PROPOSAL, }, }) ); @@ -120,7 +120,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - template: EmailTemplateId.REJECTED_PROPOSAL, + template: EmailTemplateName.REJECTED_PROPOSAL, }, }) ); @@ -149,7 +149,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - template: EmailTemplateId.RESERVED_PROPOSAL, + template: EmailTemplateName.RESERVED_PROPOSAL, }, }) ); @@ -174,7 +174,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - template: EmailTemplateId.REVIEW_REMINDER, + template: EmailTemplateName.REVIEW_REMINDER, }, }) ); @@ -207,7 +207,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - template: EmailTemplateId.INTERNAL_REVIEW_CREATED, + template: EmailTemplateName.INTERNAL_REVIEW_CREATED, }, }) ); @@ -240,7 +240,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - template: EmailTemplateId.INTERNAL_REVIEW_UPDATED, + template: EmailTemplateName.INTERNAL_REVIEW_UPDATED, }, }) ); @@ -273,7 +273,7 @@ describe('eliEmailHandler', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith( expect.objectContaining({ content: { - template: EmailTemplateId.INTERNAL_REVIEW_DELETED, + template: EmailTemplateName.INTERNAL_REVIEW_DELETED, }, }) ); diff --git a/apps/backend/src/eventHandlers/email/eliEmailHandler.ts b/apps/backend/src/eventHandlers/email/eliEmailHandler.ts index 80c5892b70..5e47109dbf 100644 --- a/apps/backend/src/eventHandlers/email/eliEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/eliEmailHandler.ts @@ -18,7 +18,7 @@ import { ProposalEndStatus } from '../../models/Proposal'; import { BasicUserDetails, UserRole } from '../../models/User'; import EmailSettings from '../MailService/EmailSettings'; import { MailService } from '../MailService/MailService'; -import { EmailTemplateId } from './emailTemplateId'; +import { EmailTemplateName } from './emailTemplateName'; export async function eliEmailHandler(event: ApplicationEvent) { const mailService = container.resolve(Tokens.MailService); @@ -105,14 +105,14 @@ export async function eliEmailHandler(event: ApplicationEvent) { return; } - const templateId = EmailTemplateId.PROPOSAL_CREATED; + const templateName = EmailTemplateName.PROPOSAL_CREATED; const emailTemplate = - await emailTemplateDataSource.getEmailTemplateByName(templateId); + await emailTemplateDataSource.getEmailTemplateByName(templateName); const options: EmailSettings = { content: { - template: emailTemplate?.name || templateId || '', + template: emailTemplate?.name || templateName || '', }, substitution_data: { preferredName: principalInvestigator.preferredname, @@ -150,13 +150,13 @@ export async function eliEmailHandler(event: ApplicationEvent) { return; } const { finalStatus } = event.proposal; - let templateId = ''; + let templateName = ''; if (finalStatus === ProposalEndStatus.ACCEPTED) { - templateId = EmailTemplateId.ACCEPTED_PROPOSAL; + templateName = EmailTemplateName.ACCEPTED_PROPOSAL; } else if (finalStatus === ProposalEndStatus.REJECTED) { - templateId = EmailTemplateId.REJECTED_PROPOSAL; + templateName = EmailTemplateName.REJECTED_PROPOSAL; } else if (finalStatus === ProposalEndStatus.RESERVED) { - templateId = EmailTemplateId.RESERVED_PROPOSAL; + templateName = EmailTemplateName.RESERVED_PROPOSAL; } else { logger.logError('Failed email notification', { event }); @@ -164,12 +164,12 @@ export async function eliEmailHandler(event: ApplicationEvent) { } const emailTemplate = - await emailTemplateDataSource.getEmailTemplateByName(templateId); + await emailTemplateDataSource.getEmailTemplateByName(templateName); mailService .sendMail({ content: { - template: emailTemplate?.name || templateId || '', + template: emailTemplate?.name || templateName || '', }, substitution_data: { preferredName: principalInvestigator.preferredname, @@ -203,14 +203,14 @@ export async function eliEmailHandler(event: ApplicationEvent) { return; } - const templateId = EmailTemplateId.REVIEW_REMINDER; + const templateName = EmailTemplateName.REVIEW_REMINDER; const emailTemplate = - await emailTemplateDataSource.getEmailTemplateByName(templateId); + await emailTemplateDataSource.getEmailTemplateByName(templateName); mailService .sendMail({ content: { - template: emailTemplate?.name || templateId || '', + template: emailTemplate?.name || templateName || '', }, substitution_data: { preferredName: fapReviewer.preferredname, @@ -284,21 +284,21 @@ export async function eliEmailHandler(event: ApplicationEvent) { } } - let templateId = EmailTemplateId.INTERNAL_REVIEW_CREATED; + let templateName = EmailTemplateName.INTERNAL_REVIEW_CREATED; if (event.type === Event.INTERNAL_REVIEW_UPDATED) { - templateId = EmailTemplateId.INTERNAL_REVIEW_UPDATED; + templateName = EmailTemplateName.INTERNAL_REVIEW_UPDATED; } else if (event.type === Event.INTERNAL_REVIEW_DELETED) { - templateId = EmailTemplateId.INTERNAL_REVIEW_DELETED; + templateName = EmailTemplateName.INTERNAL_REVIEW_DELETED; } const emailTemplate = - await emailTemplateDataSource.getEmailTemplateByName(templateId); + await emailTemplateDataSource.getEmailTemplateByName(templateName); mailService .sendMail({ content: { - template: emailTemplate?.name || templateId || '', + template: emailTemplate?.name || templateName || '', }, substitution_data: { assignedByPreferredName: assignedBy.preferredname, @@ -328,12 +328,12 @@ export async function eliEmailHandler(event: ApplicationEvent) { } } -function getTemplateIdForRole(role: UserRole): string { +function gettemplateNameForRole(role: UserRole): string { switch (role) { case UserRole.USER: - return EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_USER; + return EmailTemplateName.USER_OFFICE_REGISTRATION_INVITATION_USER; case UserRole.INTERNAL_REVIEWER: - return EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_REVIEWER; + return EmailTemplateName.USER_OFFICE_REGISTRATION_INVITATION_REVIEWER; default: throw new Error('No valid user role set for email invitation'); } @@ -342,7 +342,7 @@ function getTemplateIdForRole(role: UserRole): string { async function sendInviteEmail( invite: Invite, inviter: BasicUserDetails, - templateId: string + templateName: string ) { const mailService = container.resolve(Tokens.MailService); const inviteDataSource = container.resolve( @@ -353,12 +353,12 @@ async function sendInviteEmail( ); const emailTemplate = - await emailTemplateDataSource.getEmailTemplateByName(templateId); + await emailTemplateDataSource.getEmailTemplateByName(templateName); return mailService .sendMail({ content: { - template: emailTemplate?.name || templateId || '', + template: emailTemplate?.name || templateName || '', }, substitution_data: { email: invite.email, @@ -373,7 +373,7 @@ async function sendInviteEmail( await inviteDataSource.update({ id: invite.id, isEmailSent: true, - templateId: templateId, + templateId: templateName, }); logger.logInfo('Successful email transmission', { res }); }) diff --git a/apps/backend/src/eventHandlers/email/emailTemplateId.ts b/apps/backend/src/eventHandlers/email/emailTemplateName.ts similarity index 97% rename from apps/backend/src/eventHandlers/email/emailTemplateId.ts rename to apps/backend/src/eventHandlers/email/emailTemplateName.ts index 225e319523..11581cefa7 100644 --- a/apps/backend/src/eventHandlers/email/emailTemplateId.ts +++ b/apps/backend/src/eventHandlers/email/emailTemplateName.ts @@ -1,4 +1,4 @@ -export enum EmailTemplateId { +export enum EmailTemplateName { CO_PROPOSER_INVITE_ACCEPTED = 'co-proposer-invite-accepted', PROPOSAL_SUBMITTED = 'proposal-submitted', PROPOSAL_CREATED = 'proposal-created', diff --git a/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts b/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts index 19960abab0..9e4387ac3e 100644 --- a/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts +++ b/apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts @@ -17,7 +17,7 @@ import { import { ApplicationEvent } from '../../events/applicationEvents'; import { Event } from '../../events/event.enum'; import { Invite } from '../../models/Invite'; -import { EmailTemplateId } from './emailTemplateId'; +import { EmailTemplateName } from './emailTemplateName'; import { essEmailHandler } from './essEmailHandler'; // Mock MailService @@ -85,7 +85,7 @@ describe('essEmailHandler co-proposer invites', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith({ content: { - template: EmailTemplateId.CO_PROPOSER_INVITE_ACCEPTED, + template: EmailTemplateName.CO_PROPOSER_INVITE_ACCEPTED, }, substitution_data: { piPreferredname: expect.any(String), @@ -137,7 +137,7 @@ describe('essEmailHandler co-proposer invites', () => { expect(mockMailService.sendMail).toHaveBeenCalledWith({ content: { template: - EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_VISIT_REGISTRATION, + EmailTemplateName.USER_OFFICE_REGISTRATION_INVITATION_VISIT_REGISTRATION, }, substitution_data: { email: inviteEmail, @@ -161,7 +161,7 @@ describe('essEmailHandler co-proposer invites', () => { dummyUser.id, false, null, - EmailTemplateId.CO_PROPOSER_INVITE_ACCEPTED + EmailTemplateName.CO_PROPOSER_INVITE_ACCEPTED ); // Mock proposalDataSource.get to return null @@ -202,7 +202,7 @@ describe('essEmailHandler co-proposer invites', () => { dummyUser.id, false, null, - EmailTemplateId.CO_PROPOSER_INVITE_ACCEPTED + EmailTemplateName.CO_PROPOSER_INVITE_ACCEPTED ); // Mock userDataSource.getUser to return null for the principal investigator (first call) @@ -246,7 +246,7 @@ describe('essEmailHandler co-proposer invites', () => { dummyUser.id, false, null, - EmailTemplateId.CO_PROPOSER_INVITE_ACCEPTED + EmailTemplateName.CO_PROPOSER_INVITE_ACCEPTED ); // Mock userDataSource.getUser to return dummyUser for principal investigator but null for claimer @@ -290,7 +290,7 @@ describe('essEmailHandler co-proposer invites', () => { dummyUser.id, false, null, - EmailTemplateId.CO_PROPOSER_INVITE_ACCEPTED + EmailTemplateName.CO_PROPOSER_INVITE_ACCEPTED ); // Mock userDataSource.getUser to return dummyUser for principal investigator but null for claimer @@ -331,7 +331,7 @@ describe('essEmailHandler co-proposer invites', () => { expect(sendMailsSpy).toHaveBeenCalledTimes(1); const arg = sendMailsSpy.mock.calls[0][0]; - expect(arg.content.template).toBe(EmailTemplateId.PROPOSAL_SUBMITTED); + expect(arg.content.template).toBe(EmailTemplateName.PROPOSAL_SUBMITTED); // Recipients: first is PI, rest are co-proposers with header_to pointing to PI expect(arg.recipients).toEqual([ diff --git a/apps/backend/src/eventHandlers/email/essEmailHandler.ts b/apps/backend/src/eventHandlers/email/essEmailHandler.ts index 68f32aee25..42042922a2 100644 --- a/apps/backend/src/eventHandlers/email/essEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/essEmailHandler.ts @@ -17,7 +17,7 @@ import { ProposalEndStatus } from '../../models/Proposal'; import { BasicUserDetails } from '../../models/User'; import EmailSettings from '../MailService/EmailSettings'; import { MailService } from '../MailService/MailService'; -import { EmailTemplateId } from './emailTemplateId'; +import { EmailTemplateName } from './emailTemplateName'; export async function essEmailHandler(event: ApplicationEvent) { const mailService = container.resolve(Tokens.MailService); const proposalDataSource = container.resolve( @@ -105,7 +105,7 @@ export async function essEmailHandler(event: ApplicationEvent) { mailService .sendMail({ content: { - template: EmailTemplateId.CO_PROPOSER_INVITE_ACCEPTED, + template: EmailTemplateName.CO_PROPOSER_INVITE_ACCEPTED, }, substitution_data: { piPreferredname: principalInvestigator.preferredname, @@ -149,7 +149,7 @@ export async function essEmailHandler(event: ApplicationEvent) { const options: EmailSettings = { content: { - template: EmailTemplateId.PROPOSAL_SUBMITTED, + template: EmailTemplateName.PROPOSAL_SUBMITTED, }, substitution_data: { piPreferredname: principalInvestigator.preferredname, @@ -203,11 +203,11 @@ export async function essEmailHandler(event: ApplicationEvent) { const { finalStatus } = event.proposal; let templateId = ''; if (finalStatus === ProposalEndStatus.ACCEPTED) { - templateId = EmailTemplateId.ACCEPTED_PROPOSAL; + templateId = EmailTemplateName.ACCEPTED_PROPOSAL; } else if (finalStatus === ProposalEndStatus.REJECTED) { - templateId = EmailTemplateId.REJECTED_PROPOSAL; + templateId = EmailTemplateName.REJECTED_PROPOSAL; } else if (finalStatus === ProposalEndStatus.RESERVED) { - templateId = EmailTemplateId.RESERVED_PROPOSAL; + templateId = EmailTemplateName.RESERVED_PROPOSAL; } else { logger.logError('Failed email notification', { event }); @@ -276,7 +276,7 @@ export async function essEmailHandler(event: ApplicationEvent) { await sendInviteEmail( invite, inviter, - EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_VISIT_REGISTRATION + EmailTemplateName.USER_OFFICE_REGISTRATION_INVITATION_VISIT_REGISTRATION ).then(async () => { await eventBus.publish({ ...event, @@ -312,7 +312,7 @@ export async function essEmailHandler(event: ApplicationEvent) { await sendInviteEmail( invite, inviter, - EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_CO_PROPOSER + EmailTemplateName.USER_OFFICE_REGISTRATION_INVITATION_CO_PROPOSER ).then(async () => { await eventBus.publish({ ...event, @@ -338,7 +338,7 @@ export async function essEmailHandler(event: ApplicationEvent) { mailService .sendMail({ content: { - template: EmailTemplateId.REVIEW_REMINDER, + template: EmailTemplateName.REVIEW_REMINDER, }, substitution_data: { fapReviewerPreferredName: fapReviewer.preferredname, @@ -413,8 +413,8 @@ export async function essEmailHandler(event: ApplicationEvent) { const templateId = event.type === Event.VISIT_REGISTRATION_APPROVED - ? EmailTemplateId.VISIT_REGISTRATION_APPROVED - : EmailTemplateId.VISIT_REGISTRATION_CANCELLED; + ? EmailTemplateName.VISIT_REGISTRATION_APPROVED + : EmailTemplateName.VISIT_REGISTRATION_CANCELLED; mailService .sendMail({ @@ -465,7 +465,7 @@ export async function essEmailHandler(event: ApplicationEvent) { async function sendInviteEmail( invite: Invite, inviter: BasicUserDetails, - templateId: EmailTemplateId + templateId: EmailTemplateName ) { const mailService = container.resolve(Tokens.MailService); const inviteDataSource = container.resolve( diff --git a/apps/backend/src/eventHandlers/email/stfcEmailHandler.ts b/apps/backend/src/eventHandlers/email/stfcEmailHandler.ts index a4abf1c09b..2546be46f0 100644 --- a/apps/backend/src/eventHandlers/email/stfcEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/stfcEmailHandler.ts @@ -6,7 +6,7 @@ import { ApplicationEvent } from '../../events/applicationEvents'; import { Event } from '../../events/event.enum'; import EmailSettings from '../MailService/EmailSettings'; import { MailService } from '../MailService/MailService'; -import { EmailTemplateId } from './emailTemplateId'; +import { EmailTemplateName } from './emailTemplateName'; export async function stfcEmailHandler(event: ApplicationEvent) { //test for null @@ -36,7 +36,7 @@ export async function stfcEmailHandler(event: ApplicationEvent) { }))(event.call); const emailSettings = callCreationEmail( eventCallPartial, - EmailTemplateId.CALL_CREATED_EMAIL, + EmailTemplateName.CALL_CREATED_EMAIL, notificationEmailAddress ); diff --git a/apps/backend/src/models/Invite.ts b/apps/backend/src/models/Invite.ts index e406287693..177057c39e 100644 --- a/apps/backend/src/models/Invite.ts +++ b/apps/backend/src/models/Invite.ts @@ -1,4 +1,4 @@ -import { EmailTemplateId } from '../eventHandlers/email/emailTemplateId'; +import { EmailTemplateName } from '../eventHandlers/email/emailTemplateName'; export class Invite { constructor( @@ -11,6 +11,6 @@ export class Invite { public claimedByUserId: number | null, public isEmailSent: boolean, public expiresAt: Date | null, - public templateId: EmailTemplateId | null + public templateId: EmailTemplateName | null ) {} } diff --git a/apps/backend/src/mutations/FeedbackMutations.ts b/apps/backend/src/mutations/FeedbackMutations.ts index 055d4a08d1..73b9d98373 100644 --- a/apps/backend/src/mutations/FeedbackMutations.ts +++ b/apps/backend/src/mutations/FeedbackMutations.ts @@ -9,7 +9,7 @@ import { ExperimentDataSource } from '../datasources/ExperimentDataSource'; import { FeedbackDataSource } from '../datasources/FeedbackDataSource'; import { QuestionaryDataSource } from '../datasources/QuestionaryDataSource'; import { Authorized } from '../decorators'; -import { EmailTemplateId } from '../eventHandlers/email/emailTemplateId'; +import { EmailTemplateName } from '../eventHandlers/email/emailTemplateName'; import { MailService } from '../eventHandlers/MailService/MailService'; import { ExperimentStatus } from '../models/Experiment'; import { Feedback, FeedbackStatus } from '../models/Feedback'; @@ -327,7 +327,7 @@ export default class FeedbackMutations { try { const { results } = await this.mailService.sendMail({ content: { - template: EmailTemplateId.FEEDBACK_REQUEST, + template: EmailTemplateName.FEEDBACK_REQUEST, }, substitution_data: { teamleadPreferredname: teamLead.preferredname, diff --git a/apps/backend/src/mutations/InviteMutations.spec.ts b/apps/backend/src/mutations/InviteMutations.spec.ts index d623b0123f..4a120a4a7e 100644 --- a/apps/backend/src/mutations/InviteMutations.spec.ts +++ b/apps/backend/src/mutations/InviteMutations.spec.ts @@ -16,7 +16,7 @@ import { } from '../datasources/mockups/UserDataSource'; import { VisitDataSourceMock } from '../datasources/mockups/VisitDataSource'; import { VisitDataSource } from '../datasources/VisitDataSource'; -import { EmailTemplateId } from '../eventHandlers/email/emailTemplateId'; +import { EmailTemplateName } from '../eventHandlers/email/emailTemplateName'; import { MailService } from '../eventHandlers/MailService/MailService'; import { Event } from '../events/event.enum'; import { Invite } from '../models/Invite'; @@ -239,7 +239,7 @@ describe('Test Invite Mutations', () => { recipients: [{ address: email }], content: { template: - EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_CO_PROPOSER, + EmailTemplateName.USER_OFFICE_REGISTRATION_INVITATION_CO_PROPOSER, }, }) ); @@ -311,7 +311,7 @@ describe('Test Invite Mutations', () => { recipients: [{ address: email }], content: { template: - EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_VISIT_REGISTRATION, + EmailTemplateName.USER_OFFICE_REGISTRATION_INVITATION_VISIT_REGISTRATION, }, }) ); diff --git a/package-lock.json b/package-lock.json index 635dbc8f65..f3916220f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "wait-on": "^9.0.1" }, "devDependencies": { + "@types/pug": "^2.0.10", "@types/react-dom": "^19.2.2", "husky": "^9.0.10", "lint-staged": "^16.0.0" @@ -67,6 +68,13 @@ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==" }, + "node_modules/@types/pug": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.10.tgz", + "integrity": "sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", @@ -1373,6 +1381,12 @@ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==" }, + "@types/pug": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.10.tgz", + "integrity": "sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==", + "dev": true + }, "@types/react": { "version": "19.2.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", diff --git a/package.json b/package.json index 56146f8949..188ffabadf 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "wait-on": "^9.0.1" }, "devDependencies": { + "@types/pug": "^2.0.10", "@types/react-dom": "^19.2.2", "husky": "^9.0.10", "lint-staged": "^16.0.0" From 06990cfce669e3079fb61c9e9fc7afabdef045b8 Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Tue, 10 Feb 2026 14:16:35 +0100 Subject: [PATCH 49/62] fix: look for the email template file first and only use the DB if not found --- .../MailService/SMTPMailService.ts | 36 +++++++++++-------- package-lock.json | 6 ++-- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts b/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts index 36c64cc939..c8daf72927 100644 --- a/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts +++ b/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts @@ -118,20 +118,12 @@ export class SMTPMailService extends MailService { let templateBody = ''; let templateSubject = ''; - const emailTemplate = - await this.emailTemplateDataSource.getEmailTemplateByName( - options.content.template - ); - - if (emailTemplate) { - templateBody = emailTemplate.body; - templateSubject = emailTemplate.subject; - } else { - const templateBodyPath = - this.getEmailTemplatePath('html', options.content.template) + '.pug'; - const templateSubjectPath = - this.getEmailTemplatePath('subject', options.content.template) + '.pug'; + const templateBodyPath = + this.getEmailTemplatePath('html', options.content.template) + '.pug'; + const templateSubjectPath = + this.getEmailTemplatePath('subject', options.content.template) + '.pug'; + if (existsSync(templateBodyPath) && existsSync(templateSubjectPath)) { try { templateBody = readFileSync(templateBodyPath, 'utf-8'); templateSubject = readFileSync(templateSubjectPath, 'utf-8'); @@ -140,12 +132,28 @@ export class SMTPMailService extends MailService { error: error, }); + return null; + } + } else { + const emailTemplate = + await this.emailTemplateDataSource.getEmailTemplateByName( + options.content.template + ); + + if (emailTemplate) { + templateBody = emailTemplate.body; + templateSubject = emailTemplate.subject; + } else { + logger.logError('Email template not found', { + template: options.content.template, + }); + return null; } } return { - subject: pug.render(templateSubject, {}), + subject: pug.render(templateSubject, options.substitution_data || {}), body: pug.render(templateBody, options.substitution_data || {}), }; } diff --git a/package-lock.json b/package-lock.json index f3916220f8..d9ed59fbbf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -376,7 +376,8 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/delayed-stream": { "version": "1.0.0", @@ -1602,7 +1603,8 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true + "dev": true, + "peer": true }, "delayed-stream": { "version": "1.0.0", From 3634ed64c5624c7305367a6e89019d76a75d7067 Mon Sep 17 00:00:00 2001 From: Gergely Nyiri Date: Wed, 11 Feb 2026 14:32:21 +0100 Subject: [PATCH 50/62] add email template option to use subject and body from template file --- .../0202_CreateEmailTemplatesTable.sql | 5 ++- .../datasources/EmailTemplateDataSource.ts | 10 +++-- .../mockups/EmailTemplateDataSource.ts | 12 ++++++ .../postgres/EmailTemplateDataSource.ts | 4 ++ .../src/datasources/postgres/records.ts | 2 + .../MailService/SMTPMailService.ts | 40 +++++++++---------- apps/backend/src/models/EmailTemplate.ts | 1 + .../mutations/EmailTemplateMutation.spec.ts | 4 ++ .../src/mutations/EmailTemplateMutations.ts | 2 + .../mutations/CreateEmailTemplateMutation.ts | 11 +++-- .../mutations/UpdateEmailTemplateMutation.ts | 11 +++-- .../src/resolvers/types/EmailTemplate.ts | 7 +++- apps/e2e/cypress/e2e/statusActions.cy.ts | 2 + .../template/CreateUpdateEmailTemplate.tsx | 24 ++++++++--- .../emailTemplate/createEmailTemplate.graphql | 6 ++- .../fragment.emailTemplate.graphql | 1 + .../emailTemplate/updateEmailTemplate.graphql | 6 ++- 17 files changed, 103 insertions(+), 45 deletions(-) diff --git a/apps/backend/db_patches/0202_CreateEmailTemplatesTable.sql b/apps/backend/db_patches/0202_CreateEmailTemplatesTable.sql index e851b45c4d..c3a6f333de 100644 --- a/apps/backend/db_patches/0202_CreateEmailTemplatesTable.sql +++ b/apps/backend/db_patches/0202_CreateEmailTemplatesTable.sql @@ -7,8 +7,9 @@ BEGIN created_by INT NOT NULL REFERENCES users(user_id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), name varchar(100) NOT NULL UNIQUE, - description varchar(255) NOT NULL, - subject varchar(160) NOT NULL, + description varchar(255) NOT NULL, + use_template_file boolean DEFAULT FALSE, + subject varchar(160), body text ); END IF; diff --git a/apps/backend/src/datasources/EmailTemplateDataSource.ts b/apps/backend/src/datasources/EmailTemplateDataSource.ts index 96f5c58f4d..bd85c60156 100644 --- a/apps/backend/src/datasources/EmailTemplateDataSource.ts +++ b/apps/backend/src/datasources/EmailTemplateDataSource.ts @@ -12,16 +12,18 @@ export interface EmailTemplateDataSource { createdByUserId: number, name: string, description: string, - subject: string, - body: string + useTemplateFile: boolean, + subject?: string, + body?: string ): Promise; update( id: number, name: string, description: string, - subject: string, - body: string + useTemplateFile: boolean, + subject?: string, + body?: string ): Promise; delete(id: number): Promise; diff --git a/apps/backend/src/datasources/mockups/EmailTemplateDataSource.ts b/apps/backend/src/datasources/mockups/EmailTemplateDataSource.ts index 8b9132a16a..61c7081abd 100644 --- a/apps/backend/src/datasources/mockups/EmailTemplateDataSource.ts +++ b/apps/backend/src/datasources/mockups/EmailTemplateDataSource.ts @@ -11,6 +11,7 @@ export const dummyEmailTemplate = { subject: 'Welcome to Our Service', body: 'Hello, thank you for signing up for our service. We are excited to have you on board!', createdAt: '', + useTemplateFile: false, }; export class EmailTemplateDataSourceMock implements EmailTemplateDataSource { @@ -26,6 +27,7 @@ export class EmailTemplateDataSourceMock implements EmailTemplateDataSource { 1, 'Dummy Email Template', 'This is a dummy email template for testing purposes.', + false, 'Welcome to Our Service', 'Hello, thank you for signing up for our service. We are excited to have you on board!' ), @@ -34,6 +36,7 @@ export class EmailTemplateDataSourceMock implements EmailTemplateDataSource { 2, EmailTemplateName.PROPOSAL_CREATED, 'This is a dummy email template for testing purposes.', + false, 'Welcome to Our Service', 'Hello, thank you for signing up for our service. We are excited to have you on board!' ), @@ -42,6 +45,7 @@ export class EmailTemplateDataSourceMock implements EmailTemplateDataSource { 2, EmailTemplateName.ACCEPTED_PROPOSAL, 'This is a dummy email template for testing purposes.', + false, 'Welcome to Our Service', 'Hello, thank you for signing up for our service. We are excited to have you on board!' ), @@ -50,6 +54,7 @@ export class EmailTemplateDataSourceMock implements EmailTemplateDataSource { 2, EmailTemplateName.REJECTED_PROPOSAL, 'This is a dummy email template for testing purposes.', + false, 'Welcome to Our Service', 'Hello, thank you for signing up for our service. We are excited to have you on board!' ), @@ -58,6 +63,7 @@ export class EmailTemplateDataSourceMock implements EmailTemplateDataSource { 2, EmailTemplateName.RESERVED_PROPOSAL, 'This is a dummy email template for testing purposes.', + false, 'Welcome to Our Service', 'Hello, thank you for signing up for our service. We are excited to have you on board!' ), @@ -66,6 +72,7 @@ export class EmailTemplateDataSourceMock implements EmailTemplateDataSource { 2, EmailTemplateName.REVIEW_REMINDER, 'This is a dummy email template for testing purposes.', + false, 'Welcome to Our Service', 'Hello, thank you for signing up for our service. We are excited to have you on board!' ), @@ -74,6 +81,7 @@ export class EmailTemplateDataSourceMock implements EmailTemplateDataSource { 2, EmailTemplateName.INTERNAL_REVIEW_CREATED, 'This is a dummy email template for testing purposes.', + false, 'Welcome to Our Service', 'Hello, thank you for signing up for our service. We are excited to have you on board!' ), @@ -82,6 +90,7 @@ export class EmailTemplateDataSourceMock implements EmailTemplateDataSource { 2, EmailTemplateName.INTERNAL_REVIEW_DELETED, 'This is a dummy email template for testing purposes.', + false, 'Welcome to Our Service', 'Hello, thank you for signing up for our service. We are excited to have you on board!' ), @@ -90,6 +99,7 @@ export class EmailTemplateDataSourceMock implements EmailTemplateDataSource { 2, EmailTemplateName.INTERNAL_REVIEW_UPDATED, 'This is a dummy email template for testing purposes.', + false, 'Welcome to Our Service', 'Hello, thank you for signing up for our service. We are excited to have you on board!' ), @@ -119,6 +129,7 @@ export class EmailTemplateDataSourceMock implements EmailTemplateDataSource { createdByUserId: number, name: string, description: string, + use_template_file: boolean, subject: string, body: string ): Promise { @@ -129,6 +140,7 @@ export class EmailTemplateDataSourceMock implements EmailTemplateDataSource { id: number, name: string, description: string, + use_template_file: boolean, subject: string, body: string ): Promise { diff --git a/apps/backend/src/datasources/postgres/EmailTemplateDataSource.ts b/apps/backend/src/datasources/postgres/EmailTemplateDataSource.ts index 4470ac5666..59caeff0c2 100644 --- a/apps/backend/src/datasources/postgres/EmailTemplateDataSource.ts +++ b/apps/backend/src/datasources/postgres/EmailTemplateDataSource.ts @@ -69,6 +69,7 @@ export default class PostgresEmailTemplateDataSource createdByUserId: number, name: string, description: string, + useTemplateFile: boolean, subject: string, body: string ): Promise { @@ -80,6 +81,7 @@ export default class PostgresEmailTemplateDataSource description: description, subject: subject, body: body, + use_template_file: useTemplateFile, }, ['*'] ) @@ -97,6 +99,7 @@ export default class PostgresEmailTemplateDataSource emailTemplateId: number, name: string, description: string, + useTemplateFile: boolean, subject: string, body: string ): Promise { @@ -107,6 +110,7 @@ export default class PostgresEmailTemplateDataSource description: description, subject: subject, body: body, + use_template_file: useTemplateFile, }, ['*'] ) diff --git a/apps/backend/src/datasources/postgres/records.ts b/apps/backend/src/datasources/postgres/records.ts index 507ed36979..1105c1ca1c 100644 --- a/apps/backend/src/datasources/postgres/records.ts +++ b/apps/backend/src/datasources/postgres/records.ts @@ -380,6 +380,7 @@ export interface EmailTemplateRecord { readonly description: string; readonly subject: string; readonly body: string; + readonly use_template_file: boolean; } export interface PageTextRecord { @@ -1072,6 +1073,7 @@ export const createEmailTemplateObject = ( emailTemplate.created_by, emailTemplate.name, emailTemplate.description, + emailTemplate.use_template_file, emailTemplate.subject, emailTemplate.body ); diff --git a/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts b/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts index c8daf72927..c6d8e36b57 100644 --- a/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts +++ b/apps/backend/src/eventHandlers/MailService/SMTPMailService.ts @@ -115,15 +115,28 @@ export class SMTPMailService extends MailService { return { subject: '= ``', body: '' }; } + const emailTemplate = + await this.emailTemplateDataSource.getEmailTemplateByName( + options.content.template + ); + + if (!emailTemplate) { + logger.logError('Email template not found', { + template: options.content.template, + }); + + return null; + } + let templateBody = ''; let templateSubject = ''; - const templateBodyPath = - this.getEmailTemplatePath('html', options.content.template) + '.pug'; - const templateSubjectPath = - this.getEmailTemplatePath('subject', options.content.template) + '.pug'; + if (emailTemplate.useTemplateFile) { + const templateBodyPath = + this.getEmailTemplatePath('html', options.content.template) + '.pug'; + const templateSubjectPath = + this.getEmailTemplatePath('subject', options.content.template) + '.pug'; - if (existsSync(templateBodyPath) && existsSync(templateSubjectPath)) { try { templateBody = readFileSync(templateBodyPath, 'utf-8'); templateSubject = readFileSync(templateSubjectPath, 'utf-8'); @@ -135,21 +148,8 @@ export class SMTPMailService extends MailService { return null; } } else { - const emailTemplate = - await this.emailTemplateDataSource.getEmailTemplateByName( - options.content.template - ); - - if (emailTemplate) { - templateBody = emailTemplate.body; - templateSubject = emailTemplate.subject; - } else { - logger.logError('Email template not found', { - template: options.content.template, - }); - - return null; - } + templateBody = emailTemplate.body || ''; + templateSubject = emailTemplate.subject || ''; } return { diff --git a/apps/backend/src/models/EmailTemplate.ts b/apps/backend/src/models/EmailTemplate.ts index bbf71d7fad..37f2c88c8d 100644 --- a/apps/backend/src/models/EmailTemplate.ts +++ b/apps/backend/src/models/EmailTemplate.ts @@ -4,6 +4,7 @@ export class EmailTemplate { public createdByUserId: number, public name: string, public description: string, + public useTemplateFile: boolean, public subject: string, public body: string ) {} diff --git a/apps/backend/src/mutations/EmailTemplateMutation.spec.ts b/apps/backend/src/mutations/EmailTemplateMutation.spec.ts index 168aa7e5da..b90dbfaecb 100644 --- a/apps/backend/src/mutations/EmailTemplateMutation.spec.ts +++ b/apps/backend/src/mutations/EmailTemplateMutation.spec.ts @@ -17,6 +17,7 @@ describe('Test Email Template Mutations', () => { emailTemplateMutations.create(dummyUserWithRole, { name: 'Dummy Email Template', description: 'This is a dummy email template for testing purposes.', + useTemplateFile: false, subject: 'Welcome to Our Service', body: 'Hello, thank you for signing up for our service. We are excited to have you on board!', createdByUserId: 1, @@ -29,6 +30,7 @@ describe('Test Email Template Mutations', () => { emailTemplateMutations.create(null, { name: 'Dummy Email Template', description: 'This is a dummy email template for testing purposes.', + useTemplateFile: false, subject: 'Welcome to Our Service', body: 'Hello, thank you for signing up for our service. We are excited to have you on board!', createdByUserId: 1, @@ -40,6 +42,7 @@ describe('Test Email Template Mutations', () => { const emailTemplateToCreate = { name: 'Dummy Email Template', description: 'This is a dummy email template for testing purposes.', + useTemplateFile: false, subject: 'Welcome to Our Service', body: 'Hello, thank you for signing up for our service. We are excited to have you on board!', createdByUserId: 1, @@ -62,6 +65,7 @@ describe('Test Email Template Mutations', () => { id: 1, name: 'Dummy Email Template', description: 'This is a dummy email template for testing purposes.', + useTemplateFile: false, subject: 'Welcome to Our Service', body: 'Hello, thank you for signing up for our service. We are excited to have you on board!', createdByUserId: 1, diff --git a/apps/backend/src/mutations/EmailTemplateMutations.ts b/apps/backend/src/mutations/EmailTemplateMutations.ts index 41f352b9ac..d85f55c61e 100644 --- a/apps/backend/src/mutations/EmailTemplateMutations.ts +++ b/apps/backend/src/mutations/EmailTemplateMutations.ts @@ -32,6 +32,7 @@ export default class EmailTemplateMutations { args.createdByUserId, args.name, args.description, + args.useTemplateFile, args.subject, args.body ); @@ -56,6 +57,7 @@ export default class EmailTemplateMutations { args.id, args.name, args.description, + args.useTemplateFile, args.subject, args.body ); diff --git a/apps/backend/src/resolvers/mutations/CreateEmailTemplateMutation.ts b/apps/backend/src/resolvers/mutations/CreateEmailTemplateMutation.ts index edc82b954e..a7cc1ce2ac 100644 --- a/apps/backend/src/resolvers/mutations/CreateEmailTemplateMutation.ts +++ b/apps/backend/src/resolvers/mutations/CreateEmailTemplateMutation.ts @@ -22,11 +22,14 @@ export class CreateEmailTemplateInput { @Field(() => String) public description: string; - @Field(() => String) - public subject: string; + @Field(() => Boolean) + public useTemplateFile: boolean; - @Field(() => String) - public body: string; + @Field(() => String, { nullable: true }) + public subject?: string; + + @Field(() => String, { nullable: true }) + public body?: string; } @Resolver() diff --git a/apps/backend/src/resolvers/mutations/UpdateEmailTemplateMutation.ts b/apps/backend/src/resolvers/mutations/UpdateEmailTemplateMutation.ts index 85429560a4..13d7de55a1 100644 --- a/apps/backend/src/resolvers/mutations/UpdateEmailTemplateMutation.ts +++ b/apps/backend/src/resolvers/mutations/UpdateEmailTemplateMutation.ts @@ -25,11 +25,14 @@ export class UpdateEmailTemplateInput { @Field(() => String) public description: string; - @Field(() => String) - public subject: string; + @Field(() => Boolean) + public useTemplateFile: boolean; - @Field(() => String) - public body: string; + @Field(() => String, { nullable: true }) + public subject?: string; + + @Field(() => String, { nullable: true }) + public body?: string; } @Resolver() diff --git a/apps/backend/src/resolvers/types/EmailTemplate.ts b/apps/backend/src/resolvers/types/EmailTemplate.ts index fd13dbd875..8af481944b 100644 --- a/apps/backend/src/resolvers/types/EmailTemplate.ts +++ b/apps/backend/src/resolvers/types/EmailTemplate.ts @@ -16,9 +16,12 @@ export class EmailTemplate implements EmailTemplateOrigin { @Field(() => String, { nullable: true }) public description: string; - @Field(() => String) + @Field(() => Boolean) + public useTemplateFile: boolean; + + @Field(() => String, { nullable: true }) public subject: string; - @Field(() => String) + @Field(() => String, { nullable: true }) public body: string; } diff --git a/apps/e2e/cypress/e2e/statusActions.cy.ts b/apps/e2e/cypress/e2e/statusActions.cy.ts index ba507bf4c1..441f6ff6c1 100644 --- a/apps/e2e/cypress/e2e/statusActions.cy.ts +++ b/apps/e2e/cypress/e2e/statusActions.cy.ts @@ -46,6 +46,7 @@ context('Status actions tests', () => { createdByUserId: 0, name: 'my-first-email', description: 'My First Email', + useTemplateFile: false, subject: 'My First Subject', body: 'My First Body', }); @@ -54,6 +55,7 @@ context('Status actions tests', () => { createdByUserId: 0, name: 'my-second-email', description: 'My Second Email', + useTemplateFile: false, subject: 'My Second Subject', body: 'My Second Body', }); diff --git a/apps/frontend/src/components/template/CreateUpdateEmailTemplate.tsx b/apps/frontend/src/components/template/CreateUpdateEmailTemplate.tsx index 07ab7607fe..f278306c9d 100644 --- a/apps/frontend/src/components/template/CreateUpdateEmailTemplate.tsx +++ b/apps/frontend/src/components/template/CreateUpdateEmailTemplate.tsx @@ -5,6 +5,7 @@ import i18n from 'i18n'; import React from 'react'; import { useTranslation } from 'react-i18next'; +import CheckboxWithLabel from 'components/common/FormikUICheckboxWithLabel'; import TextField from 'components/common/FormikUITextField'; import UOLoader from 'components/common/UOLoader'; import { getCurrentUser } from 'context/UserContextProvider'; @@ -22,11 +23,15 @@ const CreateUpdateEmailTemplate = ({ }: CreateUpdateEmailTemplateProps) => { const { t } = useTranslation(); const { api, isExecutingCall } = useDataApiWithFeedback(); + // const [useTemplateFile, setUseTemplateFile] = useState( + // emailTemplate?.useTemplateFile || false + // ); const initialValues = { id: emailTemplate?.id || 0, name: emailTemplate?.name || '', description: emailTemplate?.description || '', + useTemplateFile: emailTemplate?.useTemplateFile || false, subject: emailTemplate?.subject || '', body: emailTemplate?.body || '', createdByUserId: @@ -62,7 +67,7 @@ const CreateUpdateEmailTemplate = ({ } }} > - {({ isValid }) => ( + {({ isValid, values }) => (
{(emailTemplate ? 'Update ' : 'Create new ') + @@ -90,6 +95,17 @@ const CreateUpdateEmailTemplate = ({ disabled={isExecutingCall} required /> +